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
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}) 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 get_attachment(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} <- get_attachment(media_id) do
55 Map.put(data, "name", descs[media_id])
58 |> Enum.reject(&is_nil/1)
61 defp get_attachment(media_id) do
62 Repo.get(Object, media_id)
65 @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
67 def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
68 participation = Repo.preload(participation, :recipients)
69 {Enum.map(participation.recipients, & &1.ap_id), []}
72 def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do
75 "public" -> [Pleroma.Constants.as_public() | draft.mentions]
76 "local" -> [Utils.as_local_public() | draft.mentions]
79 cc = [draft.user.follower_address]
81 if draft.in_reply_to do
82 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
88 def get_to_and_cc(%{visibility: "unlisted"} = draft) do
89 to = [draft.user.follower_address | draft.mentions]
90 cc = [Pleroma.Constants.as_public()]
92 if draft.in_reply_to do
93 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
99 def get_to_and_cc(%{visibility: "private"} = draft) do
100 {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
101 {[draft.user.follower_address | to], cc}
104 def get_to_and_cc(%{visibility: "direct"} = draft) do
105 # If the OP is a DM already, add the implicit actor.
106 if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do
107 {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
113 def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
115 def get_addressed_users(_, to) when is_list(to) do
116 User.get_ap_ids_by_nicknames(to)
119 def get_addressed_users(mentioned_users, _), do: mentioned_users
121 def maybe_add_list_data(activity_params, user, {:list, list_id}) do
122 case Pleroma.List.get(list_id, user) do
123 %Pleroma.List{} = list ->
125 |> put_in([:additional, "bcc"], [list.ap_id])
126 |> put_in([:additional, "listMessage"], list.ap_id)
127 |> put_in([:object, "listMessage"], list.ap_id)
134 def maybe_add_list_data(activity_params, _, _), do: activity_params
136 def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
137 when is_binary(expires_in) do
138 # In some cases mastofe sends out strings instead of integers
140 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
144 def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
145 when is_list(options) do
146 limits = Config.get([:instance, :poll_limits])
148 with :ok <- validate_poll_expiration(expires_in, limits),
149 :ok <- validate_poll_options_amount(options, limits),
150 :ok <- validate_poll_options_length(options, limits) do
151 {option_notes, emoji} =
152 Enum.map_reduce(options, %{}, fn option, emoji ->
156 "replies" => %{"type" => "Collection", "totalItems" => 0}
159 {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
164 |> DateTime.add(expires_in)
165 |> DateTime.to_iso8601()
167 key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
168 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
174 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
175 {:error, "Invalid poll"}
178 def make_poll_data(_data) do
182 defp validate_poll_options_amount(options, %{max_options: max_options}) do
183 if Enum.count(options) > max_options do
184 {:error, "Poll can't contain more than #{max_options} options"}
190 defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
191 if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
192 {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
198 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
200 expires_in > max -> {:error, "Expiration date is too far in the future"}
201 expires_in < min -> {:error, "Expiration date is too soon"}
206 def make_content_html(%ActivityDraft{} = draft) do
209 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
210 |> Params.truthy_param?()
212 content_type = get_content_type(draft.params[:content_type])
215 if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
222 |> format_input(content_type, options)
223 |> maybe_add_attachments(draft.attachments, attachment_links)
226 def get_content_type(content_type) do
227 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
234 def make_context(_, %Participation{} = participation) do
235 Repo.preload(participation, :conversation).conversation.ap_id
238 def make_context(%Activity{data: %{"context" => context}}, _), do: context
239 def make_context(_, _), do: Utils.generate_context_id()
241 def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
243 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
244 text = add_attachments(text, attachments)
245 {text, mentions, tags}
248 def add_attachments(text, attachments) do
249 attachment_text = Enum.map(attachments, &build_attachment_link/1)
250 Enum.join([text | attachment_text], "<br>")
253 defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
254 name = attachment["name"] || URI.decode(Path.basename(href))
255 href = MediaProxy.url(href)
256 "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
259 defp build_attachment_link(_), do: ""
261 def format_input(text, format, options \\ [])
264 Formatting text to plain text, BBCode, HTML, or Markdown
266 def format_input(text, "text/plain", options) do
268 |> Formatter.html_escape("text/plain")
269 |> Formatter.linkify(options)
270 |> (fn {text, mentions, tags} ->
271 {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
275 def format_input(text, "text/bbcode", options) do
277 |> String.replace(~r/\r/, "")
278 |> Formatter.html_escape("text/plain")
280 |> (fn {:ok, html} -> html end).()
281 |> Formatter.linkify(options)
284 def format_input(text, "text/html", options) do
286 |> Formatter.html_escape("text/html")
287 |> Formatter.linkify(options)
290 def format_input(text, "text/x.misskeymarkdown", options) do
292 |> Formatter.markdown_to_html()
293 |> MfmParser.Parser.parse()
294 |> MfmParser.Encoder.to_html()
295 |> Formatter.linkify(options)
296 |> Formatter.html_escape("text/html")
299 def format_input(text, "text/markdown", options) do
301 |> Formatter.mentions_escape(options)
302 |> Formatter.markdown_to_html()
303 |> Formatter.linkify(options)
304 |> Formatter.html_escape("text/html")
307 def format_naive_asctime(date) do
308 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
311 def format_asctime(date) do
312 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
315 def date_to_asctime(date) when is_binary(date) do
316 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
320 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
325 def date_to_asctime(date) do
326 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
330 def to_masto_date(%NaiveDateTime{} = date) do
332 |> NaiveDateTime.to_iso8601()
333 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
336 def to_masto_date(date) when is_binary(date) do
337 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
344 def to_masto_date(_), do: ""
346 defp shortname(name) do
347 with max_length when max_length > 0 <-
348 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
349 true <- String.length(name) > max_length do
350 String.slice(name, 0..max_length) <> "…"
356 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
357 def confirm_current_password(user, password) do
358 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
359 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
362 _ -> {:error, dgettext("errors", "Invalid password.")}
366 def maybe_notify_to_recipients(
368 %Activity{data: %{"to" => to, "type" => _type}} = _activity
373 def maybe_notify_to_recipients(recipients, _), do: recipients
375 def maybe_notify_mentioned_recipients(
377 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
379 when type == "Create" do
380 object = Object.normalize(activity, fetch: false)
384 not is_nil(object) ->
387 is_map(data["object"]) ->
394 tagged_mentions = maybe_extract_mentions(object_data)
396 recipients ++ tagged_mentions
399 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
401 def maybe_notify_subscribers(
403 %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity
405 # Do not notify subscribers if author is making a reply
406 with %Object{data: object} <- Object.normalize(activity, fetch: false),
407 nil <- object["inReplyTo"],
408 %User{} = user <- User.get_cached_by_ap_id(actor) do
411 |> User.subscriber_users()
412 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
413 |> Enum.map(& &1.ap_id)
415 recipients ++ subscriber_ids
421 def maybe_notify_subscribers(recipients, _), do: recipients
423 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
424 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
426 |> User.get_followers()
427 |> Enum.map(& &1.ap_id)
428 |> Enum.concat(recipients)
434 def maybe_notify_followers(recipients, _), do: recipients
436 def maybe_extract_mentions(%{"tag" => tag}) do
438 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
439 |> Enum.map(fn x -> x["href"] end)
443 def maybe_extract_mentions(_), do: []
445 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
447 def make_report_content_html(comment) do
448 max_size = Config.get([:instance, :max_report_comment_size], 1000)
450 if String.length(comment) <= max_size do
451 {:ok, format_input(comment, "text/plain")}
454 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
458 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
459 when is_list(status_ids) do
460 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
463 def get_report_statuses(_, _), do: {:ok, nil}
465 def validate_character_limit("" = _full_payload, [] = _attachments) do
466 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
469 def validate_character_limit(full_payload, _attachments) do
470 limit = Config.get([:instance, :limit])
471 length = String.length(full_payload)
473 if length <= limit do
476 {:error, dgettext("errors", "The status is over the character limit")}