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: visibility} = draft) when visibility in ["public", "local"] do
72 "public" -> [Pleroma.Constants.as_public() | draft.mentions]
73 "local" -> [Pleroma.Constants.as_local_public() | draft.mentions]
76 cc = [draft.user.follower_address]
78 if draft.in_reply_to do
79 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
85 def get_to_and_cc(%{visibility: "unlisted"} = draft) do
86 to = [draft.user.follower_address | draft.mentions]
87 cc = [Pleroma.Constants.as_public()]
89 if draft.in_reply_to do
90 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
96 def get_to_and_cc(%{visibility: "private"} = draft) do
97 {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
98 {[draft.user.follower_address | to], cc}
101 def get_to_and_cc(%{visibility: "direct"} = draft) do
102 # If the OP is a DM already, add the implicit actor.
103 if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do
104 {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
110 def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
112 def get_addressed_users(_, to) when is_list(to) do
113 User.get_ap_ids_by_nicknames(to)
116 def get_addressed_users(mentioned_users, _), do: mentioned_users
118 def maybe_add_list_data(activity_params, user, {:list, list_id}) do
119 case Pleroma.List.get(list_id, user) do
120 %Pleroma.List{} = list ->
122 |> put_in([:additional, "bcc"], [list.ap_id])
123 |> put_in([:additional, "listMessage"], list.ap_id)
124 |> put_in([:object, "listMessage"], list.ap_id)
131 def maybe_add_list_data(activity_params, _, _), do: activity_params
133 def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
134 when is_binary(expires_in) do
135 # In some cases mastofe sends out strings instead of integers
137 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
141 def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
142 when is_list(options) do
143 limits = Config.get([:instance, :poll_limits])
145 with :ok <- validate_poll_expiration(expires_in, limits),
146 :ok <- validate_poll_options_amount(options, limits),
147 :ok <- validate_poll_options_length(options, limits) do
148 {option_notes, emoji} =
149 Enum.map_reduce(options, %{}, fn option, emoji ->
153 "replies" => %{"type" => "Collection", "totalItems" => 0}
156 {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
161 |> DateTime.add(expires_in)
162 |> DateTime.to_iso8601()
164 key = if truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
165 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
171 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
172 {:error, "Invalid poll"}
175 def make_poll_data(_data) do
179 defp validate_poll_options_amount(options, %{max_options: max_options}) do
180 if Enum.count(options) > max_options do
181 {:error, "Poll can't contain more than #{max_options} options"}
187 defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
188 if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
189 {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
195 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
197 expires_in > max -> {:error, "Expiration date is too far in the future"}
198 expires_in < min -> {:error, "Expiration date is too soon"}
203 def make_content_html(%ActivityDraft{} = draft) do
206 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
209 content_type = get_content_type(draft.params[:content_type])
212 if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
219 |> format_input(content_type, options)
220 |> maybe_add_attachments(draft.attachments, attachment_links)
221 |> maybe_add_nsfw_tag(draft.params)
224 defp get_content_type(content_type) do
225 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
232 defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
233 when sensitive in [true, "True", "true", "1"] do
234 {text, mentions, [{"#nsfw", "nsfw"} | tags]}
237 defp maybe_add_nsfw_tag(data, _), do: data
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 |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer})
299 |> Formatter.linkify(options)
300 |> Formatter.html_escape("text/html")
303 def make_note_data(%ActivityDraft{} = draft) do
308 "content" => draft.content_html,
309 "summary" => draft.summary,
310 "sensitive" => draft.sensitive,
311 "context" => draft.context,
312 "attachment" => draft.attachments,
313 "actor" => draft.user.ap_id,
314 "tag" => Keyword.values(draft.tags) |> Enum.uniq()
316 |> add_in_reply_to(draft.in_reply_to)
317 |> Map.merge(draft.extra)
320 defp add_in_reply_to(object, nil), do: object
322 defp add_in_reply_to(object, in_reply_to) do
323 with %Object{} = in_reply_to_object <- Object.normalize(in_reply_to) do
324 Map.put(object, "inReplyTo", in_reply_to_object.data["id"])
330 def format_naive_asctime(date) do
331 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
334 def format_asctime(date) do
335 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
338 def date_to_asctime(date) when is_binary(date) do
339 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
343 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
348 def date_to_asctime(date) do
349 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
353 def to_masto_date(%NaiveDateTime{} = date) do
355 |> NaiveDateTime.to_iso8601()
356 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
359 def to_masto_date(date) when is_binary(date) do
360 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
367 def to_masto_date(_), do: ""
369 defp shortname(name) do
370 with max_length when max_length > 0 <-
371 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
372 true <- String.length(name) > max_length do
373 String.slice(name, 0..max_length) <> "…"
379 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
380 def confirm_current_password(user, password) do
381 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
382 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
385 _ -> {:error, dgettext("errors", "Invalid password.")}
389 def maybe_notify_to_recipients(
391 %Activity{data: %{"to" => to, "type" => _type}} = _activity
396 def maybe_notify_to_recipients(recipients, _), do: recipients
398 def maybe_notify_mentioned_recipients(
400 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
402 when type == "Create" do
403 object = Object.normalize(activity, false)
407 not is_nil(object) ->
410 is_map(data["object"]) ->
417 tagged_mentions = maybe_extract_mentions(object_data)
419 recipients ++ tagged_mentions
422 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
424 # Do not notify subscribers if author is making a reply
425 def maybe_notify_subscribers(recipients, %Activity{
426 object: %Object{data: %{"inReplyTo" => _ap_id}}
431 def maybe_notify_subscribers(
433 %Activity{data: %{"actor" => actor, "type" => type}} = activity
435 when type == "Create" do
436 with %User{} = user <- User.get_cached_by_ap_id(actor) do
439 |> User.subscriber_users()
440 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
441 |> Enum.map(& &1.ap_id)
443 recipients ++ subscriber_ids
449 def maybe_notify_subscribers(recipients, _), do: recipients
451 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
452 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
454 |> User.get_followers()
455 |> Enum.map(& &1.ap_id)
456 |> Enum.concat(recipients)
462 def maybe_notify_followers(recipients, _), do: recipients
464 def maybe_extract_mentions(%{"tag" => tag}) do
466 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
467 |> Enum.map(fn x -> x["href"] end)
471 def maybe_extract_mentions(_), do: []
473 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
475 def make_report_content_html(comment) do
476 max_size = Config.get([:instance, :max_report_comment_size], 1000)
478 if String.length(comment) <= max_size do
479 {:ok, format_input(comment, "text/plain")}
482 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
486 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
487 when is_list(status_ids) do
488 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
491 def get_report_statuses(_, _), do: {:ok, nil}
493 # DEPRECATED mostly, context objects are now created at insertion time.
494 def context_to_conversation_id(context) do
495 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
499 changeset = Object.context_mapping(context)
501 case Repo.insert(changeset) do
505 # This should be solved by an upsert, but it seems ecto
506 # has problems accessing the constraint inside the jsonb.
508 Object.get_cached_by_ap_id(context).id
513 def conversation_id_to_context(id) do
514 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
518 {:error, dgettext("errors", "No such conversation")}
522 def validate_character_limit("" = _full_payload, [] = _attachments) do
523 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
526 def validate_character_limit(full_payload, _attachments) do
527 limit = Config.get([:instance, :limit])
528 length = String.length(full_payload)
530 if length <= limit do
533 {:error, dgettext("errors", "The status is over the character limit")}