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