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
14 alias Pleroma.Formatter
16 alias Pleroma.Plugs.AuthenticationPlug
19 alias Pleroma.Web.ActivityPub.Utils
20 alias Pleroma.Web.ActivityPub.Visibility
21 alias Pleroma.Web.Endpoint
22 alias Pleroma.Web.MediaProxy
25 require Pleroma.Constants
27 def attachments_from_ids(%{"media_ids" => ids, "descriptions" => desc} = _) do
28 attachments_from_ids_descs(ids, desc)
31 def attachments_from_ids(%{"media_ids" => ids} = _) do
32 attachments_from_ids_no_descs(ids)
35 def attachments_from_ids(_), do: []
37 def attachments_from_ids_no_descs([]), do: []
39 def attachments_from_ids_no_descs(ids) do
40 Enum.map(ids, fn media_id ->
41 case Repo.get(Object, media_id) do
42 %Object{data: data} = _ -> data
49 def attachments_from_ids_descs([], _), do: []
51 def attachments_from_ids_descs(ids, descs_str) do
52 {_, descs} = Jason.decode(descs_str)
54 Enum.map(ids, fn media_id ->
55 case Repo.get(Object, media_id) do
56 %Object{data: data} = _ ->
57 Map.put(data, "name", descs[media_id])
71 Participation.t() | nil
72 ) :: {list(String.t()), list(String.t())}
74 def get_to_and_cc(_, _, _, _, %Participation{} = participation) do
75 participation = Repo.preload(participation, :recipients)
76 {Enum.map(participation.recipients, & &1.ap_id), []}
79 def get_to_and_cc(user, mentioned_users, inReplyTo, "public", _) do
80 to = [Pleroma.Constants.as_public() | mentioned_users]
81 cc = [user.follower_address]
84 {Enum.uniq([inReplyTo.data["actor"] | to]), cc}
90 def get_to_and_cc(user, mentioned_users, inReplyTo, "unlisted", _) do
91 to = [user.follower_address | mentioned_users]
92 cc = [Pleroma.Constants.as_public()]
95 {Enum.uniq([inReplyTo.data["actor"] | to]), cc}
101 def get_to_and_cc(user, mentioned_users, inReplyTo, "private", _) do
102 {to, cc} = get_to_and_cc(user, mentioned_users, inReplyTo, "direct", nil)
103 {[user.follower_address | to], cc}
106 def get_to_and_cc(_user, mentioned_users, inReplyTo, "direct", _) do
108 {Enum.uniq([inReplyTo.data["actor"] | mentioned_users]), []}
110 {mentioned_users, []}
114 def get_to_and_cc(_user, mentions, _inReplyTo, {:list, _}, _), do: {mentions, []}
116 def get_addressed_users(_, to) when is_list(to) do
117 User.get_ap_ids_by_nicknames(to)
120 def get_addressed_users(mentioned_users, _), do: mentioned_users
122 def maybe_add_list_data(activity_params, user, {:list, list_id}) do
123 case Pleroma.List.get(list_id, user) do
124 %Pleroma.List{} = list ->
126 |> put_in([:additional, "bcc"], [list.ap_id])
127 |> put_in([:additional, "listMessage"], list.ap_id)
128 |> put_in([:object, "listMessage"], list.ap_id)
135 def maybe_add_list_data(activity_params, _, _), do: activity_params
137 def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
138 when is_binary(expires_in) do
139 # In some cases mastofe sends out strings instead of integers
141 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
145 def make_poll_data(%{"poll" => %{"options" => options, "expires_in" => expires_in}} = data)
146 when is_list(options) do
147 limits = Pleroma.Config.get([:instance, :poll_limits])
149 with :ok <- validate_poll_expiration(expires_in, limits),
150 :ok <- validate_poll_options_amount(options, limits),
151 :ok <- validate_poll_options_length(options, limits) do
152 {option_notes, emoji} =
153 Enum.map_reduce(options, %{}, fn option, emoji ->
157 "replies" => %{"type" => "Collection", "totalItems" => 0}
160 {note, Map.merge(emoji, Emoji.Formatter.get_emoji_map(option))}
165 |> DateTime.add(expires_in)
166 |> DateTime.to_iso8601()
168 key = if truthy_param?(data["poll"]["multiple"]), do: "anyOf", else: "oneOf"
169 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
175 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
176 {:error, "Invalid poll"}
179 def make_poll_data(_data) do
183 defp validate_poll_options_amount(options, %{max_options: max_options}) do
184 if Enum.count(options) > max_options do
185 {:error, "Poll can't contain more than #{max_options} options"}
191 defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
192 if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
193 {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
199 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
201 expires_in > max -> {:error, "Expiration date is too far in the future"}
202 expires_in < min -> {:error, "Expiration date is too soon"}
207 def make_content_html(
215 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
218 content_type = get_content_type(data["content_type"])
221 if visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
228 |> format_input(content_type, options)
229 |> maybe_add_attachments(attachments, attachment_links)
230 |> maybe_add_nsfw_tag(data)
233 defp get_content_type(content_type) do
234 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
241 defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
242 when sensitive in [true, "True", "true", "1"] do
243 {text, mentions, [{"#nsfw", "nsfw"} | tags]}
246 defp maybe_add_nsfw_tag(data, _), do: data
248 def make_context(_, %Participation{} = participation) do
249 Repo.preload(participation, :conversation).conversation.ap_id
252 def make_context(%Activity{data: %{"context" => context}}, _), do: context
253 def make_context(_, _), do: Utils.generate_context_id()
255 def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
257 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
258 text = add_attachments(text, attachments)
259 {text, mentions, tags}
262 def add_attachments(text, attachments) do
263 attachment_text = Enum.map(attachments, &build_attachment_link/1)
264 Enum.join([text | attachment_text], "<br>")
267 defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
268 name = attachment["name"] || URI.decode(Path.basename(href))
269 href = MediaProxy.url(href)
270 "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
273 defp build_attachment_link(_), do: ""
275 def format_input(text, format, options \\ [])
278 Formatting text to plain text.
280 def format_input(text, "text/plain", options) do
282 |> Formatter.html_escape("text/plain")
283 |> Formatter.linkify(options)
284 |> (fn {text, mentions, tags} ->
285 {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
290 Formatting text as BBCode.
292 def format_input(text, "text/bbcode", options) do
294 |> String.replace(~r/\r/, "")
295 |> Formatter.html_escape("text/plain")
297 |> (fn {:ok, html} -> html end).()
298 |> Formatter.linkify(options)
302 Formatting text to html.
304 def format_input(text, "text/html", options) do
306 |> Formatter.html_escape("text/html")
307 |> Formatter.linkify(options)
311 Formatting text to markdown.
313 def format_input(text, "text/markdown", options) do
315 |> Formatter.mentions_escape(options)
316 |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer})
317 |> Formatter.linkify(options)
318 |> Formatter.html_escape("text/html")
338 "content" => content_html,
339 "summary" => summary,
340 "sensitive" => truthy_param?(sensitive),
341 "context" => context,
342 "attachment" => attachments,
344 "tag" => Keyword.values(tags) |> Enum.uniq()
346 |> add_in_reply_to(in_reply_to)
347 |> Map.merge(extra_params)
350 defp add_in_reply_to(object, nil), do: object
352 defp add_in_reply_to(object, in_reply_to) do
353 with %Object{} = in_reply_to_object <- Object.normalize(in_reply_to) do
354 Map.put(object, "inReplyTo", in_reply_to_object.data["id"])
360 def format_naive_asctime(date) do
361 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
364 def format_asctime(date) do
365 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
368 def date_to_asctime(date) when is_binary(date) do
369 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
373 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
378 def date_to_asctime(date) do
379 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
383 def to_masto_date(%NaiveDateTime{} = date) do
385 |> NaiveDateTime.to_iso8601()
386 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
389 def to_masto_date(date) when is_binary(date) do
390 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
397 def to_masto_date(_), do: ""
399 defp shortname(name) do
400 if String.length(name) < 30 do
403 String.slice(name, 0..30) <> "…"
407 def confirm_current_password(user, password) do
408 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
409 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
412 _ -> {:error, dgettext("errors", "Invalid password.")}
416 def emoji_from_profile(%User{bio: bio, name: name}) do
418 |> Enum.map(&Emoji.Formatter.get_emoji/1)
420 |> Enum.map(fn {shortcode, %Emoji{file: path}} ->
423 "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{path}"},
424 "name" => ":#{shortcode}:"
429 def maybe_notify_to_recipients(
431 %Activity{data: %{"to" => to, "type" => _type}} = _activity
436 def maybe_notify_to_recipients(recipients, _), do: recipients
438 def maybe_notify_mentioned_recipients(
440 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
442 when type == "Create" do
443 object = Object.normalize(activity)
447 not is_nil(object) ->
450 is_map(data["object"]) ->
457 tagged_mentions = maybe_extract_mentions(object_data)
459 recipients ++ tagged_mentions
462 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
464 # Do not notify subscribers if author is making a reply
465 def maybe_notify_subscribers(recipients, %Activity{
466 object: %Object{data: %{"inReplyTo" => _ap_id}}
471 def maybe_notify_subscribers(
473 %Activity{data: %{"actor" => actor, "type" => type}} = activity
475 when type == "Create" do
476 with %User{} = user <- User.get_cached_by_ap_id(actor) do
479 |> User.subscriber_users()
480 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
481 |> Enum.map(& &1.ap_id)
483 recipients ++ subscriber_ids
487 def maybe_notify_subscribers(recipients, _), do: recipients
489 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
490 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
492 |> User.get_followers()
493 |> Enum.map(& &1.ap_id)
494 |> Enum.concat(recipients)
498 def maybe_notify_followers(recipients, _), do: recipients
500 def maybe_extract_mentions(%{"tag" => tag}) do
502 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
503 |> Enum.map(fn x -> x["href"] end)
507 def maybe_extract_mentions(_), do: []
509 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
511 def make_report_content_html(comment) do
512 max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000)
514 if String.length(comment) <= max_size do
515 {:ok, format_input(comment, "text/plain")}
518 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
522 def get_report_statuses(%User{ap_id: actor}, %{"status_ids" => status_ids}) do
523 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
526 def get_report_statuses(_, _), do: {:ok, nil}
528 # DEPRECATED mostly, context objects are now created at insertion time.
529 def context_to_conversation_id(context) do
530 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
534 changeset = Object.context_mapping(context)
536 case Repo.insert(changeset) do
540 # This should be solved by an upsert, but it seems ecto
541 # has problems accessing the constraint inside the jsonb.
543 Object.get_cached_by_ap_id(context).id
548 def conversation_id_to_context(id) do
549 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
553 {:error, dgettext("errors", "No such conversation")}
557 def make_answer_data(%User{ap_id: ap_id}, object, name) do
561 "cc" => [object.data["actor"]],
564 "inReplyTo" => object.data["id"]
568 def validate_character_limit("" = _full_payload, [] = _attachments) do
569 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
572 def validate_character_limit(full_payload, _attachments) do
573 limit = Pleroma.Config.get([:instance, :limit])
574 length = String.length(full_payload)
576 if length <= limit do
579 {:error, dgettext("errors", "The status is over the character limit")}