akkoma/lib/pleroma/web/common_api/utils.ex
Oneric 271110c1a5 Drop broken list addressing feature
This feature was both conceptually broken and through bitrotting
the implementation was also buggy with the handling of certain
list-post interactions just crashing.

Remote servers had no way to know who belongs to a list and thus
posts basically showed just up as weird DM threads with different
participants on each instance. And while on the local instance
addition and removal from a listed grated and revoked post
access retroactively, it never acted retroactively on remotes.

Notably our "activity_visibility" database function also didn’t
know about "list visibility" instead treating them as direct messages.

Furthermore no known client actualy allows creating such messages
and the lack of complaints about the accumulutaed bugs supports
the absence of any users.

Given this there seems no point in fixing the implementation.
To reduce complexity of visibility handling it will be dropped instead.
Note, a similar effect with less federation weirdness can already be achieved
client-side using the explicit-addressing feature originally introduced in
https://git.pleroma.social/pleroma/pleroma/-/merge_requests/1239.

Ref: https://akkoma.dev/AkkomaGang/akkoma/issues/812
2025-11-17 00:00:00 +00:00

485 lines
14 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Pleroma: A lightweight social networking server
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.CommonAPI.Utils do
use Gettext,
backend: Pleroma.Web.Gettext
alias Calendar.Strftime
alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Conversation.Participation
alias Pleroma.Formatter
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI.ActivityDraft
alias Pleroma.Web.MediaProxy
alias Pleroma.Web.Utils.Params
require Logger
require Pleroma.Constants
def attachments_from_ids(user, %{media_ids: ids}) do
attachments_from_ids(user, ids, [])
end
def attachments_from_ids(_, _), do: []
defp attachments_from_ids(_user, [], acc), do: Enum.reverse(acc)
defp attachments_from_ids(user, [media_id | ids], acc) do
with {_, %Object{} = object} <- {:get, get_attachment(media_id)},
:ok <- Object.authorize_access(object, user) do
attachments_from_ids(user, ids, [object.data | acc])
else
{:get, _} -> attachments_from_ids(user, ids, acc)
{:error, reason} -> {:error, reason}
end
end
def get_attachment(media_id) do
with %Object{} = object <- Repo.get(Object, media_id),
true <- object.data["type"] in Pleroma.Constants.attachment_types() do
object
else
_ -> nil
end
end
@spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
participation = Repo.preload(participation, :recipients)
{Enum.map(participation.recipients, & &1.ap_id), []}
end
def get_to_and_cc(%{visibility: visibility} = draft) do
# If the OP is a DM already, add the implicit actor
mentions =
if visibility == "direct" && draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do
Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions])
else
draft.mentions
end
get_to_and_cc_for_visibility(
visibility,
draft.user.follower_address,
draft.in_reply_to && draft.in_reply_to.data["actor"],
mentions
)
end
def get_to_and_cc_for_visibility("public", follower_collection, parent_actor, mentions) do
scope_addr = Pleroma.Constants.as_public()
to =
if parent_actor,
do: Enum.uniq([parent_actor, scope_addr | mentions]),
else: [scope_addr | mentions]
{to, [follower_collection]}
end
def get_to_and_cc_for_visibility("local", follower_collection, parent_actor, mentions) do
recipients =
if parent_actor,
do: Enum.uniq([parent_actor | mentions]),
else: mentions
to = [
Utils.as_local_public()
| Enum.filter(recipients, fn addr ->
String.starts_with?(addr, Pleroma.Web.Endpoint.url() <> "/")
end)
]
{to, [follower_collection]}
end
def get_to_and_cc_for_visibility("unlisted", follower_collection, parent_actor, mentions) do
to =
if parent_actor,
do: Enum.uniq([parent_actor, follower_collection | mentions]),
else: [follower_collection | mentions]
{to, [Pleroma.Constants.as_public()]}
end
def get_to_and_cc_for_visibility("private", follower_collection, _, mentions) do
{[follower_collection | mentions], []}
end
def get_to_and_cc_for_visibility("direct", _, _, mentions) do
{mentions, []}
end
def get_addressed_users(_, to) when is_list(to) do
User.get_ap_ids_by_nicknames(to)
end
def get_addressed_users(mentioned_users, _), do: mentioned_users
def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
when is_binary(expires_in) do
# In some cases mastofe sends out strings instead of integers
data
|> put_in(["poll", "expires_in"], String.to_integer(expires_in))
|> make_poll_data()
end
def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
when is_list(options) do
limits = Config.get([:instance, :poll_limits])
options = options |> Enum.uniq()
with :ok <- validate_poll_expiration(expires_in, limits),
:ok <- validate_poll_options_amount(options, limits),
:ok <- validate_poll_options_length(options, limits) do
{option_notes, emoji} =
Enum.map_reduce(options, %{}, fn option, emoji ->
note = %{
"name" => option,
"type" => "Note",
"replies" => %{"type" => "Collection", "totalItems" => 0}
}
{note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
end)
end_time =
DateTime.utc_now()
|> DateTime.add(expires_in)
|> DateTime.to_iso8601()
key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
{:ok, {poll, emoji}}
end
end
def make_poll_data(%{"poll" => poll}) when is_map(poll) do
{:error, "Invalid poll"}
end
def make_poll_data(_data) do
{:ok, {%{}, %{}}}
end
defp validate_poll_options_amount(options, %{max_options: max_options}) do
cond do
Enum.count(options) < 2 ->
{:error, "Poll must contain at least 2 options"}
Enum.count(options) > max_options ->
{:error, "Poll can't contain more than #{max_options} options"}
true ->
:ok
end
end
defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
{:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
else
:ok
end
end
defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
cond do
expires_in > max -> {:error, "Expiration date is too far in the future"}
expires_in < min -> {:error, "Expiration date is too soon"}
true -> :ok
end
end
def make_content_html(%ActivityDraft{} = draft) do
attachment_links =
draft.params
|> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
|> Params.truthy_param?()
content_type = get_content_type(draft.params[:content_type])
options =
if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
[safe_mention: true]
else
[]
end
draft.status
|> format_input(content_type, options)
|> maybe_add_attachments(draft.attachments, attachment_links)
end
def get_content_type(content_type) do
if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
content_type
else
"text/plain"
end
end
def make_context(%{in_reply_to_conversation: %Participation{} = participation}) do
Repo.preload(participation, :conversation).conversation.ap_id
end
def make_context(%{in_reply_to: %Activity{data: %{"context" => context}}}), do: context
def make_context(%{quote: %Activity{data: %{"context" => context}}}), do: context
def make_context(_), do: Utils.generate_context_id()
def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
text = add_attachments(text, attachments)
{text, mentions, tags}
end
def add_attachments(text, attachments) do
attachment_text = Enum.map(attachments, &build_attachment_link/1)
Enum.join([text | attachment_text], "<br/>")
end
defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
name = attachment["name"] || URI.decode(Path.basename(href))
href = MediaProxy.url(href)
"<a href=\"#{href}\">#{shortname(name)}</a>"
end
defp build_attachment_link(_), do: ""
def format_input(text, format, options \\ [])
@doc """
Formatting text to plain text, BBCode, HTML, or Markdown
"""
def format_input(text, "text/plain", options) do
text
|> Formatter.html_escape("text/plain")
|> Formatter.linkify(options)
|> (fn {text, mentions, tags} ->
{String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
end).()
end
def format_input(text, "text/bbcode", options) do
text
|> String.replace(~r/\r/, "")
|> Formatter.html_escape("text/plain")
|> BBCode.to_html()
|> (fn {:ok, html} -> html end).()
|> Formatter.linkify(options)
end
def format_input(text, "text/html", options) do
text
|> Formatter.html_escape("text/html")
|> Formatter.linkify(options)
end
def format_input(text, "text/x.misskeymarkdown", options) do
text
|> Formatter.markdown_to_html(%{breaks: true})
|> MfmParser.Parser.parse()
|> MfmParser.Encoder.to_html()
|> Formatter.linkify(options)
|> Formatter.html_escape("text/html")
end
def format_input(text, "text/markdown", options) do
text
|> Formatter.mentions_escape(options)
|> Formatter.markdown_to_html()
|> Formatter.linkify(options)
|> Formatter.html_escape("text/html")
end
def format_naive_asctime(date) do
date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
end
def format_asctime(date) do
Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
end
def date_to_asctime(date) when is_binary(date) do
with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
format_asctime(date)
else
_e ->
Logger.warning("Date #{date} in wrong format, must be ISO 8601")
""
end
end
def date_to_asctime(date) do
Logger.warning("Date #{date} in wrong format, must be ISO 8601")
""
end
def to_masto_date(%NaiveDateTime{} = date) do
# NOTE: Elixirs ISO 8601 format is a superset of the real standard
# It supports negative years for example.
# ISO8601 only supports years before 1583 with mutual agreement
if date.year < 1583 do
"1970-01-01T00:00:00Z"
else
date
|> NaiveDateTime.to_iso8601()
|> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
end
end
def to_masto_date(date) when is_binary(date) do
with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
to_masto_date(date)
else
_ -> "1970-01-01T00:00:00Z"
end
end
def to_masto_date(_), do: "1970-01-01T00:00:00Z"
defp shortname(name) do
with max_length when max_length > 0 <-
Config.get([Pleroma.Upload, :filename_display_max_length], 30),
true <- String.length(name) > max_length do
String.slice(name, 0..max_length) <> ""
else
_ -> name
end
end
@spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
def confirm_current_password(user, password) do
with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
true <- Pleroma.Password.checkpw(password, db_user.password_hash) do
{:ok, db_user}
else
_ -> {:error, dgettext("errors", "Invalid password.")}
end
end
def maybe_notify_to_recipients(
recipients,
%Activity{data: %{"to" => to, "type" => _type}} = _activity
) do
recipients ++ to
end
def maybe_notify_to_recipients(recipients, _), do: recipients
def maybe_notify_mentioned_recipients(
recipients,
%Activity{data: %{"to" => _to, "type" => type} = data} = activity
)
when type == "Create" do
object = Object.normalize(activity, fetch: false)
object_data =
cond do
not is_nil(object) ->
object.data
is_map(data["object"]) ->
data["object"]
true ->
%{}
end
tagged_mentions = maybe_extract_mentions(object_data)
recipients ++ tagged_mentions
end
def maybe_notify_mentioned_recipients(recipients, _), do: recipients
def maybe_notify_subscribers(
recipients,
%Activity{data: %{"actor" => actor, "type" => "Create"}} = activity
) do
# Do not notify subscribers if author is making a reply
with %Object{data: object} <- Object.normalize(activity, fetch: false),
nil <- object["inReplyTo"],
%User{} = user <- User.get_cached_by_ap_id(actor) do
subscriber_ids =
user
|> User.subscriber_users()
|> Enum.filter(&Visibility.visible_for_user?(activity, &1))
|> Enum.map(& &1.ap_id)
recipients ++ subscriber_ids
else
_e -> recipients
end
end
def maybe_notify_subscribers(recipients, _), do: recipients
def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
user
|> User.get_followers()
|> Enum.map(& &1.ap_id)
|> Enum.concat(recipients)
else
_e -> recipients
end
end
def maybe_notify_followers(recipients, _), do: recipients
def maybe_extract_mentions(%{"tag" => tag}) do
tag
|> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
|> Enum.map(fn x -> x["href"] end)
|> Enum.uniq()
end
def maybe_extract_mentions(_), do: []
def make_report_content_html(nil), do: {:ok, {nil, [], []}}
def make_report_content_html(comment) do
max_size = Config.get([:instance, :max_report_comment_size], 1000)
if String.length(comment) <= max_size do
{:ok, format_input(comment, "text/plain")}
else
{:error,
dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
end
end
def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
when is_list(status_ids) do
{:ok, Activity.all_by_actor_and_id(actor, status_ids)}
end
def get_report_statuses(_, _), do: {:ok, nil}
def validate_character_limit("" = _full_payload, [] = _attachments) do
{:error, dgettext("errors", "Cannot post an empty status without attachments")}
end
def validate_character_limit(full_payload, _attachments) do
limit = Config.get([:instance, :limit])
length = String.length(full_payload)
if length <= limit do
:ok
else
{:error, dgettext("errors", "The status is over the character limit")}
end
end
end