Merge branch 'feature/1734-user-deletion' into 'develop'
[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.Plugs.AuthenticationPlug
16 alias Pleroma.Repo
17 alias Pleroma.User
18 alias Pleroma.Web.ActivityPub.Utils
19 alias Pleroma.Web.ActivityPub.Visibility
20 alias Pleroma.Web.MediaProxy
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 inReplyTo do
106 {Enum.uniq([inReplyTo.data["actor"] | mentioned_users]), []}
107 else
108 {mentioned_users, []}
109 end
110 end
111
112 def get_to_and_cc(_user, mentions, _inReplyTo, {:list, _}, _), do: {mentions, []}
113
114 def get_addressed_users(_, to) when is_list(to) do
115 User.get_ap_ids_by_nicknames(to)
116 end
117
118 def get_addressed_users(mentioned_users, _), do: mentioned_users
119
120 def maybe_add_list_data(activity_params, user, {:list, list_id}) do
121 case Pleroma.List.get(list_id, user) do
122 %Pleroma.List{} = list ->
123 activity_params
124 |> put_in([:additional, "bcc"], [list.ap_id])
125 |> put_in([:additional, "listMessage"], list.ap_id)
126 |> put_in([:object, "listMessage"], list.ap_id)
127
128 _ ->
129 activity_params
130 end
131 end
132
133 def maybe_add_list_data(activity_params, _, _), do: activity_params
134
135 def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
136 when is_binary(expires_in) do
137 # In some cases mastofe sends out strings instead of integers
138 data
139 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
140 |> make_poll_data()
141 end
142
143 def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
144 when is_list(options) do
145 limits = Pleroma.Config.get([:instance, :poll_limits])
146
147 with :ok <- validate_poll_expiration(expires_in, limits),
148 :ok <- validate_poll_options_amount(options, limits),
149 :ok <- validate_poll_options_length(options, limits) do
150 {option_notes, emoji} =
151 Enum.map_reduce(options, %{}, fn option, emoji ->
152 note = %{
153 "name" => option,
154 "type" => "Note",
155 "replies" => %{"type" => "Collection", "totalItems" => 0}
156 }
157
158 {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
159 end)
160
161 end_time =
162 DateTime.utc_now()
163 |> DateTime.add(expires_in)
164 |> DateTime.to_iso8601()
165
166 key = if truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
167 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
168
169 {:ok, {poll, emoji}}
170 end
171 end
172
173 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
174 {:error, "Invalid poll"}
175 end
176
177 def make_poll_data(_data) do
178 {:ok, {%{}, %{}}}
179 end
180
181 defp validate_poll_options_amount(options, %{max_options: max_options}) do
182 if Enum.count(options) > max_options do
183 {:error, "Poll can't contain more than #{max_options} options"}
184 else
185 :ok
186 end
187 end
188
189 defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
190 if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
191 {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
192 else
193 :ok
194 end
195 end
196
197 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
198 cond do
199 expires_in > max -> {:error, "Expiration date is too far in the future"}
200 expires_in < min -> {:error, "Expiration date is too soon"}
201 true -> :ok
202 end
203 end
204
205 def make_content_html(
206 status,
207 attachments,
208 data,
209 visibility
210 ) do
211 attachment_links =
212 data
213 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
214 |> truthy_param?()
215
216 content_type = get_content_type(data[:content_type])
217
218 options =
219 if visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
220 [safe_mention: true]
221 else
222 []
223 end
224
225 status
226 |> format_input(content_type, options)
227 |> maybe_add_attachments(attachments, attachment_links)
228 |> maybe_add_nsfw_tag(data)
229 end
230
231 defp 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 defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
240 when sensitive in [true, "True", "true", "1"] do
241 {text, mentions, [{"#nsfw", "nsfw"} | tags]}
242 end
243
244 defp maybe_add_nsfw_tag(data, _), do: data
245
246 def make_context(_, %Participation{} = participation) do
247 Repo.preload(participation, :conversation).conversation.ap_id
248 end
249
250 def make_context(%Activity{data: %{"context" => context}}, _), do: context
251 def make_context(_, _), do: Utils.generate_context_id()
252
253 def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
254
255 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
256 text = add_attachments(text, attachments)
257 {text, mentions, tags}
258 end
259
260 def add_attachments(text, attachments) do
261 attachment_text = Enum.map(attachments, &build_attachment_link/1)
262 Enum.join([text | attachment_text], "<br>")
263 end
264
265 defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
266 name = attachment["name"] || URI.decode(Path.basename(href))
267 href = MediaProxy.url(href)
268 "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
269 end
270
271 defp build_attachment_link(_), do: ""
272
273 def format_input(text, format, options \\ [])
274
275 @doc """
276 Formatting text to plain text.
277 """
278 def format_input(text, "text/plain", options) do
279 text
280 |> Formatter.html_escape("text/plain")
281 |> Formatter.linkify(options)
282 |> (fn {text, mentions, tags} ->
283 {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
284 end).()
285 end
286
287 @doc """
288 Formatting text as BBCode.
289 """
290 def format_input(text, "text/bbcode", options) do
291 text
292 |> String.replace(~r/\r/, "")
293 |> Formatter.html_escape("text/plain")
294 |> BBCode.to_html()
295 |> (fn {:ok, html} -> html end).()
296 |> Formatter.linkify(options)
297 end
298
299 @doc """
300 Formatting text to html.
301 """
302 def format_input(text, "text/html", options) do
303 text
304 |> Formatter.html_escape("text/html")
305 |> Formatter.linkify(options)
306 end
307
308 @doc """
309 Formatting text to markdown.
310 """
311 def format_input(text, "text/markdown", options) do
312 text
313 |> Formatter.mentions_escape(options)
314 |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer})
315 |> Formatter.linkify(options)
316 |> Formatter.html_escape("text/html")
317 end
318
319 def make_note_data(
320 actor,
321 to,
322 context,
323 content_html,
324 attachments,
325 in_reply_to,
326 tags,
327 summary \\ nil,
328 cc \\ [],
329 sensitive \\ false,
330 extra_params \\ %{}
331 ) do
332 %{
333 "type" => "Note",
334 "to" => to,
335 "cc" => cc,
336 "content" => content_html,
337 "summary" => summary,
338 "sensitive" => truthy_param?(sensitive),
339 "context" => context,
340 "attachment" => attachments,
341 "actor" => actor,
342 "tag" => Keyword.values(tags) |> Enum.uniq()
343 }
344 |> add_in_reply_to(in_reply_to)
345 |> Map.merge(extra_params)
346 end
347
348 defp add_in_reply_to(object, nil), do: object
349
350 defp add_in_reply_to(object, in_reply_to) do
351 with %Object{} = in_reply_to_object <- Object.normalize(in_reply_to) do
352 Map.put(object, "inReplyTo", in_reply_to_object.data["id"])
353 else
354 _ -> object
355 end
356 end
357
358 def format_naive_asctime(date) do
359 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
360 end
361
362 def format_asctime(date) do
363 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
364 end
365
366 def date_to_asctime(date) when is_binary(date) do
367 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
368 format_asctime(date)
369 else
370 _e ->
371 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
372 ""
373 end
374 end
375
376 def date_to_asctime(date) do
377 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
378 ""
379 end
380
381 def to_masto_date(%NaiveDateTime{} = date) do
382 date
383 |> NaiveDateTime.to_iso8601()
384 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
385 end
386
387 def to_masto_date(date) when is_binary(date) do
388 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
389 to_masto_date(date)
390 else
391 _ -> ""
392 end
393 end
394
395 def to_masto_date(_), do: ""
396
397 defp shortname(name) do
398 if String.length(name) < 30 do
399 name
400 else
401 String.slice(name, 0..30) <> "…"
402 end
403 end
404
405 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
406 def confirm_current_password(user, password) do
407 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
408 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
409 {:ok, db_user}
410 else
411 _ -> {:error, dgettext("errors", "Invalid password.")}
412 end
413 end
414
415 def maybe_notify_to_recipients(
416 recipients,
417 %Activity{data: %{"to" => to, "type" => _type}} = _activity
418 ) do
419 recipients ++ to
420 end
421
422 def maybe_notify_to_recipients(recipients, _), do: recipients
423
424 def maybe_notify_mentioned_recipients(
425 recipients,
426 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
427 )
428 when type == "Create" do
429 object = Object.normalize(activity)
430
431 object_data =
432 cond do
433 not is_nil(object) ->
434 object.data
435
436 is_map(data["object"]) ->
437 data["object"]
438
439 true ->
440 %{}
441 end
442
443 tagged_mentions = maybe_extract_mentions(object_data)
444
445 recipients ++ tagged_mentions
446 end
447
448 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
449
450 # Do not notify subscribers if author is making a reply
451 def maybe_notify_subscribers(recipients, %Activity{
452 object: %Object{data: %{"inReplyTo" => _ap_id}}
453 }) do
454 recipients
455 end
456
457 def maybe_notify_subscribers(
458 recipients,
459 %Activity{data: %{"actor" => actor, "type" => type}} = activity
460 )
461 when type == "Create" do
462 with %User{} = user <- User.get_cached_by_ap_id(actor) do
463 subscriber_ids =
464 user
465 |> User.subscriber_users()
466 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
467 |> Enum.map(& &1.ap_id)
468
469 recipients ++ subscriber_ids
470 end
471 end
472
473 def maybe_notify_subscribers(recipients, _), do: recipients
474
475 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
476 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
477 user
478 |> User.get_followers()
479 |> Enum.map(& &1.ap_id)
480 |> Enum.concat(recipients)
481 end
482 end
483
484 def maybe_notify_followers(recipients, _), do: recipients
485
486 def maybe_extract_mentions(%{"tag" => tag}) do
487 tag
488 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
489 |> Enum.map(fn x -> x["href"] end)
490 |> Enum.uniq()
491 end
492
493 def maybe_extract_mentions(_), do: []
494
495 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
496
497 def make_report_content_html(comment) do
498 max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000)
499
500 if String.length(comment) <= max_size do
501 {:ok, format_input(comment, "text/plain")}
502 else
503 {:error,
504 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
505 end
506 end
507
508 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
509 when is_list(status_ids) do
510 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
511 end
512
513 def get_report_statuses(_, _), do: {:ok, nil}
514
515 # DEPRECATED mostly, context objects are now created at insertion time.
516 def context_to_conversation_id(context) do
517 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
518 id
519 else
520 _e ->
521 changeset = Object.context_mapping(context)
522
523 case Repo.insert(changeset) do
524 {:ok, %{id: id}} ->
525 id
526
527 # This should be solved by an upsert, but it seems ecto
528 # has problems accessing the constraint inside the jsonb.
529 {:error, _} ->
530 Object.get_cached_by_ap_id(context).id
531 end
532 end
533 end
534
535 def conversation_id_to_context(id) do
536 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
537 context
538 else
539 _e ->
540 {:error, dgettext("errors", "No such conversation")}
541 end
542 end
543
544 def make_answer_data(%User{ap_id: ap_id}, object, name) do
545 %{
546 "type" => "Answer",
547 "actor" => ap_id,
548 "cc" => [object.data["actor"]],
549 "to" => [],
550 "name" => name,
551 "inReplyTo" => object.data["id"]
552 }
553 end
554
555 def validate_character_limit("" = _full_payload, [] = _attachments) do
556 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
557 end
558
559 def validate_character_limit(full_payload, _attachments) do
560 limit = Pleroma.Config.get([:instance, :limit])
561 length = String.length(full_payload)
562
563 if length <= limit do
564 :ok
565 else
566 {:error, dgettext("errors", "The status is over the character limit")}
567 end
568 end
569 end