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)
222 defp get_content_type(content_type) do
223 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
230 def make_context(_, %Participation{} = participation) do
231 Repo.preload(participation, :conversation).conversation.ap_id
234 def make_context(%Activity{data: %{"context" => context}}, _), do: context
235 def make_context(_, _), do: Utils.generate_context_id()
237 def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
239 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
240 text = add_attachments(text, attachments)
241 {text, mentions, tags}
244 def add_attachments(text, attachments) do
245 attachment_text = Enum.map(attachments, &build_attachment_link/1)
246 Enum.join([text | attachment_text], "<br>")
249 defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
250 name = attachment["name"] || URI.decode(Path.basename(href))
251 href = MediaProxy.url(href)
252 "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
255 defp build_attachment_link(_), do: ""
257 def format_input(text, format, options \\ [])
260 Formatting text to plain text, BBCode, HTML, or Markdown
262 def format_input(text, "text/plain", options) do
264 |> Formatter.html_escape("text/plain")
265 |> Formatter.linkify(options)
266 |> (fn {text, mentions, tags} ->
267 {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
271 def format_input(text, "text/bbcode", options) do
273 |> String.replace(~r/\r/, "")
274 |> Formatter.html_escape("text/plain")
276 |> (fn {:ok, html} -> html end).()
277 |> Formatter.linkify(options)
280 def format_input(text, "text/html", options) do
282 |> Formatter.html_escape("text/html")
283 |> Formatter.linkify(options)
286 def format_input(text, "text/markdown", options) do
288 |> Formatter.mentions_escape(options)
289 |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer})
290 |> Formatter.linkify(options)
291 |> Formatter.html_escape("text/html")
294 def make_note_data(%ActivityDraft{} = draft) do
299 "content" => draft.content_html,
300 "summary" => draft.summary,
301 "sensitive" => draft.sensitive,
302 "context" => draft.context,
303 "attachment" => draft.attachments,
304 "actor" => draft.user.ap_id,
305 "tag" => Keyword.values(draft.tags) |> Enum.uniq()
307 |> add_in_reply_to(draft.in_reply_to)
308 |> Map.merge(draft.extra)
311 defp add_in_reply_to(object, nil), do: object
313 defp add_in_reply_to(object, in_reply_to) do
314 with %Object{} = in_reply_to_object <- Object.normalize(in_reply_to, fetch: false) do
315 Map.put(object, "inReplyTo", in_reply_to_object.data["id"])
321 def format_naive_asctime(date) do
322 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
325 def format_asctime(date) do
326 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
329 def date_to_asctime(date) when is_binary(date) do
330 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
334 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
339 def date_to_asctime(date) do
340 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
344 def to_masto_date(%NaiveDateTime{} = date) do
346 |> NaiveDateTime.to_iso8601()
347 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
350 def to_masto_date(date) when is_binary(date) do
351 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
358 def to_masto_date(_), do: ""
360 defp shortname(name) do
361 with max_length when max_length > 0 <-
362 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
363 true <- String.length(name) > max_length do
364 String.slice(name, 0..max_length) <> "…"
370 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
371 def confirm_current_password(user, password) do
372 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
373 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
376 _ -> {:error, dgettext("errors", "Invalid password.")}
380 def maybe_notify_to_recipients(
382 %Activity{data: %{"to" => to, "type" => _type}} = _activity
387 def maybe_notify_to_recipients(recipients, _), do: recipients
389 def maybe_notify_mentioned_recipients(
391 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
393 when type == "Create" do
394 object = Object.normalize(activity, fetch: false)
398 not is_nil(object) ->
401 is_map(data["object"]) ->
408 tagged_mentions = maybe_extract_mentions(object_data)
410 recipients ++ tagged_mentions
413 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
415 # Do not notify subscribers if author is making a reply
416 def maybe_notify_subscribers(recipients, %Activity{
417 object: %Object{data: %{"inReplyTo" => _ap_id}}
422 def maybe_notify_subscribers(
424 %Activity{data: %{"actor" => actor, "type" => type}} = activity
426 when type == "Create" do
427 with %User{} = user <- User.get_cached_by_ap_id(actor) do
430 |> User.subscriber_users()
431 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
432 |> Enum.map(& &1.ap_id)
434 recipients ++ subscriber_ids
440 def maybe_notify_subscribers(recipients, _), do: recipients
442 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
443 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
445 |> User.get_followers()
446 |> Enum.map(& &1.ap_id)
447 |> Enum.concat(recipients)
453 def maybe_notify_followers(recipients, _), do: recipients
455 def maybe_extract_mentions(%{"tag" => tag}) do
457 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
458 |> Enum.map(fn x -> x["href"] end)
462 def maybe_extract_mentions(_), do: []
464 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
466 def make_report_content_html(comment) do
467 max_size = Config.get([:instance, :max_report_comment_size], 1000)
469 if String.length(comment) <= max_size do
470 {:ok, format_input(comment, "text/plain")}
473 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
477 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
478 when is_list(status_ids) do
479 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
482 def get_report_statuses(_, _), do: {:ok, nil}
484 # DEPRECATED mostly, context objects are now created at insertion time.
485 def context_to_conversation_id(context) do
486 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
490 changeset = Object.context_mapping(context)
492 case Repo.insert(changeset) do
496 # This should be solved by an upsert, but it seems ecto
497 # has problems accessing the constraint inside the jsonb.
499 Object.get_cached_by_ap_id(context).id
504 def conversation_id_to_context(id) do
505 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
509 {:error, dgettext("errors", "No such conversation")}
513 def validate_character_limit("" = _full_payload, [] = _attachments) do
514 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
517 def validate_character_limit(full_payload, _attachments) do
518 limit = Config.get([:instance, :limit])
519 length = String.length(full_payload)
521 if length <= limit do
524 {:error, dgettext("errors", "The status is over the character limit")}