1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
5 defmodule Pleroma.Web.CommonAPI.Utils do
6 import Pleroma.Web.Gettext
7 import Pleroma.Web.ControllerHelper, only: [truthy_param?: 1]
9 alias Calendar.Strftime
10 alias Pleroma.Activity
12 alias Pleroma.Conversation.Participation
13 alias Pleroma.Formatter
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
24 require Pleroma.Constants
26 def attachments_from_ids(%{media_ids: ids, descriptions: desc}) do
27 attachments_from_ids_descs(ids, desc)
30 def attachments_from_ids(%{media_ids: ids}) do
31 attachments_from_ids_no_descs(ids)
34 def attachments_from_ids(_), do: []
36 def attachments_from_ids_no_descs([]), do: []
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
45 |> Enum.reject(&is_nil/1)
48 def attachments_from_ids_descs([], _), do: []
50 def attachments_from_ids_descs(ids, descs_str) do
51 {_, descs} = Jason.decode(descs_str)
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])
58 |> Enum.reject(&is_nil/1)
61 @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
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), []}
68 def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do
71 "public" -> [Pleroma.Constants.as_public() | draft.mentions]
72 "local" -> [Pleroma.Constants.as_local_public() | draft.mentions]
75 cc = [draft.user.follower_address]
77 if draft.in_reply_to do
78 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
84 def get_to_and_cc(%{visibility: "unlisted"} = draft) do
85 to = [draft.user.follower_address | draft.mentions]
86 cc = [Pleroma.Constants.as_public()]
88 if draft.in_reply_to do
89 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
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}
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]), []}
109 def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
111 def get_addressed_users(_, to) when is_list(to) do
112 User.get_ap_ids_by_nicknames(to)
115 def get_addressed_users(mentioned_users, _), do: mentioned_users
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 ->
121 |> put_in([:additional, "bcc"], [list.ap_id])
122 |> put_in([:additional, "listMessage"], list.ap_id)
123 |> put_in([:object, "listMessage"], list.ap_id)
130 def maybe_add_list_data(activity_params, _, _), do: activity_params
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
136 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
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])
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 ->
152 "replies" => %{"type" => "Collection", "totalItems" => 0}
155 {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
160 |> DateTime.add(expires_in)
161 |> DateTime.to_iso8601()
163 key = if truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
164 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
170 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
171 {:error, "Invalid poll"}
174 def make_poll_data(_data) do
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"}
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"}
194 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
196 expires_in > max -> {:error, "Expiration date is too far in the future"}
197 expires_in < min -> {:error, "Expiration date is too soon"}
202 def make_content_html(%ActivityDraft{} = draft) do
205 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
208 content_type = get_content_type(draft.params[:content_type])
211 if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
218 |> format_input(content_type, options)
219 |> maybe_add_attachments(draft.attachments, attachment_links)
220 |> maybe_add_nsfw_tag(draft.params)
223 defp get_content_type(content_type) do
224 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
231 defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
232 when sensitive in [true, "True", "true", "1"] do
233 {text, mentions, [{"#nsfw", "nsfw"} | tags]}
236 defp maybe_add_nsfw_tag(data, _), do: data
238 def make_context(_, %Participation{} = participation) do
239 Repo.preload(participation, :conversation).conversation.ap_id
242 def make_context(%Activity{data: %{"context" => context}}, _), do: context
243 def make_context(_, _), do: Utils.generate_context_id()
245 def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
247 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
248 text = add_attachments(text, attachments)
249 {text, mentions, tags}
252 def add_attachments(text, attachments) do
253 attachment_text = Enum.map(attachments, &build_attachment_link/1)
254 Enum.join([text | attachment_text], "<br>")
257 defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
258 name = attachment["name"] || URI.decode(Path.basename(href))
259 href = MediaProxy.url(href)
260 "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
263 defp build_attachment_link(_), do: ""
265 def format_input(text, format, options \\ [])
268 Formatting text to plain text, BBCode, HTML, or Markdown
270 def format_input(text, "text/plain", options) do
272 |> Formatter.html_escape("text/plain")
273 |> Formatter.linkify(options)
274 |> (fn {text, mentions, tags} ->
275 {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
279 def format_input(text, "text/bbcode", options) do
281 |> String.replace(~r/\r/, "")
282 |> Formatter.html_escape("text/plain")
284 |> (fn {:ok, html} -> html end).()
285 |> Formatter.linkify(options)
288 def format_input(text, "text/html", options) do
290 |> Formatter.html_escape("text/html")
291 |> Formatter.linkify(options)
294 def format_input(text, "text/markdown", options) do
296 |> Formatter.mentions_escape(options)
297 |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer})
298 |> Formatter.linkify(options)
299 |> Formatter.html_escape("text/html")
302 def make_note_data(%ActivityDraft{} = draft) do
307 "content" => draft.content_html,
308 "summary" => draft.summary,
309 "sensitive" => draft.sensitive,
310 "context" => draft.context,
311 "attachment" => draft.attachments,
312 "actor" => draft.user.ap_id,
313 "tag" => Keyword.values(draft.tags) |> Enum.uniq()
315 |> add_in_reply_to(draft.in_reply_to)
316 |> Map.merge(draft.extra)
319 defp add_in_reply_to(object, nil), do: object
321 defp add_in_reply_to(object, in_reply_to) do
322 with %Object{} = in_reply_to_object <- Object.normalize(in_reply_to, fetch: false) do
323 Map.put(object, "inReplyTo", in_reply_to_object.data["id"])
329 def format_naive_asctime(date) do
330 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
333 def format_asctime(date) do
334 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
337 def date_to_asctime(date) when is_binary(date) do
338 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
342 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
347 def date_to_asctime(date) do
348 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
352 def to_masto_date(%NaiveDateTime{} = date) do
354 |> NaiveDateTime.to_iso8601()
355 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
358 def to_masto_date(date) when is_binary(date) do
359 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
366 def to_masto_date(_), do: ""
368 defp shortname(name) do
369 with max_length when max_length > 0 <-
370 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
371 true <- String.length(name) > max_length do
372 String.slice(name, 0..max_length) <> "…"
378 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
379 def confirm_current_password(user, password) do
380 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
381 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
384 _ -> {:error, dgettext("errors", "Invalid password.")}
388 def maybe_notify_to_recipients(
390 %Activity{data: %{"to" => to, "type" => _type}} = _activity
395 def maybe_notify_to_recipients(recipients, _), do: recipients
397 def maybe_notify_mentioned_recipients(
399 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
401 when type == "Create" do
402 object = Object.normalize(activity, fetch: false)
406 not is_nil(object) ->
409 is_map(data["object"]) ->
416 tagged_mentions = maybe_extract_mentions(object_data)
418 recipients ++ tagged_mentions
421 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
423 # Do not notify subscribers if author is making a reply
424 def maybe_notify_subscribers(recipients, %Activity{
425 object: %Object{data: %{"inReplyTo" => _ap_id}}
430 def maybe_notify_subscribers(
432 %Activity{data: %{"actor" => actor, "type" => type}} = activity
434 when type == "Create" do
435 with %User{} = user <- User.get_cached_by_ap_id(actor) do
438 |> User.subscriber_users()
439 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
440 |> Enum.map(& &1.ap_id)
442 recipients ++ subscriber_ids
448 def maybe_notify_subscribers(recipients, _), do: recipients
450 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
451 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
453 |> User.get_followers()
454 |> Enum.map(& &1.ap_id)
455 |> Enum.concat(recipients)
461 def maybe_notify_followers(recipients, _), do: recipients
463 def maybe_extract_mentions(%{"tag" => tag}) do
465 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
466 |> Enum.map(fn x -> x["href"] end)
470 def maybe_extract_mentions(_), do: []
472 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
474 def make_report_content_html(comment) do
475 max_size = Config.get([:instance, :max_report_comment_size], 1000)
477 if String.length(comment) <= max_size do
478 {:ok, format_input(comment, "text/plain")}
481 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
485 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
486 when is_list(status_ids) do
487 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
490 def get_report_statuses(_, _), do: {:ok, nil}
492 # DEPRECATED mostly, context objects are now created at insertion time.
493 def context_to_conversation_id(context) do
494 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
498 changeset = Object.context_mapping(context)
500 case Repo.insert(changeset) do
504 # This should be solved by an upsert, but it seems ecto
505 # has problems accessing the constraint inside the jsonb.
507 Object.get_cached_by_ap_id(context).id
512 def conversation_id_to_context(id) do
513 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
517 {:error, dgettext("errors", "No such conversation")}
521 def validate_character_limit("" = _full_payload, [] = _attachments) do
522 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
525 def validate_character_limit(full_payload, _attachments) do
526 limit = Config.get([:instance, :limit])
527 length = String.length(full_payload)
529 if length <= limit do
532 {:error, dgettext("errors", "The status is over the character limit")}