CommonAPI: Don't make repeating announces possible
[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.filter(& &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.filter(& &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 def confirm_current_password(user, password) do
406 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
407 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
408 {:ok, db_user}
409 else
410 _ -> {:error, dgettext("errors", "Invalid password.")}
411 end
412 end
413
414 def maybe_notify_to_recipients(
415 recipients,
416 %Activity{data: %{"to" => to, "type" => _type}} = _activity
417 ) do
418 recipients ++ to
419 end
420
421 def maybe_notify_to_recipients(recipients, _), do: recipients
422
423 def maybe_notify_mentioned_recipients(
424 recipients,
425 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
426 )
427 when type == "Create" do
428 object = Object.normalize(activity)
429
430 object_data =
431 cond do
432 not is_nil(object) ->
433 object.data
434
435 is_map(data["object"]) ->
436 data["object"]
437
438 true ->
439 %{}
440 end
441
442 tagged_mentions = maybe_extract_mentions(object_data)
443
444 recipients ++ tagged_mentions
445 end
446
447 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
448
449 # Do not notify subscribers if author is making a reply
450 def maybe_notify_subscribers(recipients, %Activity{
451 object: %Object{data: %{"inReplyTo" => _ap_id}}
452 }) do
453 recipients
454 end
455
456 def maybe_notify_subscribers(
457 recipients,
458 %Activity{data: %{"actor" => actor, "type" => type}} = activity
459 )
460 when type == "Create" do
461 with %User{} = user <- User.get_cached_by_ap_id(actor) do
462 subscriber_ids =
463 user
464 |> User.subscriber_users()
465 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
466 |> Enum.map(& &1.ap_id)
467
468 recipients ++ subscriber_ids
469 end
470 end
471
472 def maybe_notify_subscribers(recipients, _), do: recipients
473
474 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
475 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
476 user
477 |> User.get_followers()
478 |> Enum.map(& &1.ap_id)
479 |> Enum.concat(recipients)
480 end
481 end
482
483 def maybe_notify_followers(recipients, _), do: recipients
484
485 def maybe_extract_mentions(%{"tag" => tag}) do
486 tag
487 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
488 |> Enum.map(fn x -> x["href"] end)
489 |> Enum.uniq()
490 end
491
492 def maybe_extract_mentions(_), do: []
493
494 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
495
496 def make_report_content_html(comment) do
497 max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000)
498
499 if String.length(comment) <= max_size do
500 {:ok, format_input(comment, "text/plain")}
501 else
502 {:error,
503 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
504 end
505 end
506
507 def get_report_statuses(%User{ap_id: actor}, %{"status_ids" => 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 make_answer_data(%User{ap_id: ap_id}, object, name) do
543 %{
544 "type" => "Answer",
545 "actor" => ap_id,
546 "cc" => [object.data["actor"]],
547 "to" => [],
548 "name" => name,
549 "inReplyTo" => object.data["id"]
550 }
551 end
552
553 def validate_character_limit("" = _full_payload, [] = _attachments) do
554 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
555 end
556
557 def validate_character_limit(full_payload, _attachments) do
558 limit = Pleroma.Config.get([:instance, :limit])
559 length = String.length(full_payload)
560
561 if length <= limit do
562 :ok
563 else
564 {:error, dgettext("errors", "The status is over the character limit")}
565 end
566 end
567 end