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