alias alphabetically order
[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.Repo
16 alias Pleroma.User
17 alias Pleroma.Web.ActivityPub.Utils
18 alias Pleroma.Web.ActivityPub.Visibility
19 alias Pleroma.Web.MediaProxy
20 alias Pleroma.Web.Plugs.AuthenticationPlug
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 = 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 with max_length when max_length > 0 <-
400 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
401 true <- String.length(name) > max_length do
402 String.slice(name, 0..max_length) <> "…"
403 else
404 _ -> name
405 end
406 end
407
408 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
409 def confirm_current_password(user, password) do
410 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
411 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
412 {:ok, db_user}
413 else
414 _ -> {:error, dgettext("errors", "Invalid password.")}
415 end
416 end
417
418 def maybe_notify_to_recipients(
419 recipients,
420 %Activity{data: %{"to" => to, "type" => _type}} = _activity
421 ) do
422 recipients ++ to
423 end
424
425 def maybe_notify_to_recipients(recipients, _), do: recipients
426
427 def maybe_notify_mentioned_recipients(
428 recipients,
429 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
430 )
431 when type == "Create" do
432 object = Object.normalize(activity, false)
433
434 object_data =
435 cond do
436 not is_nil(object) ->
437 object.data
438
439 is_map(data["object"]) ->
440 data["object"]
441
442 true ->
443 %{}
444 end
445
446 tagged_mentions = maybe_extract_mentions(object_data)
447
448 recipients ++ tagged_mentions
449 end
450
451 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
452
453 # Do not notify subscribers if author is making a reply
454 def maybe_notify_subscribers(recipients, %Activity{
455 object: %Object{data: %{"inReplyTo" => _ap_id}}
456 }) do
457 recipients
458 end
459
460 def maybe_notify_subscribers(
461 recipients,
462 %Activity{data: %{"actor" => actor, "type" => type}} = activity
463 )
464 when type == "Create" do
465 with %User{} = user <- User.get_cached_by_ap_id(actor) do
466 subscriber_ids =
467 user
468 |> User.subscriber_users()
469 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
470 |> Enum.map(& &1.ap_id)
471
472 recipients ++ subscriber_ids
473 else
474 _e -> recipients
475 end
476 end
477
478 def maybe_notify_subscribers(recipients, _), do: recipients
479
480 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
481 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
482 user
483 |> User.get_followers()
484 |> Enum.map(& &1.ap_id)
485 |> Enum.concat(recipients)
486 else
487 _e -> recipients
488 end
489 end
490
491 def maybe_notify_followers(recipients, _), do: recipients
492
493 def maybe_extract_mentions(%{"tag" => tag}) do
494 tag
495 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
496 |> Enum.map(fn x -> x["href"] end)
497 |> Enum.uniq()
498 end
499
500 def maybe_extract_mentions(_), do: []
501
502 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
503
504 def make_report_content_html(comment) do
505 max_size = Config.get([:instance, :max_report_comment_size], 1000)
506
507 if String.length(comment) <= max_size do
508 {:ok, format_input(comment, "text/plain")}
509 else
510 {:error,
511 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
512 end
513 end
514
515 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
516 when is_list(status_ids) do
517 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
518 end
519
520 def get_report_statuses(_, _), do: {:ok, nil}
521
522 # DEPRECATED mostly, context objects are now created at insertion time.
523 def context_to_conversation_id(context) do
524 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
525 id
526 else
527 _e ->
528 changeset = Object.context_mapping(context)
529
530 case Repo.insert(changeset) do
531 {:ok, %{id: id}} ->
532 id
533
534 # This should be solved by an upsert, but it seems ecto
535 # has problems accessing the constraint inside the jsonb.
536 {:error, _} ->
537 Object.get_cached_by_ap_id(context).id
538 end
539 end
540 end
541
542 def conversation_id_to_context(id) do
543 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
544 context
545 else
546 _e ->
547 {:error, dgettext("errors", "No such conversation")}
548 end
549 end
550
551 def validate_character_limit("" = _full_payload, [] = _attachments) do
552 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
553 end
554
555 def validate_character_limit(full_payload, _attachments) do
556 limit = Config.get([:instance, :limit])
557 length = String.length(full_payload)
558
559 if length <= limit do
560 :ok
561 else
562 {:error, dgettext("errors", "The status is over the character limit")}
563 end
564 end
565 end