move to 2.5.5
[anni] / lib / pleroma / web / common_api / utils.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.CommonAPI.Utils do
6   import Pleroma.Web.Gettext
7
8   alias Calendar.Strftime
9   alias Pleroma.Activity
10   alias Pleroma.Config
11   alias Pleroma.Conversation.Participation
12   alias Pleroma.Formatter
13   alias Pleroma.Object
14   alias Pleroma.Repo
15   alias Pleroma.User
16   alias Pleroma.Web.ActivityPub.Utils
17   alias Pleroma.Web.ActivityPub.Visibility
18   alias Pleroma.Web.CommonAPI.ActivityDraft
19   alias Pleroma.Web.MediaProxy
20   alias Pleroma.Web.Plugs.AuthenticationPlug
21   alias Pleroma.Web.Utils.Params
22
23   require Logger
24   require Pleroma.Constants
25
26   def attachments_from_ids(%{media_ids: ids, descriptions: desc}, user) do
27     attachments_from_ids_descs(ids, desc, user)
28   end
29
30   def attachments_from_ids(%{media_ids: ids}, user) do
31     attachments_from_ids_no_descs(ids, user)
32   end
33
34   def attachments_from_ids(_, _), do: []
35
36   def attachments_from_ids_no_descs([], _), do: []
37
38   def attachments_from_ids_no_descs(ids, user) do
39     Enum.map(ids, fn media_id ->
40       case get_attachment(media_id, user) do
41         %Object{data: data} -> data
42         _ -> nil
43       end
44     end)
45     |> Enum.reject(&is_nil/1)
46   end
47
48   def attachments_from_ids_descs([], _, _), do: []
49
50   def attachments_from_ids_descs(ids, descs_str, user) do
51     {_, descs} = Jason.decode(descs_str)
52
53     Enum.map(ids, fn media_id ->
54       with %Object{data: data} <- get_attachment(media_id, user) do
55         Map.put(data, "name", descs[media_id])
56       end
57     end)
58     |> Enum.reject(&is_nil/1)
59   end
60
61   defp get_attachment(media_id, user) do
62     with %Object{data: _data} = object <- Repo.get(Object, media_id),
63          :ok <- Object.authorize_access(object, user) do
64       object
65     else
66       _ -> nil
67     end
68   end
69
70   @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
71
72   def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
73     participation = Repo.preload(participation, :recipients)
74     {Enum.map(participation.recipients, & &1.ap_id), []}
75   end
76
77   def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do
78     to =
79       case visibility do
80         "public" -> [Pleroma.Constants.as_public() | draft.mentions]
81         "local" -> [Utils.as_local_public() | draft.mentions]
82       end
83
84     cc = [draft.user.follower_address]
85
86     if draft.in_reply_to do
87       {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
88     else
89       {to, cc}
90     end
91   end
92
93   def get_to_and_cc(%{visibility: "unlisted"} = draft) do
94     to = [draft.user.follower_address | draft.mentions]
95     cc = [Pleroma.Constants.as_public()]
96
97     if draft.in_reply_to do
98       {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
99     else
100       {to, cc}
101     end
102   end
103
104   def get_to_and_cc(%{visibility: "private"} = draft) do
105     {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
106     {[draft.user.follower_address | to], cc}
107   end
108
109   def get_to_and_cc(%{visibility: "direct"} = draft) do
110     # If the OP is a DM already, add the implicit actor.
111     if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do
112       {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
113     else
114       {draft.mentions, []}
115     end
116   end
117
118   def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
119
120   def get_addressed_users(_, to) when is_list(to) do
121     User.get_ap_ids_by_nicknames(to)
122   end
123
124   def get_addressed_users(mentioned_users, _), do: mentioned_users
125
126   def maybe_add_list_data(activity_params, user, {:list, list_id}) do
127     case Pleroma.List.get(list_id, user) do
128       %Pleroma.List{} = list ->
129         activity_params
130         |> put_in([:additional, "bcc"], [list.ap_id])
131         |> put_in([:additional, "listMessage"], list.ap_id)
132         |> put_in([:object, "listMessage"], list.ap_id)
133
134       _ ->
135         activity_params
136     end
137   end
138
139   def maybe_add_list_data(activity_params, _, _), do: activity_params
140
141   def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
142       when is_binary(expires_in) do
143     # In some cases mastofe sends out strings instead of integers
144     data
145     |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
146     |> make_poll_data()
147   end
148
149   def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
150       when is_list(options) do
151     limits = Config.get([:instance, :poll_limits])
152
153     with :ok <- validate_poll_expiration(expires_in, limits),
154          :ok <- validate_poll_options_amount(options, limits),
155          :ok <- validate_poll_options_length(options, limits) do
156       {option_notes, emoji} =
157         Enum.map_reduce(options, %{}, fn option, emoji ->
158           note = %{
159             "name" => option,
160             "type" => "Note",
161             "replies" => %{"type" => "Collection", "totalItems" => 0}
162           }
163
164           {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
165         end)
166
167       end_time =
168         DateTime.utc_now()
169         |> DateTime.add(expires_in)
170         |> DateTime.to_iso8601()
171
172       key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
173       poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
174
175       {:ok, {poll, emoji}}
176     end
177   end
178
179   def make_poll_data(%{"poll" => poll}) when is_map(poll) do
180     {:error, "Invalid poll"}
181   end
182
183   def make_poll_data(_data) do
184     {:ok, {%{}, %{}}}
185   end
186
187   defp validate_poll_options_amount(options, %{max_options: max_options}) do
188     if Enum.count(options) > max_options do
189       {:error, "Poll can't contain more than #{max_options} options"}
190     else
191       :ok
192     end
193   end
194
195   defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
196     if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
197       {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
198     else
199       :ok
200     end
201   end
202
203   defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
204     cond do
205       expires_in > max -> {:error, "Expiration date is too far in the future"}
206       expires_in < min -> {:error, "Expiration date is too soon"}
207       true -> :ok
208     end
209   end
210
211   def make_content_html(%ActivityDraft{} = draft) do
212     attachment_links =
213       draft.params
214       |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
215       |> Params.truthy_param?()
216
217     content_type = get_content_type(draft.params[:content_type])
218
219     options =
220       if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
221         [safe_mention: true]
222       else
223         []
224       end
225
226     draft.status
227     |> format_input(content_type, options)
228     |> maybe_add_attachments(draft.attachments, attachment_links)
229   end
230
231   def get_content_type(content_type) do
232     if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
233       content_type
234     else
235       "text/plain"
236     end
237   end
238
239   def make_context(_, %Participation{} = participation) do
240     Repo.preload(participation, :conversation).conversation.ap_id
241   end
242
243   def make_context(%Activity{data: %{"context" => context}}, _), do: context
244   def make_context(_, _), do: Utils.generate_context_id()
245
246   def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
247
248   def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
249     text = add_attachments(text, attachments)
250     {text, mentions, tags}
251   end
252
253   def add_attachments(text, attachments) do
254     attachment_text = Enum.map(attachments, &build_attachment_link/1)
255     Enum.join([text | attachment_text], "<br>")
256   end
257
258   defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
259     name = attachment["name"] || URI.decode(Path.basename(href))
260     href = MediaProxy.url(href)
261     "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
262   end
263
264   defp build_attachment_link(_), do: ""
265
266   def format_input(text, format, options \\ [])
267
268   @doc """
269   Formatting text to plain text, BBCode, HTML, or Markdown
270   """
271   def format_input(text, "text/plain", options) do
272     text
273     |> Formatter.html_escape("text/plain")
274     |> Formatter.linkify(options)
275     |> (fn {text, mentions, tags} ->
276           {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
277         end).()
278   end
279
280   def format_input(text, "text/bbcode", options) do
281     text
282     |> String.replace(~r/\r/, "")
283     |> Formatter.html_escape("text/plain")
284     |> BBCode.to_html()
285     |> (fn {:ok, html} -> html end).()
286     |> Formatter.linkify(options)
287   end
288
289   def format_input(text, "text/html", options) do
290     text
291     |> Formatter.html_escape("text/html")
292     |> Formatter.linkify(options)
293   end
294
295   def format_input(text, "text/markdown", options) do
296     text
297     |> Formatter.mentions_escape(options)
298     |> Formatter.markdown_to_html()
299     |> Formatter.linkify(options)
300     |> Formatter.html_escape("text/html")
301   end
302
303   def format_naive_asctime(date) do
304     date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
305   end
306
307   def format_asctime(date) do
308     Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
309   end
310
311   def date_to_asctime(date) when is_binary(date) do
312     with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
313       format_asctime(date)
314     else
315       _e ->
316         Logger.warn("Date #{date} in wrong format, must be ISO 8601")
317         ""
318     end
319   end
320
321   def date_to_asctime(date) do
322     Logger.warn("Date #{date} in wrong format, must be ISO 8601")
323     ""
324   end
325
326   def to_masto_date(%NaiveDateTime{} = date) do
327     date
328     |> NaiveDateTime.to_iso8601()
329     |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
330   end
331
332   def to_masto_date(date) when is_binary(date) do
333     with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
334       to_masto_date(date)
335     else
336       _ -> ""
337     end
338   end
339
340   def to_masto_date(_), do: ""
341
342   defp shortname(name) do
343     with max_length when max_length > 0 <-
344            Config.get([Pleroma.Upload, :filename_display_max_length], 30),
345          true <- String.length(name) > max_length do
346       String.slice(name, 0..max_length) <> "…"
347     else
348       _ -> name
349     end
350   end
351
352   @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
353   def confirm_current_password(user, password) do
354     with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
355          true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
356       {:ok, db_user}
357     else
358       _ -> {:error, dgettext("errors", "Invalid password.")}
359     end
360   end
361
362   def maybe_notify_to_recipients(
363         recipients,
364         %Activity{data: %{"to" => to, "type" => _type}} = _activity
365       ) do
366     recipients ++ to
367   end
368
369   def maybe_notify_to_recipients(recipients, _), do: recipients
370
371   def maybe_notify_mentioned_recipients(
372         recipients,
373         %Activity{data: %{"to" => _to, "type" => type} = data} = activity
374       )
375       when type == "Create" do
376     object = Object.normalize(activity, fetch: false)
377
378     object_data =
379       cond do
380         not is_nil(object) ->
381           object.data
382
383         is_map(data["object"]) ->
384           data["object"]
385
386         true ->
387           %{}
388       end
389
390     tagged_mentions = maybe_extract_mentions(object_data)
391
392     recipients ++ tagged_mentions
393   end
394
395   def maybe_notify_mentioned_recipients(recipients, _), do: recipients
396
397   def maybe_notify_subscribers(
398         recipients,
399         %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity
400       ) do
401     # Do not notify subscribers if author is making a reply
402     with %Object{data: object} <- Object.normalize(activity, fetch: false),
403          nil <- object["inReplyTo"],
404          %User{} = user <- User.get_cached_by_ap_id(actor) do
405       subscriber_ids =
406         user
407         |> User.subscriber_users()
408         |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
409         |> Enum.map(& &1.ap_id)
410
411       recipients ++ subscriber_ids
412     else
413       _e -> recipients
414     end
415   end
416
417   def maybe_notify_subscribers(recipients, _), do: recipients
418
419   def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
420     with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
421       user
422       |> User.get_followers()
423       |> Enum.map(& &1.ap_id)
424       |> Enum.concat(recipients)
425     else
426       _e -> recipients
427     end
428   end
429
430   def maybe_notify_followers(recipients, _), do: recipients
431
432   def maybe_extract_mentions(%{"tag" => tag}) do
433     tag
434     |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
435     |> Enum.map(fn x -> x["href"] end)
436     |> Enum.uniq()
437   end
438
439   def maybe_extract_mentions(_), do: []
440
441   def make_report_content_html(nil), do: {:ok, {nil, [], []}}
442
443   def make_report_content_html(comment) do
444     max_size = Config.get([:instance, :max_report_comment_size], 1000)
445
446     if String.length(comment) <= max_size do
447       {:ok, format_input(comment, "text/plain")}
448     else
449       {:error,
450        dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
451     end
452   end
453
454   def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
455       when is_list(status_ids) do
456     {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
457   end
458
459   def get_report_statuses(_, _), do: {:ok, nil}
460
461   def validate_character_limit("" = _full_payload, [] = _attachments) do
462     {:error, dgettext("errors", "Cannot post an empty status without attachments")}
463   end
464
465   def validate_character_limit(full_payload, _attachments) do
466     limit = Config.get([:instance, :limit])
467     length = String.length(full_payload)
468
469     if length <= limit do
470       :ok
471     else
472       {:error, dgettext("errors", "The status is over the character limit")}
473     end
474   end
475
476   def validate_attachments_count([] = _attachments) do
477     :ok
478   end
479
480   def validate_attachments_count(attachments) do
481     limit = Config.get([:instance, :max_media_attachments])
482     count = length(attachments)
483
484     if count <= limit do
485       :ok
486     else
487       {:error, dgettext("errors", "Too many attachments")}
488     end
489   end
490 end