1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 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: "public"} = draft) do
69 to = [public_uri(draft) | draft.mentions]
70 cc = [draft.user.follower_address]
72 if draft.in_reply_to do
73 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
79 def get_to_and_cc(%{visibility: "unlisted"} = draft) do
80 to = [draft.user.follower_address | draft.mentions]
81 cc = [public_uri(draft)]
83 if draft.in_reply_to do
84 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
90 def get_to_and_cc(%{visibility: "private"} = draft) do
91 {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
92 {[draft.user.follower_address | to], cc}
95 def get_to_and_cc(%{visibility: "direct"} = draft) do
96 # If the OP is a DM already, add the implicit actor.
97 if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do
98 {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
104 def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
106 defp public_uri(%{params: %{local_only: true}}), do: Pleroma.Web.base_url() <> "/#Public"
107 defp public_uri(_), do: Pleroma.Constants.as_public()
109 def get_addressed_users(_, to) when is_list(to) do
110 User.get_ap_ids_by_nicknames(to)
113 def get_addressed_users(mentioned_users, _), do: mentioned_users
115 def maybe_add_list_data(activity_params, user, {:list, list_id}) do
116 case Pleroma.List.get(list_id, user) do
117 %Pleroma.List{} = list ->
119 |> put_in([:additional, "bcc"], [list.ap_id])
120 |> put_in([:additional, "listMessage"], list.ap_id)
121 |> put_in([:object, "listMessage"], list.ap_id)
128 def maybe_add_list_data(activity_params, _, _), do: activity_params
130 def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
131 when is_binary(expires_in) do
132 # In some cases mastofe sends out strings instead of integers
134 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
138 def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
139 when is_list(options) do
140 limits = Config.get([:instance, :poll_limits])
142 with :ok <- validate_poll_expiration(expires_in, limits),
143 :ok <- validate_poll_options_amount(options, limits),
144 :ok <- validate_poll_options_length(options, limits) do
145 {option_notes, emoji} =
146 Enum.map_reduce(options, %{}, fn option, emoji ->
150 "replies" => %{"type" => "Collection", "totalItems" => 0}
153 {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
158 |> DateTime.add(expires_in)
159 |> DateTime.to_iso8601()
161 key = if truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
162 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
168 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
169 {:error, "Invalid poll"}
172 def make_poll_data(_data) do
176 defp validate_poll_options_amount(options, %{max_options: max_options}) do
177 if Enum.count(options) > max_options do
178 {:error, "Poll can't contain more than #{max_options} options"}
184 defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
185 if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
186 {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
192 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
194 expires_in > max -> {:error, "Expiration date is too far in the future"}
195 expires_in < min -> {:error, "Expiration date is too soon"}
200 def make_content_html(%ActivityDraft{} = draft) do
203 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
206 content_type = get_content_type(draft.params[:content_type])
209 if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
216 |> format_input(content_type, options)
217 |> maybe_add_attachments(draft.attachments, attachment_links)
218 |> maybe_add_nsfw_tag(draft.params)
221 defp get_content_type(content_type) do
222 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
229 defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
230 when sensitive in [true, "True", "true", "1"] do
231 {text, mentions, [{"#nsfw", "nsfw"} | tags]}
234 defp maybe_add_nsfw_tag(data, _), do: data
236 def make_context(_, %Participation{} = participation) do
237 Repo.preload(participation, :conversation).conversation.ap_id
240 def make_context(%Activity{data: %{"context" => context}}, _), do: context
241 def make_context(_, _), do: Utils.generate_context_id()
243 def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
245 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
246 text = add_attachments(text, attachments)
247 {text, mentions, tags}
250 def add_attachments(text, attachments) do
251 attachment_text = Enum.map(attachments, &build_attachment_link/1)
252 Enum.join([text | attachment_text], "<br>")
255 defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
256 name = attachment["name"] || URI.decode(Path.basename(href))
257 href = MediaProxy.url(href)
258 "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
261 defp build_attachment_link(_), do: ""
263 def format_input(text, format, options \\ [])
266 Formatting text to plain text.
268 def format_input(text, "text/plain", options) do
270 |> Formatter.html_escape("text/plain")
271 |> Formatter.linkify(options)
272 |> (fn {text, mentions, tags} ->
273 {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
278 Formatting text as BBCode.
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)
290 Formatting text to html.
292 def format_input(text, "text/html", options) do
294 |> Formatter.html_escape("text/html")
295 |> Formatter.linkify(options)
299 Formatting text to markdown.
301 def format_input(text, "text/markdown", options) do
303 |> Formatter.mentions_escape(options)
304 |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer})
305 |> Formatter.linkify(options)
306 |> Formatter.html_escape("text/html")
309 def make_note_data(%ActivityDraft{} = draft) do
314 "content" => draft.content_html,
315 "summary" => draft.summary,
316 "sensitive" => draft.sensitive,
317 "context" => draft.context,
318 "attachment" => draft.attachments,
319 "actor" => draft.user.ap_id,
320 "tag" => Keyword.values(draft.tags) |> Enum.uniq()
322 |> add_in_reply_to(draft.in_reply_to)
323 |> Map.merge(draft.extra)
326 defp add_in_reply_to(object, nil), do: object
328 defp add_in_reply_to(object, in_reply_to) do
329 with %Object{} = in_reply_to_object <- Object.normalize(in_reply_to) do
330 Map.put(object, "inReplyTo", in_reply_to_object.data["id"])
336 def format_naive_asctime(date) do
337 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
340 def format_asctime(date) do
341 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
344 def date_to_asctime(date) when is_binary(date) do
345 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
349 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
354 def date_to_asctime(date) do
355 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
359 def to_masto_date(%NaiveDateTime{} = date) do
361 |> NaiveDateTime.to_iso8601()
362 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
365 def to_masto_date(date) when is_binary(date) do
366 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
373 def to_masto_date(_), do: ""
375 defp shortname(name) do
376 with max_length when max_length > 0 <-
377 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
378 true <- String.length(name) > max_length do
379 String.slice(name, 0..max_length) <> "…"
385 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
386 def confirm_current_password(user, password) do
387 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
388 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
391 _ -> {:error, dgettext("errors", "Invalid password.")}
395 def maybe_notify_to_recipients(
397 %Activity{data: %{"to" => to, "type" => _type}} = _activity
402 def maybe_notify_to_recipients(recipients, _), do: recipients
404 def maybe_notify_mentioned_recipients(
406 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
408 when type == "Create" do
409 object = Object.normalize(activity, false)
413 not is_nil(object) ->
416 is_map(data["object"]) ->
423 tagged_mentions = maybe_extract_mentions(object_data)
425 recipients ++ tagged_mentions
428 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
430 # Do not notify subscribers if author is making a reply
431 def maybe_notify_subscribers(recipients, %Activity{
432 object: %Object{data: %{"inReplyTo" => _ap_id}}
437 def maybe_notify_subscribers(
439 %Activity{data: %{"actor" => actor, "type" => type}} = activity
441 when type == "Create" do
442 with %User{} = user <- User.get_cached_by_ap_id(actor) do
445 |> User.subscriber_users()
446 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
447 |> Enum.map(& &1.ap_id)
449 recipients ++ subscriber_ids
455 def maybe_notify_subscribers(recipients, _), do: recipients
457 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
458 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
460 |> User.get_followers()
461 |> Enum.map(& &1.ap_id)
462 |> Enum.concat(recipients)
468 def maybe_notify_followers(recipients, _), do: recipients
470 def maybe_extract_mentions(%{"tag" => tag}) do
472 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
473 |> Enum.map(fn x -> x["href"] end)
477 def maybe_extract_mentions(_), do: []
479 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
481 def make_report_content_html(comment) do
482 max_size = Config.get([:instance, :max_report_comment_size], 1000)
484 if String.length(comment) <= max_size do
485 {:ok, format_input(comment, "text/plain")}
488 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
492 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
493 when is_list(status_ids) do
494 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
497 def get_report_statuses(_, _), do: {:ok, nil}
499 # DEPRECATED mostly, context objects are now created at insertion time.
500 def context_to_conversation_id(context) do
501 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
505 changeset = Object.context_mapping(context)
507 case Repo.insert(changeset) do
511 # This should be solved by an upsert, but it seems ecto
512 # has problems accessing the constraint inside the jsonb.
514 Object.get_cached_by_ap_id(context).id
519 def conversation_id_to_context(id) do
520 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
524 {:error, dgettext("errors", "No such conversation")}
528 def validate_character_limit("" = _full_payload, [] = _attachments) do
529 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
532 def validate_character_limit(full_payload, _attachments) do
533 limit = Config.get([:instance, :limit])
534 length = String.length(full_payload)
536 if length <= limit do
539 {:error, dgettext("errors", "The status is over the character limit")}