Add CommonAPI.ActivityDraft
[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 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 NaiveDateTime.utc_now()
183 |> NaiveDateTime.add(expires_in)
184 |> NaiveDateTime.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 no_attachment_links =
232 data
233 |> Map.get("no_attachment_links", Config.get([:instance, :no_attachment_links]))
234 |> Kernel.in([true, "true"])
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, no_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, true = _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!()
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(%{info: _info} = user) do
435 (Emoji.Formatter.get_emoji(user.bio) ++ Emoji.Formatter.get_emoji(user.name))
436 |> Enum.map(fn {shortcode, %Emoji{file: url}} ->
437 %{
438 "type" => "Emoji",
439 "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{url}"},
440 "name" => ":#{shortcode}:"
441 }
442 end)
443 end
444
445 def maybe_notify_to_recipients(
446 recipients,
447 %Activity{data: %{"to" => to, "type" => _type}} = _activity
448 ) do
449 recipients ++ to
450 end
451
452 def maybe_notify_mentioned_recipients(
453 recipients,
454 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
455 )
456 when type == "Create" do
457 object = Object.normalize(activity)
458
459 object_data =
460 cond do
461 not is_nil(object) ->
462 object.data
463
464 is_map(data["object"]) ->
465 data["object"]
466
467 true ->
468 %{}
469 end
470
471 tagged_mentions = maybe_extract_mentions(object_data)
472
473 recipients ++ tagged_mentions
474 end
475
476 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
477
478 # Do not notify subscribers if author is making a reply
479 def maybe_notify_subscribers(recipients, %Activity{
480 object: %Object{data: %{"inReplyTo" => _ap_id}}
481 }) do
482 recipients
483 end
484
485 def maybe_notify_subscribers(
486 recipients,
487 %Activity{data: %{"actor" => actor, "type" => type}} = activity
488 )
489 when type == "Create" do
490 with %User{} = user <- User.get_cached_by_ap_id(actor) do
491 subscriber_ids =
492 user
493 |> User.subscribers()
494 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
495 |> Enum.map(& &1.ap_id)
496
497 recipients ++ subscriber_ids
498 end
499 end
500
501 def maybe_notify_subscribers(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