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