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 :ok <- Object.authorize_access(object, user) do
70 @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
72 def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
73 participation = Repo.preload(participation, :recipients)
74 {Enum.map(participation.recipients, & &1.ap_id), []}
77 def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do
80 "public" -> [Pleroma.Constants.as_public() | draft.mentions]
81 "local" -> [Utils.as_local_public() | draft.mentions]
84 cc = [draft.user.follower_address]
86 if draft.in_reply_to do
87 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
93 def get_to_and_cc(%{visibility: "unlisted"} = draft) do
94 to = [draft.user.follower_address | draft.mentions]
95 cc = [Pleroma.Constants.as_public()]
97 if draft.in_reply_to do
98 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
104 def get_to_and_cc(%{visibility: "private"} = draft) do
105 {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
106 {[draft.user.follower_address | to], cc}
109 def get_to_and_cc(%{visibility: "direct"} = draft) do
110 # If the OP is a DM already, add the implicit actor.
111 if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do
112 {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
118 def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
120 def get_addressed_users(_, to) when is_list(to) do
121 User.get_ap_ids_by_nicknames(to)
124 def get_addressed_users(mentioned_users, _), do: mentioned_users
126 def maybe_add_list_data(activity_params, user, {:list, list_id}) do
127 case Pleroma.List.get(list_id, user) do
128 %Pleroma.List{} = list ->
130 |> put_in([:additional, "bcc"], [list.ap_id])
131 |> put_in([:additional, "listMessage"], list.ap_id)
132 |> put_in([:object, "listMessage"], list.ap_id)
139 def maybe_add_list_data(activity_params, _, _), do: activity_params
141 def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
142 when is_binary(expires_in) do
143 # In some cases mastofe sends out strings instead of integers
145 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
149 def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
150 when is_list(options) do
151 limits = Config.get([:instance, :poll_limits])
153 with :ok <- validate_poll_expiration(expires_in, limits),
154 :ok <- validate_poll_options_amount(options, limits),
155 :ok <- validate_poll_options_length(options, limits) do
156 {option_notes, emoji} =
157 Enum.map_reduce(options, %{}, fn option, emoji ->
161 "replies" => %{"type" => "Collection", "totalItems" => 0}
164 {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
169 |> DateTime.add(expires_in)
170 |> DateTime.to_iso8601()
172 key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
173 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
179 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
180 {:error, "Invalid poll"}
183 def make_poll_data(_data) do
187 defp validate_poll_options_amount(options, %{max_options: max_options}) do
188 if Enum.count(options) > max_options do
189 {:error, "Poll can't contain more than #{max_options} options"}
195 defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
196 if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
197 {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
203 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
205 expires_in > max -> {:error, "Expiration date is too far in the future"}
206 expires_in < min -> {:error, "Expiration date is too soon"}
211 def make_content_html(%ActivityDraft{} = draft) do
214 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
215 |> Params.truthy_param?()
217 content_type = get_content_type(draft.params[:content_type])
220 if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
227 |> format_input(content_type, options)
228 |> maybe_add_attachments(draft.attachments, attachment_links)
231 def get_content_type(content_type) do
232 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
239 def make_context(_, %Participation{} = participation) do
240 Repo.preload(participation, :conversation).conversation.ap_id
243 def make_context(%Activity{data: %{"context" => context}}, _), do: context
244 def make_context(_, _), do: Utils.generate_context_id()
246 def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
248 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
249 text = add_attachments(text, attachments)
250 {text, mentions, tags}
253 def add_attachments(text, attachments) do
254 attachment_text = Enum.map(attachments, &build_attachment_link/1)
255 Enum.join([text | attachment_text], "<br>")
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>"
264 defp build_attachment_link(_), do: ""
266 def format_input(text, format, options \\ [])
269 Formatting text to plain text, BBCode, HTML, or Markdown
271 def format_input(text, "text/plain", options) do
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}
280 def format_input(text, "text/bbcode", options) do
282 |> String.replace(~r/\r/, "")
283 |> Formatter.html_escape("text/plain")
285 |> (fn {:ok, html} -> html end).()
286 |> Formatter.linkify(options)
289 def format_input(text, "text/html", options) do
291 |> Formatter.html_escape("text/html")
292 |> Formatter.linkify(options)
295 def format_input(text, "text/markdown", options) do
297 |> Formatter.mentions_escape(options)
298 |> Formatter.markdown_to_html()
299 |> Formatter.linkify(options)
300 |> Formatter.html_escape("text/html")
303 def format_naive_asctime(date) do
304 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
307 def format_asctime(date) do
308 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
311 def date_to_asctime(date) when is_binary(date) do
312 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
316 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
321 def date_to_asctime(date) do
322 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
326 def to_masto_date(%NaiveDateTime{} = date) do
328 |> NaiveDateTime.to_iso8601()
329 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
332 def to_masto_date(date) when is_binary(date) do
333 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
340 def to_masto_date(_), do: ""
342 defp shortname(name) do
343 with max_length when max_length > 0 <-
344 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
345 true <- String.length(name) > max_length do
346 String.slice(name, 0..max_length) <> "…"
352 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
353 def confirm_current_password(user, password) do
354 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
355 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
358 _ -> {:error, dgettext("errors", "Invalid password.")}
362 def maybe_notify_to_recipients(
364 %Activity{data: %{"to" => to, "type" => _type}} = _activity
369 def maybe_notify_to_recipients(recipients, _), do: recipients
371 def maybe_notify_mentioned_recipients(
373 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
375 when type == "Create" do
376 object = Object.normalize(activity, fetch: false)
380 not is_nil(object) ->
383 is_map(data["object"]) ->
390 tagged_mentions = maybe_extract_mentions(object_data)
392 recipients ++ tagged_mentions
395 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
397 def maybe_notify_subscribers(
399 %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity
401 # Do not notify subscribers if author is making a reply
402 with %Object{data: object} <- Object.normalize(activity, fetch: false),
403 nil <- object["inReplyTo"],
404 %User{} = user <- User.get_cached_by_ap_id(actor) do
407 |> User.subscriber_users()
408 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
409 |> Enum.map(& &1.ap_id)
411 recipients ++ subscriber_ids
417 def maybe_notify_subscribers(recipients, _), do: recipients
419 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
420 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
422 |> User.get_followers()
423 |> Enum.map(& &1.ap_id)
424 |> Enum.concat(recipients)
430 def maybe_notify_followers(recipients, _), do: recipients
432 def maybe_extract_mentions(%{"tag" => tag}) do
434 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
435 |> Enum.map(fn x -> x["href"] end)
439 def maybe_extract_mentions(_), do: []
441 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
443 def make_report_content_html(comment) do
444 max_size = Config.get([:instance, :max_report_comment_size], 1000)
446 if String.length(comment) <= max_size do
447 {:ok, format_input(comment, "text/plain")}
450 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
454 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
455 when is_list(status_ids) do
456 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
459 def get_report_statuses(_, _), do: {:ok, nil}
461 def validate_character_limit("" = _full_payload, [] = _attachments) do
462 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
465 def validate_character_limit(full_payload, _attachments) do
466 limit = Config.get([:instance, :limit])
467 length = String.length(full_payload)
469 if length <= limit do
472 {:error, dgettext("errors", "The status is over the character limit")}
476 def validate_attachments_count([] = _attachments) do
480 def validate_attachments_count(attachments) do
481 limit = Config.get([:instance, :max_media_attachments])
482 count = length(attachments)
487 {:error, dgettext("errors", "Too many attachments")}