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