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