Merge branch 'develop' into chore/elixir-1.11
[akkoma] / lib / pleroma / web / common_api / utils.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 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 import Pleroma.Web.ControllerHelper, only: [truthy_param?: 1]
8
9 alias Calendar.Strftime
10 alias Pleroma.Activity
11 alias Pleroma.Config
12 alias Pleroma.Conversation.Participation
13 alias Pleroma.Formatter
14 alias Pleroma.Object
15 alias Pleroma.Repo
16 alias Pleroma.User
17 alias Pleroma.Web.ActivityPub.Utils
18 alias Pleroma.Web.ActivityPub.Visibility
19 alias Pleroma.Web.MediaProxy
20 alias Pleroma.Web.Plugs.AuthenticationPlug
21
22 require Logger
23 require Pleroma.Constants
24
25 def attachments_from_ids(%{media_ids: ids, descriptions: desc}) do
26 attachments_from_ids_descs(ids, desc)
27 end
28
29 def attachments_from_ids(%{media_ids: ids}) do
30 attachments_from_ids_no_descs(ids)
31 end
32
33 def attachments_from_ids(_), do: []
34
35 def attachments_from_ids_no_descs([]), do: []
36
37 def attachments_from_ids_no_descs(ids) do
38 Enum.map(ids, fn media_id ->
39 case Repo.get(Object, media_id) do
40 %Object{data: data} -> data
41 _ -> nil
42 end
43 end)
44 |> Enum.reject(&is_nil/1)
45 end
46
47 def attachments_from_ids_descs([], _), do: []
48
49 def attachments_from_ids_descs(ids, descs_str) do
50 {_, descs} = Jason.decode(descs_str)
51
52 Enum.map(ids, fn media_id ->
53 case Repo.get(Object, media_id) do
54 %Object{data: data} ->
55 Map.put(data, "name", descs[media_id])
56
57 _ ->
58 nil
59 end
60 end)
61 |> Enum.reject(&is_nil/1)
62 end
63
64 @spec get_to_and_cc(
65 User.t(),
66 list(String.t()),
67 Activity.t() | nil,
68 String.t(),
69 Participation.t() | nil
70 ) :: {list(String.t()), list(String.t())}
71
72 def get_to_and_cc(_, _, _, _, %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(user, mentioned_users, inReplyTo, "public", _) do
78 to = [Pleroma.Constants.as_public() | mentioned_users]
79 cc = [user.follower_address]
80
81 if inReplyTo do
82 {Enum.uniq([inReplyTo.data["actor"] | to]), cc}
83 else
84 {to, cc}
85 end
86 end
87
88 def get_to_and_cc(user, mentioned_users, inReplyTo, "unlisted", _) do
89 to = [user.follower_address | mentioned_users]
90 cc = [Pleroma.Constants.as_public()]
91
92 if inReplyTo do
93 {Enum.uniq([inReplyTo.data["actor"] | to]), cc}
94 else
95 {to, cc}
96 end
97 end
98
99 def get_to_and_cc(user, mentioned_users, inReplyTo, "private", _) do
100 {to, cc} = get_to_and_cc(user, mentioned_users, inReplyTo, "direct", nil)
101 {[user.follower_address | to], cc}
102 end
103
104 def get_to_and_cc(_user, mentioned_users, inReplyTo, "direct", _) do
105 # If the OP is a DM already, add the implicit actor.
106 if inReplyTo && Visibility.is_direct?(inReplyTo) do
107 {Enum.uniq([inReplyTo.data["actor"] | mentioned_users]), []}
108 else
109 {mentioned_users, []}
110 end
111 end
112
113 def get_to_and_cc(_user, mentions, _inReplyTo, {:list, _}, _), 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 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(
207 status,
208 attachments,
209 data,
210 visibility
211 ) do
212 attachment_links =
213 data
214 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
215 |> truthy_param?()
216
217 content_type = get_content_type(data[:content_type])
218
219 options =
220 if visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
221 [safe_mention: true]
222 else
223 []
224 end
225
226 status
227 |> format_input(content_type, options)
228 |> maybe_add_attachments(attachments, attachment_links)
229 |> maybe_add_nsfw_tag(data)
230 end
231
232 defp get_content_type(content_type) do
233 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
234 content_type
235 else
236 "text/plain"
237 end
238 end
239
240 defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
241 when sensitive in [true, "True", "true", "1"] do
242 {text, mentions, [{"#nsfw", "nsfw"} | tags]}
243 end
244
245 defp maybe_add_nsfw_tag(data, _), do: data
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 |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer})
307 |> Formatter.linkify(options)
308 |> Formatter.html_escape("text/html")
309 end
310
311 def make_note_data(
312 actor,
313 to,
314 context,
315 content_html,
316 attachments,
317 in_reply_to,
318 tags,
319 summary \\ nil,
320 cc \\ [],
321 sensitive \\ false,
322 extra_params \\ %{}
323 ) do
324 %{
325 "type" => "Note",
326 "to" => to,
327 "cc" => cc,
328 "content" => content_html,
329 "summary" => summary,
330 "sensitive" => truthy_param?(sensitive),
331 "context" => context,
332 "attachment" => attachments,
333 "actor" => actor,
334 "tag" => Keyword.values(tags) |> Enum.uniq()
335 }
336 |> add_in_reply_to(in_reply_to)
337 |> Map.merge(extra_params)
338 end
339
340 defp add_in_reply_to(object, nil), do: object
341
342 defp add_in_reply_to(object, in_reply_to) do
343 with %Object{} = in_reply_to_object <- Object.normalize(in_reply_to) do
344 Map.put(object, "inReplyTo", in_reply_to_object.data["id"])
345 else
346 _ -> object
347 end
348 end
349
350 def format_naive_asctime(date) do
351 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
352 end
353
354 def format_asctime(date) do
355 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
356 end
357
358 def date_to_asctime(date) when is_binary(date) do
359 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
360 format_asctime(date)
361 else
362 _e ->
363 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
364 ""
365 end
366 end
367
368 def date_to_asctime(date) do
369 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
370 ""
371 end
372
373 def to_masto_date(%NaiveDateTime{} = date) do
374 date
375 |> NaiveDateTime.to_iso8601()
376 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
377 end
378
379 def to_masto_date(date) when is_binary(date) do
380 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
381 to_masto_date(date)
382 else
383 _ -> ""
384 end
385 end
386
387 def to_masto_date(_), do: ""
388
389 defp shortname(name) do
390 with max_length when max_length > 0 <-
391 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
392 true <- String.length(name) > max_length do
393 String.slice(name, 0..max_length) <> "…"
394 else
395 _ -> name
396 end
397 end
398
399 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
400 def confirm_current_password(user, password) do
401 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
402 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
403 {:ok, db_user}
404 else
405 _ -> {:error, dgettext("errors", "Invalid password.")}
406 end
407 end
408
409 def maybe_notify_to_recipients(
410 recipients,
411 %Activity{data: %{"to" => to, "type" => _type}} = _activity
412 ) do
413 recipients ++ to
414 end
415
416 def maybe_notify_to_recipients(recipients, _), do: recipients
417
418 def maybe_notify_mentioned_recipients(
419 recipients,
420 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
421 )
422 when type == "Create" do
423 object = Object.normalize(activity, false)
424
425 object_data =
426 cond do
427 not is_nil(object) ->
428 object.data
429
430 is_map(data["object"]) ->
431 data["object"]
432
433 true ->
434 %{}
435 end
436
437 tagged_mentions = maybe_extract_mentions(object_data)
438
439 recipients ++ tagged_mentions
440 end
441
442 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
443
444 # Do not notify subscribers if author is making a reply
445 def maybe_notify_subscribers(recipients, %Activity{
446 object: %Object{data: %{"inReplyTo" => _ap_id}}
447 }) do
448 recipients
449 end
450
451 def maybe_notify_subscribers(
452 recipients,
453 %Activity{data: %{"actor" => actor, "type" => type}} = activity
454 )
455 when type == "Create" do
456 with %User{} = user <- User.get_cached_by_ap_id(actor) do
457 subscriber_ids =
458 user
459 |> User.subscriber_users()
460 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
461 |> Enum.map(& &1.ap_id)
462
463 recipients ++ subscriber_ids
464 else
465 _e -> recipients
466 end
467 end
468
469 def maybe_notify_subscribers(recipients, _), do: recipients
470
471 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
472 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
473 user
474 |> User.get_followers()
475 |> Enum.map(& &1.ap_id)
476 |> Enum.concat(recipients)
477 else
478 _e -> recipients
479 end
480 end
481
482 def maybe_notify_followers(recipients, _), do: recipients
483
484 def maybe_extract_mentions(%{"tag" => tag}) do
485 tag
486 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
487 |> Enum.map(fn x -> x["href"] end)
488 |> Enum.uniq()
489 end
490
491 def maybe_extract_mentions(_), do: []
492
493 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
494
495 def make_report_content_html(comment) do
496 max_size = Config.get([:instance, :max_report_comment_size], 1000)
497
498 if String.length(comment) <= max_size do
499 {:ok, format_input(comment, "text/plain")}
500 else
501 {:error,
502 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
503 end
504 end
505
506 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
507 when is_list(status_ids) do
508 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
509 end
510
511 def get_report_statuses(_, _), do: {:ok, nil}
512
513 # DEPRECATED mostly, context objects are now created at insertion time.
514 def context_to_conversation_id(context) do
515 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
516 id
517 else
518 _e ->
519 changeset = Object.context_mapping(context)
520
521 case Repo.insert(changeset) do
522 {:ok, %{id: id}} ->
523 id
524
525 # This should be solved by an upsert, but it seems ecto
526 # has problems accessing the constraint inside the jsonb.
527 {:error, _} ->
528 Object.get_cached_by_ap_id(context).id
529 end
530 end
531 end
532
533 def conversation_id_to_context(id) do
534 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
535 context
536 else
537 _e ->
538 {:error, dgettext("errors", "No such conversation")}
539 end
540 end
541
542 def validate_character_limit("" = _full_payload, [] = _attachments) do
543 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
544 end
545
546 def validate_character_limit(full_payload, _attachments) do
547 limit = Config.get([:instance, :limit])
548 length = String.length(full_payload)
549
550 if length <= limit do
551 :ok
552 else
553 {:error, dgettext("errors", "The status is over the character limit")}
554 end
555 end
556 end