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