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