83a745b58bec926dca90e64cfa4eae1a3f735a61
[akkoma] / lib / pleroma / web / common_api / utils.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.CommonAPI.Utils do
6 alias Calendar.Strftime
7 alias Comeonin.Pbkdf2
8 alias Pleroma.Activity
9 alias Pleroma.Config
10 alias Pleroma.Formatter
11 alias Pleroma.List
12 alias Pleroma.Object
13 alias Pleroma.Repo
14 alias Pleroma.User
15 alias Pleroma.Web.ActivityPub.Utils
16 alias Pleroma.Web.ActivityPub.Visibility
17 alias Pleroma.Web.Endpoint
18 alias Pleroma.Web.MediaProxy
19
20 require Logger
21
22 # This is a hack for twidere.
23 def get_by_id_or_ap_id(id) do
24 activity =
25 Activity.get_by_id_with_object(id) || Activity.get_create_by_object_ap_id_with_object(id)
26
27 activity &&
28 if activity.data["type"] == "Create" do
29 activity
30 else
31 Activity.get_create_by_object_ap_id_with_object(activity.data["object"])
32 end
33 end
34
35 def get_replied_to_activity(""), do: nil
36
37 def get_replied_to_activity(id) when not is_nil(id) do
38 Activity.get_by_id(id)
39 end
40
41 def get_replied_to_activity(_), do: nil
42
43 def attachments_from_ids(data) do
44 if Map.has_key?(data, "descriptions") do
45 attachments_from_ids_descs(data["media_ids"], data["descriptions"])
46 else
47 attachments_from_ids_no_descs(data["media_ids"])
48 end
49 end
50
51 def attachments_from_ids_no_descs(ids) do
52 Enum.map(ids || [], fn media_id ->
53 Repo.get(Object, media_id).data
54 end)
55 end
56
57 def attachments_from_ids_descs(ids, descs_str) do
58 {_, descs} = Jason.decode(descs_str)
59
60 Enum.map(ids || [], fn media_id ->
61 Map.put(Repo.get(Object, media_id).data, "name", descs[media_id])
62 end)
63 end
64
65 def to_for_user_and_mentions(user, mentions, inReplyTo, "public") do
66 mentioned_users = Enum.map(mentions, fn {_, %{ap_id: ap_id}} -> ap_id end)
67
68 to = ["https://www.w3.org/ns/activitystreams#Public" | mentioned_users]
69 cc = [user.follower_address]
70
71 if inReplyTo do
72 {Enum.uniq([inReplyTo.data["actor"] | to]), cc}
73 else
74 {to, cc}
75 end
76 end
77
78 def to_for_user_and_mentions(user, mentions, inReplyTo, "unlisted") do
79 mentioned_users = Enum.map(mentions, fn {_, %{ap_id: ap_id}} -> ap_id end)
80
81 to = [user.follower_address | mentioned_users]
82 cc = ["https://www.w3.org/ns/activitystreams#Public"]
83
84 if inReplyTo do
85 {Enum.uniq([inReplyTo.data["actor"] | to]), cc}
86 else
87 {to, cc}
88 end
89 end
90
91 def to_for_user_and_mentions(user, mentions, inReplyTo, "private") do
92 {to, cc} = to_for_user_and_mentions(user, mentions, inReplyTo, "direct")
93 {[user.follower_address | to], cc}
94 end
95
96 def to_for_user_and_mentions(_user, mentions, inReplyTo, "direct") do
97 mentioned_users = Enum.map(mentions, fn {_, %{ap_id: ap_id}} -> ap_id end)
98
99 if inReplyTo do
100 {Enum.uniq([inReplyTo.data["actor"] | mentioned_users]), []}
101 else
102 {mentioned_users, []}
103 end
104 end
105
106 def to_for_user_and_mentions(_user, _mentions, _inReplyTo, _), do: {[], []}
107
108 def bcc_for_list(user, {:list, list_id}) do
109 with {_, %List{} = list} <- {:list, List.get(list_id, user)},
110 {:ok, following} <- List.get_following(list) do
111 {:ok, Enum.map(following, & &1.ap_id)}
112 else
113 {:list, _} -> {:error, "List not found"}
114 err -> err
115 end
116 end
117
118 def bcc_for_list(_, _), do: {:ok, []}
119
120 def make_content_html(
121 status,
122 attachments,
123 data,
124 visibility
125 ) do
126 no_attachment_links =
127 data
128 |> Map.get("no_attachment_links", Config.get([:instance, :no_attachment_links]))
129 |> Kernel.in([true, "true"])
130
131 content_type = get_content_type(data["content_type"])
132
133 options =
134 if visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
135 [safe_mention: true]
136 else
137 []
138 end
139
140 status
141 |> format_input(content_type, options)
142 |> maybe_add_attachments(attachments, no_attachment_links)
143 |> maybe_add_nsfw_tag(data)
144 end
145
146 defp get_content_type(content_type) do
147 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
148 content_type
149 else
150 "text/plain"
151 end
152 end
153
154 defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
155 when sensitive in [true, "True", "true", "1"] do
156 {text, mentions, [{"#nsfw", "nsfw"} | tags]}
157 end
158
159 defp maybe_add_nsfw_tag(data, _), do: data
160
161 def make_context(%Activity{data: %{"context" => context}}), do: context
162 def make_context(_), do: Utils.generate_context_id()
163
164 def maybe_add_attachments(parsed, _attachments, true = _no_links), do: parsed
165
166 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
167 text = add_attachments(text, attachments)
168 {text, mentions, tags}
169 end
170
171 def add_attachments(text, attachments) do
172 attachment_text =
173 Enum.map(attachments, fn
174 %{"url" => [%{"href" => href} | _]} = attachment ->
175 name = attachment["name"] || URI.decode(Path.basename(href))
176 href = MediaProxy.url(href)
177 "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
178
179 _ ->
180 ""
181 end)
182
183 Enum.join([text | attachment_text], "<br>")
184 end
185
186 def format_input(text, format, options \\ [])
187
188 @doc """
189 Formatting text to plain text.
190 """
191 def format_input(text, "text/plain", options) do
192 text
193 |> Formatter.html_escape("text/plain")
194 |> Formatter.linkify(options)
195 |> (fn {text, mentions, tags} ->
196 {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
197 end).()
198 end
199
200 @doc """
201 Formatting text to html.
202 """
203 def format_input(text, "text/html", options) do
204 text
205 |> Formatter.html_escape("text/html")
206 |> Formatter.linkify(options)
207 end
208
209 @doc """
210 Formatting text to markdown.
211 """
212 def format_input(text, "text/markdown", options) do
213 text
214 |> Formatter.mentions_escape(options)
215 |> Earmark.as_html!()
216 |> Formatter.linkify(options)
217 |> Formatter.html_escape("text/html")
218 end
219
220 def make_note_data(
221 actor,
222 to,
223 context,
224 content_html,
225 attachments,
226 in_reply_to,
227 tags,
228 cw \\ nil,
229 cc \\ []
230 ) do
231 object = %{
232 "type" => "Note",
233 "to" => to,
234 "cc" => cc,
235 "content" => content_html,
236 "summary" => cw,
237 "context" => context,
238 "attachment" => attachments,
239 "actor" => actor,
240 "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq()
241 }
242
243 if in_reply_to do
244 in_reply_to_object = Object.normalize(in_reply_to)
245
246 object
247 |> Map.put("inReplyTo", in_reply_to_object.data["id"])
248 else
249 object
250 end
251 end
252
253 def format_naive_asctime(date) do
254 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
255 end
256
257 def format_asctime(date) do
258 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
259 end
260
261 def date_to_asctime(date) when is_binary(date) do
262 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
263 format_asctime(date)
264 else
265 _e ->
266 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
267 ""
268 end
269 end
270
271 def date_to_asctime(date) do
272 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
273 ""
274 end
275
276 def to_masto_date(%NaiveDateTime{} = date) do
277 date
278 |> NaiveDateTime.to_iso8601()
279 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
280 end
281
282 def to_masto_date(date) do
283 try do
284 date
285 |> NaiveDateTime.from_iso8601!()
286 |> NaiveDateTime.to_iso8601()
287 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
288 rescue
289 _e -> ""
290 end
291 end
292
293 defp shortname(name) do
294 if String.length(name) < 30 do
295 name
296 else
297 String.slice(name, 0..30) <> "…"
298 end
299 end
300
301 def confirm_current_password(user, password) do
302 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
303 true <- Pbkdf2.checkpw(password, db_user.password_hash) do
304 {:ok, db_user}
305 else
306 _ -> {:error, "Invalid password."}
307 end
308 end
309
310 def emoji_from_profile(%{info: _info} = user) do
311 (Formatter.get_emoji(user.bio) ++ Formatter.get_emoji(user.name))
312 |> Enum.map(fn {shortcode, url, _} ->
313 %{
314 "type" => "Emoji",
315 "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{url}"},
316 "name" => ":#{shortcode}:"
317 }
318 end)
319 end
320
321 def maybe_notify_to_recipients(
322 recipients,
323 %Activity{data: %{"to" => to, "type" => _type}} = _activity
324 ) do
325 recipients ++ to
326 end
327
328 def maybe_notify_mentioned_recipients(
329 recipients,
330 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
331 )
332 when type == "Create" do
333 object = Object.normalize(activity)
334
335 object_data =
336 cond do
337 !is_nil(object) ->
338 object.data
339
340 is_map(data["object"]) ->
341 data["object"]
342
343 true ->
344 %{}
345 end
346
347 tagged_mentions = maybe_extract_mentions(object_data)
348
349 recipients ++ tagged_mentions
350 end
351
352 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
353
354 def maybe_notify_subscribers(
355 recipients,
356 %Activity{data: %{"actor" => actor, "type" => type}} = activity
357 )
358 when type == "Create" do
359 with %User{} = user <- User.get_cached_by_ap_id(actor) do
360 subscriber_ids =
361 user
362 |> User.subscribers()
363 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
364 |> Enum.map(& &1.ap_id)
365
366 recipients ++ subscriber_ids
367 end
368 end
369
370 def maybe_notify_subscribers(recipients, _), do: recipients
371
372 def maybe_extract_mentions(%{"tag" => tag}) do
373 tag
374 |> Enum.filter(fn x -> is_map(x) end)
375 |> Enum.filter(fn x -> x["type"] == "Mention" end)
376 |> Enum.map(fn x -> x["href"] end)
377 end
378
379 def maybe_extract_mentions(_), do: []
380
381 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
382
383 def make_report_content_html(comment) do
384 max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000)
385
386 if String.length(comment) <= max_size do
387 {:ok, format_input(comment, "text/plain")}
388 else
389 {:error, "Comment must be up to #{max_size} characters"}
390 end
391 end
392
393 def get_report_statuses(%User{ap_id: actor}, %{"status_ids" => status_ids}) do
394 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
395 end
396
397 def get_report_statuses(_, _), do: {:ok, nil}
398
399 # DEPRECATED mostly, context objects are now created at insertion time.
400 def context_to_conversation_id(context) do
401 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
402 id
403 else
404 _e ->
405 changeset = Object.context_mapping(context)
406
407 case Repo.insert(changeset) do
408 {:ok, %{id: id}} ->
409 id
410
411 # This should be solved by an upsert, but it seems ecto
412 # has problems accessing the constraint inside the jsonb.
413 {:error, _} ->
414 Object.get_cached_by_ap_id(context).id
415 end
416 end
417 end
418
419 def conversation_id_to_context(id) do
420 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
421 context
422 else
423 _e ->
424 {:error, "No such conversation"}
425 end
426 end
427 end