1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 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
8 alias Calendar.Strftime
11 alias Pleroma.Conversation.Participation
12 alias Pleroma.Formatter
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
24 require Pleroma.Constants
26 def attachments_from_ids(%{media_ids: ids, descriptions: desc}, user) do
27 attachments_from_ids_descs(ids, desc, user)
30 def attachments_from_ids(%{media_ids: ids}, user) do
31 attachments_from_ids_no_descs(ids, user)
34 def attachments_from_ids(_, _), do: []
36 def attachments_from_ids_no_descs([], _), do: []
38 def attachments_from_ids_no_descs(ids, user) do
39 Enum.map(ids, fn media_id ->
40 case get_attachment(media_id, user) 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, user) do
51 {_, descs} = Jason.decode(descs_str)
53 Enum.map(ids, fn media_id ->
54 with %Object{data: data} <- get_attachment(media_id, user) do
55 Map.put(data, "name", descs[media_id])
58 |> Enum.reject(&is_nil/1)
61 defp get_attachment(media_id, user) do
62 with %Object{data: data} = object <- Repo.get(Object, media_id),
63 %{"type" => type} when type in Pleroma.Constants.upload_object_types() <- data,
64 :ok <- Object.authorize_access(object, user) do
71 @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
73 def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
74 participation = Repo.preload(participation, :recipients)
75 {Enum.map(participation.recipients, & &1.ap_id), []}
78 def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do
81 "public" -> [Pleroma.Constants.as_public() | draft.mentions]
82 "local" -> [Utils.as_local_public() | draft.mentions]
85 cc = [draft.user.follower_address]
87 if draft.in_reply_to do
88 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
94 def get_to_and_cc(%{visibility: "unlisted"} = draft) do
95 to = [draft.user.follower_address | draft.mentions]
96 cc = [Pleroma.Constants.as_public()]
98 if draft.in_reply_to do
99 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
105 def get_to_and_cc(%{visibility: "private"} = draft) do
106 {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
107 {[draft.user.follower_address | to], cc}
110 def get_to_and_cc(%{visibility: "direct"} = draft) do
111 # If the OP is a DM already, add the implicit actor.
112 if draft.in_reply_to && Visibility.direct?(draft.in_reply_to) do
113 {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
119 def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
121 def get_addressed_users(_, to) when is_list(to) do
122 User.get_ap_ids_by_nicknames(to)
125 def get_addressed_users(mentioned_users, _), do: mentioned_users
127 def maybe_add_list_data(activity_params, user, {:list, list_id}) do
128 case Pleroma.List.get(list_id, user) do
129 %Pleroma.List{} = list ->
131 |> put_in([:additional, "bcc"], [list.ap_id])
132 |> put_in([:additional, "listMessage"], list.ap_id)
133 |> put_in([:object, "listMessage"], list.ap_id)
140 def maybe_add_list_data(activity_params, _, _), do: activity_params
142 def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
143 when is_binary(expires_in) do
144 # In some cases mastofe sends out strings instead of integers
146 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
150 def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
151 when is_list(options) do
152 limits = Config.get([:instance, :poll_limits])
154 options = options |> Enum.uniq()
156 with :ok <- validate_poll_expiration(expires_in, limits),
157 :ok <- validate_poll_options_amount(options, limits),
158 :ok <- validate_poll_options_length(options, limits) do
159 {option_notes, emoji} =
160 Enum.map_reduce(options, %{}, fn option, emoji ->
164 "replies" => %{"type" => "Collection", "totalItems" => 0}
167 {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
172 |> DateTime.add(expires_in)
173 |> DateTime.to_iso8601()
175 key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
176 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
182 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
183 {:error, "Invalid poll"}
186 def make_poll_data(_data) do
190 defp validate_poll_options_amount(options, %{max_options: max_options}) do
192 Enum.count(options) < 2 ->
193 {:error, "Poll must contain at least 2 options"}
195 Enum.count(options) > max_options ->
196 {:error, "Poll can't contain more than #{max_options} options"}
203 defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
204 if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
205 {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
211 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
213 expires_in > max -> {:error, "Expiration date is too far in the future"}
214 expires_in < min -> {:error, "Expiration date is too soon"}
219 def make_content_html(%ActivityDraft{} = draft) do
222 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
223 |> Params.truthy_param?()
225 content_type = get_content_type(draft.params[:content_type])
228 if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
235 |> format_input(content_type, options)
236 |> maybe_add_attachments(draft.attachments, attachment_links)
239 def get_content_type(content_type) do
240 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
247 def make_context(_, %Participation{} = participation) do
248 Repo.preload(participation, :conversation).conversation.ap_id
251 def make_context(%Activity{data: %{"context" => context}}, _), do: context
252 def make_context(_, _), do: Utils.generate_context_id()
254 def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
256 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
257 text = add_attachments(text, attachments)
258 {text, mentions, tags}
261 def add_attachments(text, attachments) do
262 attachment_text = Enum.map(attachments, &build_attachment_link/1)
263 Enum.join([text | attachment_text], "<br>")
266 defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
267 name = attachment["name"] || URI.decode(Path.basename(href))
268 href = MediaProxy.url(href)
269 "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
272 defp build_attachment_link(_), do: ""
274 def format_input(text, format, options \\ [])
277 Formatting text to plain text, BBCode, HTML, or Markdown
279 def format_input(text, "text/plain", options) do
281 |> Formatter.html_escape("text/plain")
282 |> Formatter.linkify(options)
283 |> (fn {text, mentions, tags} ->
284 {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
288 def format_input(text, "text/bbcode", options) do
290 |> String.replace(~r/\r/, "")
291 |> Formatter.html_escape("text/plain")
293 |> (fn {:ok, html} -> html end).()
294 |> Formatter.linkify(options)
297 def format_input(text, "text/html", options) do
299 |> Formatter.html_escape("text/html")
300 |> Formatter.linkify(options)
303 def format_input(text, "text/markdown", options) do
305 |> Formatter.mentions_escape(options)
306 |> Formatter.markdown_to_html()
307 |> Formatter.linkify(options)
308 |> Formatter.html_escape("text/html")
311 def format_naive_asctime(date) do
312 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
315 def format_asctime(date) do
316 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
319 def date_to_asctime(date) when is_binary(date) do
320 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
324 Logger.warning("Date #{date} in wrong format, must be ISO 8601")
329 def date_to_asctime(date) do
330 Logger.warning("Date #{date} in wrong format, must be ISO 8601")
334 def to_masto_date(%NaiveDateTime{} = date) do
336 |> NaiveDateTime.to_iso8601()
337 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
340 def to_masto_date(date) when is_binary(date) do
341 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
348 def to_masto_date(_), do: ""
350 defp shortname(name) do
351 with max_length when max_length > 0 <-
352 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
353 true <- String.length(name) > max_length do
354 String.slice(name, 0..max_length) <> "…"
360 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
361 def confirm_current_password(user, password) do
362 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
363 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
366 _ -> {:error, dgettext("errors", "Invalid password.")}
370 def maybe_notify_to_recipients(
372 %Activity{data: %{"to" => to, "type" => _type}} = _activity
377 def maybe_notify_to_recipients(recipients, _), do: recipients
379 def maybe_notify_mentioned_recipients(
381 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
383 when type == "Create" do
384 object = Object.normalize(activity, fetch: false)
388 not is_nil(object) ->
391 is_map(data["object"]) ->
398 tagged_mentions = maybe_extract_mentions(object_data)
400 recipients ++ tagged_mentions
403 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
405 def maybe_notify_subscribers(
407 %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity
409 # Do not notify subscribers if author is making a reply
410 with %Object{data: object} <- Object.normalize(activity, fetch: false),
411 nil <- object["inReplyTo"],
412 %User{} = user <- User.get_cached_by_ap_id(actor) do
415 |> User.subscriber_users()
416 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
417 |> Enum.map(& &1.ap_id)
419 recipients ++ subscriber_ids
425 def maybe_notify_subscribers(recipients, _), do: recipients
427 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
428 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
430 |> User.get_followers()
431 |> Enum.map(& &1.ap_id)
432 |> Enum.concat(recipients)
438 def maybe_notify_followers(recipients, _), do: recipients
440 def maybe_extract_mentions(%{"tag" => tag}) do
442 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
443 |> Enum.map(fn x -> x["href"] end)
447 def maybe_extract_mentions(_), do: []
449 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
451 def make_report_content_html(comment) do
452 max_size = Config.get([:instance, :max_report_comment_size], 1000)
454 if String.length(comment) <= max_size do
455 {:ok, format_input(comment, "text/plain")}
458 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
462 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
463 when is_list(status_ids) do
464 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
467 def get_report_statuses(_, _), do: {:ok, nil}
469 def validate_character_limit("" = _full_payload, [] = _attachments) do
470 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
473 def validate_character_limit(full_payload, _attachments) do
474 limit = Config.get([:instance, :limit])
475 length = String.length(full_payload)
477 if length <= limit do
480 {:error, dgettext("errors", "The status is over the character limit")}
484 def validate_attachments_count([] = _attachments) do
488 def validate_attachments_count(attachments) do
489 limit = Config.get([:instance, :max_media_attachments])
490 count = length(attachments)
495 {:error, dgettext("errors", "Too many attachments")}