ActivityPubController: Add Mastodon activity compat route.
[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 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 = Pleroma.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.
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 @doc """
289 Formatting text as BBCode.
290 """
291 def format_input(text, "text/bbcode", options) do
292 text
293 |> String.replace(~r/\r/, "")
294 |> Formatter.html_escape("text/plain")
295 |> BBCode.to_html()
296 |> (fn {:ok, html} -> html end).()
297 |> Formatter.linkify(options)
298 end
299
300 @doc """
301 Formatting text to html.
302 """
303 def format_input(text, "text/html", options) do
304 text
305 |> Formatter.html_escape("text/html")
306 |> Formatter.linkify(options)
307 end
308
309 @doc """
310 Formatting text to markdown.
311 """
312 def format_input(text, "text/markdown", options) do
313 text
314 |> Formatter.mentions_escape(options)
315 |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer})
316 |> Formatter.linkify(options)
317 |> Formatter.html_escape("text/html")
318 end
319
320 def make_note_data(
321 actor,
322 to,
323 context,
324 content_html,
325 attachments,
326 in_reply_to,
327 tags,
328 summary \\ nil,
329 cc \\ [],
330 sensitive \\ false,
331 extra_params \\ %{}
332 ) do
333 %{
334 "type" => "Note",
335 "to" => to,
336 "cc" => cc,
337 "content" => content_html,
338 "summary" => summary,
339 "sensitive" => truthy_param?(sensitive),
340 "context" => context,
341 "attachment" => attachments,
342 "actor" => actor,
343 "tag" => Keyword.values(tags) |> Enum.uniq()
344 }
345 |> add_in_reply_to(in_reply_to)
346 |> Map.merge(extra_params)
347 end
348
349 defp add_in_reply_to(object, nil), do: object
350
351 defp add_in_reply_to(object, in_reply_to) do
352 with %Object{} = in_reply_to_object <- Object.normalize(in_reply_to) do
353 Map.put(object, "inReplyTo", in_reply_to_object.data["id"])
354 else
355 _ -> object
356 end
357 end
358
359 def format_naive_asctime(date) do
360 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
361 end
362
363 def format_asctime(date) do
364 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
365 end
366
367 def date_to_asctime(date) when is_binary(date) do
368 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
369 format_asctime(date)
370 else
371 _e ->
372 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
373 ""
374 end
375 end
376
377 def date_to_asctime(date) do
378 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
379 ""
380 end
381
382 def to_masto_date(%NaiveDateTime{} = date) do
383 date
384 |> NaiveDateTime.to_iso8601()
385 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
386 end
387
388 def to_masto_date(date) when is_binary(date) do
389 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
390 to_masto_date(date)
391 else
392 _ -> ""
393 end
394 end
395
396 def to_masto_date(_), do: ""
397
398 defp shortname(name) do
399 if String.length(name) < 30 do
400 name
401 else
402 String.slice(name, 0..30) <> "…"
403 end
404 end
405
406 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
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 maybe_notify_to_recipients(
417 recipients,
418 %Activity{data: %{"to" => to, "type" => _type}} = _activity
419 ) do
420 recipients ++ to
421 end
422
423 def maybe_notify_to_recipients(recipients, _), do: recipients
424
425 def maybe_notify_mentioned_recipients(
426 recipients,
427 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
428 )
429 when type == "Create" do
430 object = Object.normalize(activity)
431
432 object_data =
433 cond do
434 not is_nil(object) ->
435 object.data
436
437 is_map(data["object"]) ->
438 data["object"]
439
440 true ->
441 %{}
442 end
443
444 tagged_mentions = maybe_extract_mentions(object_data)
445
446 recipients ++ tagged_mentions
447 end
448
449 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
450
451 # Do not notify subscribers if author is making a reply
452 def maybe_notify_subscribers(recipients, %Activity{
453 object: %Object{data: %{"inReplyTo" => _ap_id}}
454 }) do
455 recipients
456 end
457
458 def maybe_notify_subscribers(
459 recipients,
460 %Activity{data: %{"actor" => actor, "type" => type}} = activity
461 )
462 when type == "Create" do
463 with %User{} = user <- User.get_cached_by_ap_id(actor) do
464 subscriber_ids =
465 user
466 |> User.subscriber_users()
467 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
468 |> Enum.map(& &1.ap_id)
469
470 recipients ++ subscriber_ids
471 else
472 _e -> recipients
473 end
474 end
475
476 def maybe_notify_subscribers(recipients, _), do: recipients
477
478 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
479 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
480 user
481 |> User.get_followers()
482 |> Enum.map(& &1.ap_id)
483 |> Enum.concat(recipients)
484 else
485 _e -> recipients
486 end
487 end
488
489 def maybe_notify_followers(recipients, _), do: recipients
490
491 def maybe_extract_mentions(%{"tag" => tag}) do
492 tag
493 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
494 |> Enum.map(fn x -> x["href"] end)
495 |> Enum.uniq()
496 end
497
498 def maybe_extract_mentions(_), do: []
499
500 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
501
502 def make_report_content_html(comment) do
503 max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000)
504
505 if String.length(comment) <= max_size do
506 {:ok, format_input(comment, "text/plain")}
507 else
508 {:error,
509 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
510 end
511 end
512
513 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
514 when is_list(status_ids) do
515 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
516 end
517
518 def get_report_statuses(_, _), do: {:ok, nil}
519
520 # DEPRECATED mostly, context objects are now created at insertion time.
521 def context_to_conversation_id(context) do
522 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
523 id
524 else
525 _e ->
526 changeset = Object.context_mapping(context)
527
528 case Repo.insert(changeset) do
529 {:ok, %{id: id}} ->
530 id
531
532 # This should be solved by an upsert, but it seems ecto
533 # has problems accessing the constraint inside the jsonb.
534 {:error, _} ->
535 Object.get_cached_by_ap_id(context).id
536 end
537 end
538 end
539
540 def conversation_id_to_context(id) do
541 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
542 context
543 else
544 _e ->
545 {:error, dgettext("errors", "No such conversation")}
546 end
547 end
548
549 def make_answer_data(%User{ap_id: ap_id}, object, name) do
550 %{
551 "type" => "Answer",
552 "actor" => ap_id,
553 "cc" => [object.data["actor"]],
554 "to" => [],
555 "name" => name,
556 "inReplyTo" => object.data["id"]
557 }
558 end
559
560 def validate_character_limit("" = _full_payload, [] = _attachments) do
561 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
562 end
563
564 def validate_character_limit(full_payload, _attachments) do
565 limit = Pleroma.Config.get([:instance, :limit])
566 length = String.length(full_payload)
567
568 if length <= limit do
569 :ok
570 else
571 {:error, dgettext("errors", "The status is over the character limit")}
572 end
573 end
574 end