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