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