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