Merge branch 'develop' into gitlab-mr-iid-4381

This commit is contained in:
Phantasm 2026-05-14 00:14:27 +02:00
commit 7651490c50
No known key found for this signature in database
GPG key ID: 2669E588BCC634C8
134 changed files with 4584 additions and 471 deletions

View file

@ -72,7 +72,7 @@ defmodule Mix.Tasks.Pleroma.Search.Meilisearch do
query,
timeout: :infinity
)
|> Stream.map(&Pleroma.Search.Meilisearch.object_to_search_data/1)
|> Stream.map(&Pleroma.Search.object_to_search_data/1)
|> Stream.filter(fn o -> not is_nil(o) end)
|> Stream.chunk_every(chunk_size)
|> Stream.transform(0, fn objects, acc ->

View file

@ -10,6 +10,7 @@ defmodule Pleroma.ConfigDB do
import Pleroma.Web.Gettext
alias __MODULE__
alias Pleroma.EctoType.Config.RateLimit
alias Pleroma.Repo
@type t :: %__MODULE__{}
@ -60,8 +61,59 @@ defmodule Pleroma.ConfigDB do
|> cast(params, [:key, :group, :value])
|> validate_required([:key, :group, :value])
|> unique_constraint(:key, name: :config_group_key_index)
|> validate_rate_limit()
end
defp validate_rate_limit(changeset) do
group = get_field(changeset, :group)
key = get_field(changeset, :key)
if group == :pleroma and key == :rate_limit do
value = get_field(changeset, :value)
case normalize_rate_limit(value) do
{:ok, normalized_value} ->
put_change(changeset, :value, normalized_value)
{:error, {limiter_name, reason}} ->
add_error(
changeset,
:value,
"invalid :rate_limit value for #{inspect(limiter_name)}: #{reason}"
)
end
else
changeset
end
end
defp normalize_rate_limit(nil), do: {:ok, nil}
defp normalize_rate_limit(%{} = value), do: normalize_rate_limit(Map.to_list(value))
defp normalize_rate_limit(value) when is_list(value) do
if Keyword.keyword?(value) do
value
|> Enum.reduce_while({:ok, []}, fn {limiter_name, limiter_value}, {:ok, acc} ->
case RateLimit.cast_with_error(limiter_value) do
{:ok, normalized_limiter_value} ->
{:cont, {:ok, [{limiter_name, normalized_limiter_value} | acc]}}
{:error, reason} ->
{:halt, {:error, {limiter_name, reason}}}
end
end)
|> case do
{:ok, acc} -> {:ok, Enum.reverse(acc)}
{:error, _} = error -> error
end
else
{:error, {:rate_limit, "must be a keyword list"}}
end
end
defp normalize_rate_limit(_), do: {:error, {:rate_limit, "must be a keyword list"}}
defp create(params) do
%ConfigDB{}
|> changeset(params)

View file

@ -29,7 +29,7 @@ defmodule Pleroma.Constants do
const(static_only_files,
do:
~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css)
~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js schemas doc embed.js embed.css)
)
const(status_updatable_fields,

View file

@ -0,0 +1,71 @@
# Pleroma: A lightweight social networking server
# Copyright © Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.EctoType.Config.RateLimit do
@moduledoc false
use Ecto.Type
@type t ::
nil
| {non_neg_integer(), non_neg_integer()}
| [{non_neg_integer(), non_neg_integer()}]
@impl true
def type, do: :term
@impl true
def cast(value) do
case cast_with_error(value) do
{:ok, normalized} -> {:ok, normalized}
{:error, _reason} -> :error
end
end
@impl true
def load(value), do: cast(value)
@impl true
def dump(value), do: cast(value)
@spec cast_with_error(term()) :: {:ok, t()} | {:error, String.t()}
def cast_with_error(nil), do: {:ok, nil}
def cast_with_error({scale, limit}) do
with {:ok, scale} <- parse_integer(scale, "scale"),
{:ok, limit} <- parse_integer(limit, "limit"),
true <- scale >= 1 and limit >= 1 do
{:ok, {scale, limit}}
else
false -> {:error, "scale and limit must be >= 1"}
{:error, reason} -> {:error, reason}
end
end
def cast_with_error([{_, _} = unauth, {_, _} = auth]) do
with {:ok, unauth} <- cast_with_error(unauth),
{:ok, auth} <- cast_with_error(auth) do
{:ok, [unauth, auth]}
else
{:error, reason} -> {:error, reason}
end
end
def cast_with_error(_),
do:
{:error, "must be a {scale, limit} tuple, a [{scale, limit}, {scale, limit}] list, or nil"}
defp parse_integer(value, _label) when is_integer(value), do: {:ok, value}
defp parse_integer(value, label) when is_binary(value) do
value = String.trim(value)
case Integer.parse(value) do
{number, ""} -> {:ok, number}
_ -> {:error, "#{label} must be an integer"}
end
end
defp parse_integer(_value, label), do: {:error, "#{label} must be an integer"}
end

View file

@ -127,6 +127,13 @@ defmodule Pleroma.Formatter do
Earmark.as_html!(text, %Earmark.Options{compact_output: true, smartypants: false})
end
def markdown_to_html(text, opts) do
Earmark.as_html!(
text,
%Earmark.Options{compact_output: true, smartypants: false} |> Map.merge(opts)
)
end
def html_escape({text, mentions, hashtags}, type) do
{html_escape(text, type), mentions, hashtags}
end
@ -135,6 +142,10 @@ defmodule Pleroma.Formatter do
HTML.filter_tags(text)
end
def html_escape(text, "text/x.misskeymarkdown") do
HTML.filter_tags(text)
end
def html_escape(text, "text/plain") do
Regex.split(@link_regex, text, include_captures: true)
|> Enum.map_every(2, fn chunk ->

View file

@ -75,8 +75,8 @@ defmodule Pleroma.Frontend do
end
defp download_build(frontend_info, dest) do
Logger.info("Downloading pre-built bundle for #{frontend_info["name"]}")
url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"])
Logger.info("Downloading pre-built bundle for #{frontend_info["name"]} from #{url}")
with {:ok, %{status: 200, body: zip_body}} <-
Pleroma.HTTP.get(url, [], pool: :media, recv_timeout: 120_000) do

View file

@ -372,12 +372,28 @@ defmodule Pleroma.Object do
option
end)
voters = [actor | object.data["voters"] || []] |> Enum.uniq()
existing_voters = object.data["voters"] || []
voters = [actor | existing_voters] |> Enum.uniq()
new_voter? = actor not in existing_voters
existing_voters_count = object.data["votersCount"]
voters_count =
cond do
is_integer(existing_voters_count) and new_voter? ->
existing_voters_count + 1
is_integer(existing_voters_count) ->
existing_voters_count
true ->
length(voters)
end
data =
object.data
|> Map.put(key, options)
|> Map.put("voters", voters)
|> Map.put("votersCount", voters_count)
object
|> Object.change(%{data: data})

View file

@ -5,7 +5,10 @@
defmodule Pleroma.ReleaseTasks do
@repo Pleroma.Repo
def run(args) do
# TODO: Kept for some backwards compatibility with buggy pleroma_ctl,
# if a mismatch between pleroma_ctl and Pleroma accidentaly happens.
# Remove in the future.
def run(args) when is_binary(args) do
[task | args] = String.split(args)
case task do
@ -16,6 +19,20 @@ defmodule Pleroma.ReleaseTasks do
end
end
# HACK: Script arguments need to be received as a list, otherwise (quoted) arguments with
# whitespace will be broken. Previously the broken string form above was used,
# escaping in the shell does not help.
def run(args) when is_list(args) do
[task | args] = args
case task do
"migrate" -> migrate(args)
"create" -> create()
"rollback" -> rollback(args)
task -> mix_task(task, args)
end
end
def find_module(task) do
module_name =
task

View file

@ -12,7 +12,7 @@ defmodule Pleroma.ReverseProxy do
@keep_resp_headers @resp_cache_headers ++
~w(content-length content-type content-disposition content-encoding) ++
~w(content-range accept-ranges vary)
@default_cache_control_header "public, max-age=1209600"
@default_cache_control_header "public, max-age=1209600, immutable"
@valid_resp_codes [200, 206, 304]
@max_read_duration :timer.seconds(30)
@max_body_length :infinity

View file

@ -4,6 +4,9 @@
defmodule Pleroma.ReverseProxy.Client.Hackney do
@behaviour Pleroma.ReverseProxy.Client
@redirect_limit 5
require Logger
# In-app redirect handler to avoid Hackney redirect bugs:
# - https://github.com/benoitc/hackney/issues/527 (relative/protocol-less redirects can crash Hackney)
@ -31,26 +34,15 @@ defmodule Pleroma.ReverseProxy.Client.Hackney do
if opts[:follow_redirect] != false do
{_state, req_opts} = Access.get_and_update(opts, :follow_redirect, fn a -> {a, false} end)
env = %{method: method, headers: headers, body: body, req_opts: req_opts}
res = :hackney.request(method, url, headers, body, req_opts)
case res do
{:ok, code, resp_headers, _client} when code in @redirect_statuses ->
:hackney.request(
method,
absolute_redirect_url(url, resp_headers),
headers,
body,
req_opts
)
redirect(url, resp_headers, env, @redirect_limit)
{:ok, code, resp_headers} when code in @redirect_statuses ->
:hackney.request(
method,
absolute_redirect_url(url, resp_headers),
headers,
body,
req_opts
)
redirect(url, resp_headers, env, @redirect_limit)
_ ->
res
@ -71,4 +63,32 @@ defmodule Pleroma.ReverseProxy.Client.Hackney do
@impl true
def close(ref), do: :hackney.close(ref)
defp redirect(url, resp_headers, env, limit) when limit == 0 do
new_url = absolute_redirect_url(url, resp_headers)
Logger.debug(
"#{__MODULE__}: Handling redirect #{url} -> #{new_url}; redirect limit was reached - returning response after final redirect"
)
:hackney.request(env.method, new_url, env.headers, env.body, env.req_opts)
end
defp redirect(url, resp_headers, env, limit) do
new_url = absolute_redirect_url(url, resp_headers)
Logger.debug("#{__MODULE__}: handling redirect #{url} -> #{new_url}; limit = #{limit}")
res = :hackney.request(env.method, new_url, env.headers, env.body, env.req_opts)
case res do
{:ok, code, new_resp_headers, _client} when code in @redirect_statuses ->
redirect(new_url, new_resp_headers, env, limit - 1)
{:ok, code, new_resp_headers} when code in @redirect_statuses ->
redirect(new_url, new_resp_headers, env, limit - 1)
_ ->
res
end
end
end

View file

@ -1,11 +1,28 @@
defmodule Pleroma.Search do
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Workers.SearchIndexingWorker
def add_to_index(%Pleroma.Activity{id: activity_id}) do
SearchIndexingWorker.new(%{"op" => "add_to_index", "activity" => activity_id})
|> Oban.insert()
@spec add_to_index(Activity.t()) :: {:ok, Oban.Job.t() | :noop} | {:error, Oban.Job.changeset()}
def add_to_index(%Activity{id: activity_id, object: %Object{} = object} = activity) do
with {_, true} <- {:indexable, indexable?(activity)},
{_, "public"} <- {:visibility, Visibility.get_visibility(object)} do
SearchIndexingWorker.new(%{"op" => "add_to_index", "activity" => activity_id})
|> Oban.insert()
else
_ -> {:ok, :noop}
end
end
def add_to_index(%Activity{id: activity_id}) do
case Activity.get_by_id_with_object(activity_id) do
%Activity{} = preloaded -> add_to_index(preloaded)
_ -> {:ok, :noop}
end
end
@spec remove_from_index(Object.t()) :: {:ok, Oban.Job.t()} | {:error, Oban.Job.changeset()}
def remove_from_index(%Pleroma.Object{id: object_id}) do
SearchIndexingWorker.new(%{"op" => "remove_from_index", "object" => object_id})
|> Oban.insert()
@ -20,4 +37,44 @@ defmodule Pleroma.Search do
search_module = Pleroma.Config.get([Pleroma.Search, :module])
search_module.healthcheck_endpoints()
end
def object_to_search_data(%Object{} = object) do
data = object.data
content_str =
case data["content"] do
[nil | rest] -> to_string(rest)
str -> str
end
content =
with {:ok, scrubbed} <-
FastSanitize.Sanitizer.scrub(content_str, Pleroma.HTML.Scrubber.SearchIndexing),
trimmed <- String.trim(scrubbed) do
trimmed
end
# Make sure we have a non-empty string
if content != "" do
{:ok, published, _} = DateTime.from_iso8601(data["published"])
%{
id: object.id,
content: content,
ap: data["id"],
published: published |> DateTime.to_unix()
}
end
end
defp indexable?(%Activity{
data: %{"type" => "Create"},
object: %Object{
data: %{"content" => content, "published" => published, "type" => "Note"}
}
})
when not is_nil(content) and content not in ["", "."] and not is_nil(published),
do: true
defp indexable?(_), do: false
end

View file

@ -4,6 +4,8 @@ defmodule Pleroma.Search.Meilisearch do
alias Pleroma.Activity
alias Pleroma.Config.Getting, as: Config
alias Pleroma.Object
alias Pleroma.Search
import Pleroma.Search.DatabaseSearch
import Ecto.Query
@ -118,66 +120,24 @@ defmodule Pleroma.Search.Meilisearch do
end
end
def object_to_search_data(object) do
# Only index public or unlisted Notes
if not is_nil(object) and object.data["type"] == "Note" and
not is_nil(object.data["content"]) and
not is_nil(object.data["published"]) and
(Pleroma.Constants.as_public() in object.data["to"] or
Pleroma.Constants.as_public() in object.data["cc"]) and
object.data["content"] not in ["", "."] do
data = object.data
content_str =
case data["content"] do
[nil | rest] -> to_string(rest)
str -> str
end
content =
with {:ok, scrubbed} <-
FastSanitize.Sanitizer.scrub(content_str, Pleroma.HTML.Scrubber.SearchIndexing),
trimmed <- String.trim(scrubbed) do
trimmed
end
# Make sure we have a non-empty string
if content != "" do
{:ok, published, _} = DateTime.from_iso8601(data["published"])
%{
id: object.id,
content: content,
ap: data["id"],
published: published |> DateTime.to_unix()
}
end
end
end
@impl true
def add_to_index(activity) do
maybe_search_data = object_to_search_data(activity.object)
def add_to_index(%Activity{object: %Object{} = object} = activity) do
search_data = Search.object_to_search_data(object)
if activity.data["type"] == "Create" and maybe_search_data do
result =
meili_put(
"/indexes/objects/documents",
[maybe_search_data]
)
result =
meili_put(
"/indexes/objects/documents",
[search_data]
)
with {:ok, %{"status" => "enqueued"}} <- result do
# Added successfully
:ok
else
_ ->
# There was an error, report it
Logger.error("Failed to add activity #{activity.id} to index: #{inspect(result)}")
{:error, result}
end
else
# The post isn't something we can search, that's ok
with {:ok, %{"status" => "enqueued"}} <- result do
# Added successfully
:ok
else
_ ->
# There was an error, report it
Logger.error("Failed to add activity #{activity.id} to index: #{inspect(result)}")
{:error, result}
end
end

View file

@ -4,11 +4,12 @@ defmodule Pleroma.Search.QdrantSearch do
alias Pleroma.Activity
alias Pleroma.Config.Getting, as: Config
alias Pleroma.Object
alias Pleroma.Search
alias __MODULE__.OpenAIClient
alias __MODULE__.QdrantClient
import Pleroma.Search.Meilisearch, only: [object_to_search_data: 1]
import Pleroma.Search.DatabaseSearch, only: [maybe_fetch: 3]
@impl true
@ -82,23 +83,18 @@ defmodule Pleroma.Search.QdrantSearch do
end
@impl true
def add_to_index(activity) do
# This will only index public or unlisted notes
maybe_search_data = object_to_search_data(activity.object)
def add_to_index(%Activity{object: %Object{} = object} = activity) do
search_data = Search.object_to_search_data(object)
if activity.data["type"] == "Create" and maybe_search_data do
with {:ok, embedding} <- get_embedding(maybe_search_data.content),
{:ok, %{status: 200}} <-
QdrantClient.put(
"/collections/posts/points",
build_index_payload(activity, embedding)
) do
:ok
else
e -> {:error, e}
end
else
with {:ok, embedding} <- get_embedding(search_data.content),
{:ok, %{status: 200}} <-
QdrantClient.put(
"/collections/posts/points",
build_index_payload(activity, embedding)
) do
:ok
else
e -> {:error, e}
end
end

View file

@ -104,7 +104,7 @@ defmodule Pleroma.Signature do
|> put_req_header("(request-target)", request_target)
|> put_req_header("@request-target", request_target)
@http_signatures_impl.validate_conn(conn)
@http_signatures_impl.validate_conn(conn) == true
end
@spec validate_signature(Plug.Conn.t()) :: boolean()

View file

@ -4,6 +4,7 @@
defmodule Pleroma.User.Search do
alias Pleroma.EctoType.ActivityPub.ObjectValidators.Uri, as: UriType
alias Pleroma.Instances.Instance
alias Pleroma.Pagination
alias Pleroma.User
@ -88,12 +89,13 @@ defmodule Pleroma.User.Search do
|> filter_invisible_users()
|> filter_internal_users()
|> filter_blocked_domains(for_user)
|> filter_unreachable_users()
|> fts_search(query_string)
|> select_top_users(top_user_ids)
|> trigram_rank(query_string)
|> boost_search_rank(for_user, top_user_ids)
|> subquery()
|> order_by(desc: :search_rank)
|> order_by_search_rank(for_user)
|> maybe_restrict_local(for_user)
|> maybe_restrict_accepting_chat_messages(capabilities)
|> filter_deactivated_users()
@ -196,6 +198,14 @@ defmodule Pleroma.User.Search do
defp filter_blocked_domains(query, _), do: query
defp filter_unreachable_users(query) do
from(u in query,
left_join: i in Instance,
on: i.host == fragment("substring(? from '.*://([^/]*)')", u.ap_id),
where: is_nil(i.unreachable_since)
)
end
defp maybe_resolve(true, user, query) do
case {limit(), user} do
{:all, _} -> :noop
@ -236,6 +246,16 @@ defmodule Pleroma.User.Search do
from(u in subquery(query),
select_merge: %{
search_type:
fragment(
"""
CASE WHEN (?) THEN 2
WHEN (?) THEN 1
ELSE 0 END
""",
u.id in ^top_user_ids,
u.id in ^friends_ids or u.id in ^followers_ids
),
search_rank:
fragment(
"""
@ -261,6 +281,14 @@ defmodule Pleroma.User.Search do
defp boost_search_rank(query, _for_user, top_user_ids) do
from(u in subquery(query),
select_merge: %{
search_type:
fragment(
"""
CASE WHEN (?) THEN 2
ELSE 0 END
""",
u.id in ^top_user_ids
),
search_rank:
fragment(
"""
@ -273,4 +301,22 @@ defmodule Pleroma.User.Search do
}
)
end
defp order_by_search_rank(query, %User{}) do
order_by(
query,
[u],
desc: u.search_type,
desc_nulls_last:
fragment(
"CASE WHEN ? = 1 THEN COALESCE(?, ?) ELSE NULL END",
u.search_type,
u.last_status_at,
u.last_active_at
),
desc: u.search_rank
)
end
defp order_by_search_rank(query, _), do: order_by(query, desc: :search_rank)
end

View file

@ -1677,44 +1677,80 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
show_birthday = !!birthday
# if WebFinger request was already done, we probably have acct, otherwise
# we request WebFinger here
nickname = additional[:nickname_from_acct] || generate_nickname(data)
with {:ok, nickname} <- nickname_from_actor(data, additional) do
{:ok,
%{
ap_id: data["id"],
uri: get_actor_url(data["url"]),
banner: normalize_image(data["image"]),
fields: fields,
emoji: emojis,
is_locked: is_locked,
is_discoverable: is_discoverable,
invisible: invisible,
avatar: normalize_image(data["icon"]),
name: data["name"],
follower_address: data["followers"],
following_address: data["following"],
featured_address: featured_address,
bio: data["summary"] || "",
actor_type: actor_type,
also_known_as: normalize_also_known_as(data["alsoKnownAs"]),
public_key: public_key,
inbox: data["inbox"],
shared_inbox: shared_inbox,
accepts_chat_messages: accepts_chat_messages,
birthday: birthday,
show_birthday: show_birthday,
pinned_objects: pinned_objects,
nickname: nickname
}}
end
end
%{
ap_id: data["id"],
uri: get_actor_url(data["url"]),
banner: normalize_image(data["image"]),
fields: fields,
emoji: emojis,
is_locked: is_locked,
is_discoverable: is_discoverable,
invisible: invisible,
avatar: normalize_image(data["icon"]),
name: data["name"],
follower_address: data["followers"],
following_address: data["following"],
featured_address: featured_address,
bio: data["summary"] || "",
actor_type: actor_type,
also_known_as: normalize_also_known_as(data["alsoKnownAs"]),
public_key: public_key,
inbox: data["inbox"],
shared_inbox: shared_inbox,
accepts_chat_messages: accepts_chat_messages,
birthday: birthday,
show_birthday: show_birthday,
pinned_objects: pinned_objects,
nickname: nickname
}
defp nickname_from_actor(data, additional) do
generated = generated_nickname(data)
case additional[:nickname_from_acct] do
^generated when is_binary(generated) ->
{:ok, generated}
acct when is_binary(acct) ->
with ^acct <- webfinger_nickname(data) do
{:ok, acct}
else
_ -> {:error, {:webfinger_actor_mismatch, acct, data["id"]}}
end
_ ->
{:ok, generate_nickname(data)}
end
end
defp generated_nickname(%{"preferredUsername" => username, "id" => ap_id})
when is_binary(username) and is_binary(ap_id) do
case URI.parse(ap_id) do
%URI{host: host} when is_binary(host) -> "#{username}@#{host}"
_ -> nil
end
end
defp generated_nickname(_), do: nil
defp webfinger_nickname(data) do
with generated when is_binary(generated) <- generated_nickname(data),
{:ok, %{"subject" => "acct:" <> acct, "ap_id" => ap_id}} <- WebFinger.finger(generated),
true <- ap_id == data["id"] do
acct
end
end
defp generate_nickname(%{"preferredUsername" => username} = data) when is_binary(username) do
generated = "#{username}@#{URI.parse(data["id"]).host}"
generated = generated_nickname(data)
if Config.get([WebFinger, :update_nickname_on_user_fetch]) do
case WebFinger.finger(generated) do
{:ok, %{"subject" => "acct:" <> acct}} -> acct
case webfinger_nickname(data) do
acct when is_binary(acct) -> acct
_ -> generated
end
else
@ -1794,9 +1830,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
defp collection_private(_data), do: {:ok, true}
def user_data_from_user_object(data, additional \\ []) do
with {:ok, data} <- MRF.filter(data) do
{:ok, object_to_user_data(data, additional)}
with {:ok, data} <- MRF.filter(data),
{:ok, data} <- object_to_user_data(data, additional) do
{:ok, data}
else
{:error, _} = e -> e
e -> {:error, e}
end
end

View file

@ -348,7 +348,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
end
def inbox(%{assigns: %{valid_signature: false}} = conn, params) do
Federator.incoming_ap_doc(%{
Federator.incoming_failed_signature_ap_doc(%{
method: conn.method,
req_headers: conn.req_headers,
request_path: conn.request_path,

View file

@ -6,9 +6,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
use Ecto.Schema
alias Pleroma.EctoType.ActivityPub.ObjectValidators
alias Pleroma.HTML
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.CommonAPI.Utils
import Ecto.Changeset
@ -26,6 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
end
field(:replies, {:array, ObjectValidators.ObjectID}, default: [])
field(:source, :map)
end
def cast_and_apply(data) do
@ -80,6 +84,113 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
def fix_attachments(data), do: data
defp remote_mention_resolver(
%{"id" => ap_id, "tag" => tags},
"@" <> nickname = mention,
buffer,
opts,
acc
)
when is_binary(ap_id) and is_list(tags) do
initial_host =
ap_id
|> URI.parse()
|> Map.get(:host)
with mention_tag when not is_nil(mention_tag) <-
Enum.find(tags, &mention_tag?(&1, mention, initial_host)),
href when is_binary(href) <- mention_tag["href"],
%User{} = user <- User.get_cached_by_ap_id(href) do
link = Pleroma.Formatter.mention_from_user(user, opts)
{link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, user})}}
else
_ -> {buffer, acc}
end
end
defp remote_mention_resolver(_object, _mention, buffer, _opts, acc), do: {buffer, acc}
defp mention_tag?(%{"type" => "Mention", "name" => name}, mention, initial_host)
when is_binary(name) do
name == mention || mention == "#{name}@#{initial_host}"
end
defp mention_tag?(_tag, _mention, _initial_host), do: false
defp scrub_content(%{"content" => content} = object) when is_binary(content) do
Map.put(object, "content", HTML.filter_tags(content))
end
defp scrub_content(object), do: object
defp mfm_parse_limit do
min(Pleroma.Config.get([:instance, :limit]), Pleroma.Config.get([:instance, :remote_limit]))
end
defp normalize_source(%{"source" => source} = object) when is_binary(source) do
object
|> Map.put("source", %{"content" => source})
|> normalize_source()
end
defp normalize_source(%{"source" => source} = object) when is_map(source) do
source =
case source["content"] do
content when is_binary(content) ->
if String.length(content) <= mfm_parse_limit() do
source
else
Map.delete(source, "content")
end
nil ->
source
_ ->
Map.delete(source, "content")
end
Map.put(object, "source", source)
end
defp normalize_source(object), do: object
defp fix_misskey_content(%{"htmlMfm" => true, "content" => content} = object)
when is_binary(content) do
Map.put(object, "content", HTML.filter_tags(content))
end
defp fix_misskey_content(%{"htmlMfm" => true} = object), do: object
defp fix_misskey_content(
%{"source" => %{"mediaType" => "text/x.misskeymarkdown", "content" => content}} = object
)
when is_binary(content) do
mention_handler = fn nick, buffer, opts, acc ->
remote_mention_resolver(object, nick, buffer, opts, acc)
end
{linked, _mentions, _tags} =
Utils.format_input(content, "text/x.misskeymarkdown", mention_handler: mention_handler)
Map.put(object, "content", linked)
end
defp fix_misskey_content(%{"source" => %{"mediaType" => "text/x.misskeymarkdown"}} = object),
do: scrub_content(object)
defp fix_misskey_content(%{"_misskey_content" => content} = object) when is_binary(content) do
object
|> Map.put("source", %{
"content" => content,
"mediaType" => "text/x.misskeymarkdown"
})
|> Map.delete("_misskey_content")
|> fix_misskey_content()
end
defp fix_misskey_content(object), do: object
defp fix(data) do
data
|> CommonFixes.fix_actor()
@ -88,6 +199,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
|> fix_tag()
|> fix_replies()
|> fix_attachments()
|> normalize_source()
|> fix_misskey_content()
|> CommonFixes.fix_quote_url()
|> CommonFixes.fix_likes()
|> Transmogrifier.fix_emoji()

View file

@ -32,6 +32,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFields do
quote bind_quoted: binding() do
field(:content, :string)
field(:contentMap, ObjectValidators.ContentLanguageMap)
field(:htmlMfm, :boolean)
field(:published, ObjectValidators.DateTime)
field(:updated, ObjectValidators.DateTime)

View file

@ -28,6 +28,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do
end
field(:closed, ObjectValidators.DateTime)
field(:votersCount, :integer)
field(:voters, {:array, ObjectValidators.ObjectID}, default: [])
field(:nonAnonymous, :boolean)
embeds_many(:anyOf, QuestionOptionsValidator)

View file

@ -75,15 +75,40 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator do
end
end
# For remote Updates, verify the host is the same.
# For remote Updates, verify the Actor is the same
def validate_updating_rights_remote(cng) do
with actor = get_field(cng, :actor),
object = get_field(cng, :object),
{:ok, object_id} <- ObjectValidators.ObjectID.cast(object),
actor_uri <- URI.parse(actor),
object_uri <- URI.parse(object_id),
true <- actor_uri.host == object_uri.host do
cng
entity <-
Object.normalize(object_id, fetch: false) || User.get_cached_by_ap_id(object_id) do
case entity do
# Actor must own Object to update it
%Object{} ->
if actor == entity.data["actor"] do
cng
else
cng
|> add_error(:object, "Can't be updated by this actor")
end
# Actor must only be allowed to update itself
%User{} ->
if actor == entity.ap_id do
cng
else
cng
|> add_error(:object, "Can't be updated by this actor")
end
nil ->
cng
|> add_error(:object, "Can't be updated by this actor")
_ ->
cng
|> add_error(:object, "Update is neither for Object or Actor")
end
else
_e ->
cng

View file

@ -430,6 +430,12 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
end)
end
defp reject_third_party_report(%User{local: false}, %User{local: false} = account) do
{:reject, "[Transmogrifier] third-party report: #{account.ap_id}"}
end
defp reject_third_party_report(_, _), do: :ok
def handle_incoming(data, options \\ []) do
data
|> fix_recursive(&strip_internal_fields/1)
@ -444,9 +450,11 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
) do
with context <- data["context"] || Utils.generate_context_id(),
content <- data["content"] || "",
objects <- List.wrap(objects),
%User{} = actor <- User.get_cached_by_ap_id(actor),
# Reduce the object list to find the reported user.
%User{} = account <- get_reported(objects),
:ok <- reject_third_party_report(actor, account),
# Remove the reported user from the object list.
statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
%{
@ -783,6 +791,12 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def set_replies(obj_data), do: obj_data
defp set_voters_count(%{"voters" => [_ | _] = voters} = obj) do
Map.merge(obj, %{"votersCount" => length(voters)})
end
defp set_voters_count(obj), do: obj
# Prepares and sanitizes the object for federation.
def prepare_object(object) do
object
@ -795,6 +809,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
|> set_reply_to_uri
|> set_quote_url
|> set_replies
|> set_voters_count
|> CommonFixes.maybe_add_content_map()
|> strip_internal_fields
|> strip_internal_tags

View file

@ -120,7 +120,8 @@ defmodule Pleroma.Web.ActivityPub.Utils do
"https://www.w3.org/ns/activitystreams",
"#{Endpoint.url()}/schemas/litepub-0.1.jsonld",
%{
"@language" => get_language(data)
"@language" => get_language(data),
"htmlMfm" => "https://w3id.org/fep/c16b#htmlMfm"
}
]
}

View file

@ -172,7 +172,10 @@ defmodule Pleroma.Web.ApiSpec.ListOperation do
type: :object,
properties: %{
title: %Schema{type: :string, description: "List title"},
exclusive: %Schema{type: :boolean, description: "Whether members of the list should be removed from the “Home” feed"}
exclusive: %Schema{
type: :boolean,
description: "Whether members of the list should be removed from the “Home” feed"
}
},
required: [:title]
},
@ -188,7 +191,10 @@ defmodule Pleroma.Web.ApiSpec.ListOperation do
type: :object,
properties: %{
title: %Schema{type: :string, description: "List title"},
exclusive: %Schema{type: :boolean, description: "Whether members of the list should be removed from the “Home” feed"}
exclusive: %Schema{
type: :boolean,
description: "Whether members of the list should be removed from the “Home” feed"
}
}
},
required: true

View file

@ -317,6 +317,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
emoji = Map.merge(emoji, summary_emoji)
media_type = Utils.get_content_type(draft.params[:content_type])
{:ok, note_data, _meta} = Builder.note(draft)
object =
@ -324,14 +325,18 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
|> Map.put("emoji", emoji)
|> Map.put("source", %{
"content" => draft.status,
"mediaType" => Utils.get_content_type(draft.params[:content_type])
"mediaType" => media_type
})
|> maybe_put("htmlMfm", true, media_type == "text/x.misskeymarkdown")
|> Map.put("generator", draft.params[:generator])
|> Map.put("language", draft.language)
%{draft | object: object}
end
defp maybe_put(map, key, value, true), do: Map.put(map, key, value)
defp maybe_put(map, _key, _value, _condition), do: map
defp preview?(%__MODULE__{} = draft) do
preview? = Pleroma.Web.Utils.Params.truthy_param?(draft.params[:preview])
%{draft | preview?: preview?}

View file

@ -322,6 +322,14 @@ defmodule Pleroma.Web.CommonAPI.Utils do
|> Formatter.linkify(options)
end
def format_input(text, "text/x.misskeymarkdown", options) do
text
|> Formatter.markdown_to_html(%{breaks: true})
|> safe_mfm_to_html()
|> Formatter.linkify(options)
|> Formatter.html_escape("text/x.misskeymarkdown")
end
def format_input(text, "text/markdown", options) do
text
|> Formatter.mentions_escape(options)
@ -330,6 +338,16 @@ defmodule Pleroma.Web.CommonAPI.Utils do
|> Formatter.html_escape("text/html")
end
defp safe_mfm_to_html(html) do
html
|> MfmParser.Parser.parse()
|> MfmParser.Encoder.to_html()
rescue
_ -> html
catch
_, _ -> html
end
def format_naive_asctime(date) do
date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
end

View file

@ -20,6 +20,7 @@ defmodule Pleroma.Web.EmbedController do
conn
|> delete_resp_header("x-frame-options")
|> delete_resp_header("content-security-policy")
|> put_layout({Pleroma.Web.LayoutView, :embed})
|> render("show.html",
activity: activity,
author: User.sanitize_html(author),

View file

@ -46,8 +46,10 @@ defmodule Pleroma.Web.Endpoint do
plug(Pleroma.Web.Plugs.HTTPSecurityPlug)
plug(Pleroma.Web.Plugs.UploadedMedia)
@static_cache_control "public, max-age=1209600"
@static_cache_control "public, max-age=1209600, immutable"
@static_cache_disabled "public, no-cache"
# cache for a day
@favicon_cache_control "public, max=age=86400, immutable"
# InstanceStatic needs to be before Plug.Static to be able to override shipped-static files
# If you're adding new paths to `only:` you'll need to configure them in InstanceStatic as well
@ -64,6 +66,15 @@ defmodule Pleroma.Web.Endpoint do
}
)
plug(Pleroma.Web.Plugs.FaviconPlug,
at: "/",
only: ["favicon.png"],
cache_control_for_etags: @favicon_cache_control,
headers: %{
"cache-control" => @favicon_cache_control
}
)
plug(Pleroma.Web.Plugs.InstanceStatic,
at: "/",
gzip: true,

View file

@ -29,9 +29,18 @@ defmodule Pleroma.Web.Fallback.RedirectController do
)
end
def live_dashboard(conn, _params) do
def live_dashboard(conn, %{"path" => path}) do
query_params = conn.query_string
redirect_path =
if query_params == "" do
"/pleroma/live_dashboard/#{path}"
else
"/pleroma/live_dashboard/#{path}?#{query_params}"
end
conn
|> redirect(to: "/pleroma/live_dashboard")
|> redirect(to: redirect_path)
end
def redirector(conn, _params, code \\ 200) do

View file

@ -11,6 +11,7 @@ defmodule Pleroma.Web.Federator do
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Workers.PublisherWorker
alias Pleroma.Workers.ReceiverWorker
alias Pleroma.Workers.SignatureRetryWorker
require Logger
@ -35,12 +36,21 @@ defmodule Pleroma.Web.Federator do
end
# Client API
def incoming_ap_doc(%{params: params, req_headers: req_headers}) do
ReceiverWorker.new(
def incoming_failed_signature_ap_doc(%{
method: method,
params: params,
req_headers: req_headers,
request_path: request_path,
query_string: query_string
}) do
SignatureRetryWorker.new(
%{
"op" => "incoming_ap_doc",
"op" => "incoming_failed_signature_ap_doc",
"method" => method,
"req_headers" => req_headers,
"params" => params,
"request_path" => request_path,
"query_string" => query_string,
"timeout" => :timer.seconds(20)
},
priority: 2

View file

@ -71,6 +71,8 @@ defmodule Pleroma.Web.MastodonAPI.PollView do
end)
end
defp voters_count(%{data: %{"votersCount" => voters}}) when is_integer(voters), do: voters
defp voters_count(%{data: %{"voters" => [_ | _] = voters}}) do
length(voters)
end

View file

@ -7,8 +7,8 @@ defmodule Pleroma.Web.OAuth.TokenController do
alias Pleroma.User
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.OAuth.TokenView
alias Pleroma.Web.Plugs.OAuthScopesPlug
require Logger

View file

@ -7,8 +7,8 @@ defmodule Pleroma.Web.PleromaAPI.TokenController do
alias Pleroma.User
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.PleromaAPI.TokenView
alias Pleroma.Web.Plugs.OAuthScopesPlug
require Logger

View file

@ -0,0 +1,71 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.FaviconPlug do
@behaviour Plug
@moduledoc """
This is a shim to call `Plug.Static` but with runtime `from` configuration for instance favicon.
Serves default or custom favicon.png with cacheable cache-control.
"""
import Plug.Conn, only: [put_resp_header: 3, send_resp: 3, halt: 1]
require Logger
def init(opts) do
opts
|> Keyword.put(:from, "__unconfigured_favicon_static_plug")
|> Plug.Static.init()
end
def call(%{request_path: "/favicon.png"} = conn, opts) do
case find_favicon_dir() do
{:ok, dir} ->
call_static(conn, opts, dir)
:error ->
# Favicon should always be available and this should never occur.
# If it does, halt the pipeline before having unintended side-effects.
Logger.error("No favicon.png found! Is the default favicon deleted?")
conn
|> send_resp(404, "Not found")
|> halt()
end
end
def call(conn, _) do
conn
end
defp find_favicon_dir do
instance_dir = Pleroma.Config.get([:instance, :static_dir], "instance/static")
instance_path = Path.join(instance_dir, "favicon.png")
priv_dir = Application.app_dir(:pleroma, "priv/static")
priv_path = Path.join(priv_dir, "favicon.png")
cond do
File.exists?(instance_path) -> {:ok, instance_dir}
File.exists?(priv_path) -> {:ok, priv_dir}
true -> :error
end
end
defp call_static(conn, opts, from) do
opts =
opts
|> Map.put(:from, from)
|> Map.put(:content_types, false)
conn = set_content_type(conn)
Plug.Static.call(conn, opts)
end
defp set_content_type(conn) do
put_resp_header(conn, "content-type", "image/png")
end
end

View file

@ -4,7 +4,7 @@
defmodule Pleroma.Web.Plugs.InstanceStatic do
require Pleroma.Constants
import Plug.Conn, only: [put_resp_header: 3]
import Plug.Conn, only: [put_resp_header: 3, put_status: 2, send_resp: 3, halt: 1]
@moduledoc """
This is a shim to call `Plug.Static` but with runtime `from` configuration.
@ -51,10 +51,37 @@ defmodule Pleroma.Web.Plugs.InstanceStatic do
|> Map.put(:from, from)
|> Map.put(:content_types, false)
conn = set_content_type(conn, conn.request_path)
conn =
conn
|> set_content_type(conn.request_path)
|> Plug.Static.call(opts)
# Call Plug.Static with our sanitized content-type
Plug.Static.call(conn, opts)
if conn.halted do
conn
else
path = String.trim_leading(conn.request_path, "/")
if not File.exists?(file_path(path)) do
conn
|> put_status(:not_found)
|> send_404()
|> halt()
else
conn
end
end
end
defp send_404(conn) do
if String.ends_with?(String.downcase(conn.request_path), ".json") do
conn
|> put_resp_header("content-type", "application/json")
|> send_resp(404, Jason.encode!(%{error: "not found"}))
else
conn
|> put_resp_header("content-type", "text/plain")
|> send_resp(404, "Not found")
end
end
defp set_content_type(conn, "/emoji/" <> filepath) do

View file

@ -32,8 +32,8 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do
# remove me once testsuite uses mapped capabilities instead of what we do now
{:user, nil} ->
Logger.debug("Failed to map identity from signature (lookup failure)")
Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{actor}")
conn
Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{inspect(actor)}")
assign(conn, :valid_signature, false)
end
end

View file

@ -67,6 +67,8 @@ defmodule Pleroma.Web.Plugs.RateLimiter do
import Plug.Conn
alias Pleroma.Config
alias Pleroma.Config.Holder
alias Pleroma.EctoType.Config.RateLimit
alias Pleroma.User
alias Pleroma.Web.Plugs.RateLimiter.LimiterSupervisor
@ -143,7 +145,7 @@ defmodule Pleroma.Web.Plugs.RateLimiter do
def action_settings(plug_opts) do
with limiter_name when is_atom(limiter_name) <- plug_opts[:name],
limits when not is_nil(limits) <- Config.get([:rate_limit, limiter_name]) do
{:ok, limits} <- fetch_and_normalize_limits(limiter_name) do
bucket_name_root = Keyword.get(plug_opts, :bucket_name, limiter_name)
%{
@ -151,6 +153,72 @@ defmodule Pleroma.Web.Plugs.RateLimiter do
limits: limits,
opts: plug_opts
}
else
:disabled -> nil
end
end
defp fetch_and_normalize_limits(limiter_name) do
limits = Config.get([:rate_limit, limiter_name])
case normalize_limits(limits) do
{:ok, limits} ->
{:ok, limits}
:disabled ->
:disabled
:error ->
default_limits =
Holder.default_config(:pleroma, :rate_limit)
|> get_default_limits(limiter_name)
case normalize_limits(default_limits) do
{:ok, normalized_limits} ->
warn_invalid_limits_once(limiter_name, limits)
{:ok, normalized_limits}
_ ->
warn_invalid_limits_once(limiter_name, limits)
:disabled
end
end
end
defp get_default_limits(%{} = rate_limit, limiter_name), do: Map.get(rate_limit, limiter_name)
defp get_default_limits(rate_limit, limiter_name) when is_list(rate_limit) do
if Keyword.keyword?(rate_limit) do
Keyword.get(rate_limit, limiter_name)
else
nil
end
end
defp get_default_limits(_, _), do: nil
@invalid_limits_warned_key {__MODULE__, :invalid_limits_warned}
defp warn_invalid_limits_once(limiter_name, limits) do
warned = :persistent_term.get(@invalid_limits_warned_key, MapSet.new())
if MapSet.member?(warned, limiter_name) do
:ok
else
:persistent_term.put(@invalid_limits_warned_key, MapSet.put(warned, limiter_name))
Logger.warning(
"Invalid rate limiter config for #{inspect(limiter_name)}: #{inspect(limits)}. Falling back to defaults or disabling this limiter."
)
end
end
defp normalize_limits(nil), do: :disabled
defp normalize_limits(limits) do
case RateLimit.cast(limits) do
{:ok, normalized_limits} -> {:ok, normalized_limits}
:error -> :error
end
end

View file

@ -17,7 +17,7 @@ defmodule Pleroma.Web.Plugs.UploadedMedia do
# no slashes
@path "media"
@default_cache_control_header "public, max-age=1209600"
@default_cache_control_header "public, max-age=1209600, immutable"
def init(_opts) do
static_plug_opts =

View file

@ -5,9 +5,9 @@
defmodule Pleroma.Web.Preload.Providers.Instance do
alias Pleroma.Web.MastodonAPI.InstanceView
alias Pleroma.Web.Nodeinfo.Nodeinfo
alias Pleroma.Web.PleromaAPI.UtilView
alias Pleroma.Web.Plugs.InstanceStatic
alias Pleroma.Web.Preload.Providers.Provider
alias Pleroma.Web.PleromaAPI.UtilView
@behaviour Provider
@instance_url "/api/v1/instance"

View file

@ -1032,6 +1032,8 @@ defmodule Pleroma.Web.Router do
pipe_through(:pleroma_html)
post("/auth/password", OAuth.PasswordController, :request)
get("/embed/:id", EmbedController, :show)
end
scope "/proxy/", Pleroma.Web do
@ -1093,7 +1095,7 @@ defmodule Pleroma.Web.Router do
get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta)
match(:*, "/api/pleroma/*path", LegacyPleromaApiRerouterPlug, [])
get("/api/*path", RedirectController, :api_not_implemented)
get("/phoenix/live_dashboard", RedirectController, :live_dashboard)
get("/phoenix/live_dashboard/*path", RedirectController, :live_dashboard)
get("/*path", RedirectController, :redirector_with_preload)
options("/*path", RedirectController, :empty)

View file

@ -7,7 +7,7 @@ defmodule Pleroma.Workers.Cron.DigestEmailsWorker do
The worker to send digest emails.
"""
use Oban.Worker, queue: "mailer"
use Oban.Worker, queue: :background
alias Pleroma.Config
alias Pleroma.Emails

View file

@ -4,40 +4,37 @@
defmodule Pleroma.Workers.ReceiverWorker do
alias Pleroma.Instances
alias Pleroma.Signature
alias Pleroma.User
alias Pleroma.Web.Federator
alias Pleroma.Workers.SignatureRetryWorker
use Oban.Worker, queue: :federator_incoming, max_attempts: 5, unique: [period: :infinity]
@impl true
def perform(%Job{args: %{"op" => "incoming_ap_doc", "params" => params} = args} = job) do
if signature_retry_job?(args) do
perform_signature_retry(job)
else
perform_incoming(params)
end
end
def perform(%Job{
args: %{
"op" => "incoming_ap_doc",
"method" => method,
"params" => params,
"req_headers" => req_headers,
"request_path" => request_path,
"query_string" => query_string
}
}) do
# Oban's serialization converts our tuple headers to lists.
# Revert it for the signature validation.
req_headers = Enum.into(req_headers, [], &List.to_tuple(&1))
def perform(%Job{args: %{"op" => "incoming_ap_doc"} = args} = job) do
if signature_retry_job?(args) do
perform_signature_retry(job)
else
process_errors(:missing_incoming_ap_doc_params)
end
end
conn_data = %Plug.Conn{
method: method,
params: params,
req_headers: req_headers,
request_path: request_path,
query_string: query_string
}
defp perform_signature_retry(%Job{args: args} = job) do
SignatureRetryWorker.perform(%Job{
job
| args: Map.put(args, "op", "incoming_failed_signature_ap_doc")
})
end
with {:ok, %User{}} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]),
{:ok, _public_key} <- Signature.refetch_public_key(conn_data),
{:signature, true} <- {:signature, Signature.validate_signature(conn_data)},
{:ok, res} <- Federator.perform(:incoming_ap_doc, params) do
defp perform_incoming(params) do
with {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do
unless Instances.reachable?(params["actor"]) do
domain = URI.parse(params["actor"]).host
Oban.insert(Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain}))
@ -49,17 +46,8 @@ defmodule Pleroma.Workers.ReceiverWorker do
end
end
def perform(%Job{args: %{"op" => "incoming_ap_doc", "params" => params}}) do
with {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do
unless Instances.reachable?(params["actor"]) do
domain = URI.parse(params["actor"]).host
Oban.insert(Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain}))
end
{:ok, res}
else
e -> process_errors(e)
end
defp signature_retry_job?(args) do
Enum.any?(~w(method req_headers request_path query_string), &Map.has_key?(args, &1))
end
@impl true
@ -85,10 +73,12 @@ defmodule Pleroma.Workers.ReceiverWorker do
{:error, {:reject, _} = reason} -> {:cancel, reason}
# HTTP Sigs
{:signature, false} -> {:cancel, :invalid_signature}
{:same_actor, false} -> {:cancel, :actor_signature_mismatch}
# Origin / URL validation failed somewhere possibly due to spoofing
{:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed}
# Unclear if this can be reached
{:error, {:side_effects, {:error, :no_object_actor}} = reason} -> {:cancel, reason}
:missing_incoming_ap_doc_params -> {:cancel, :missing_incoming_ap_doc_params}
# Catchall
{:error, _} = e -> e
e -> {:error, e}

View file

@ -0,0 +1,254 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.SignatureRetryWorker do
alias Pleroma.Instances
alias Pleroma.Signature
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.Federator
alias Pleroma.Web.Plugs.MappedSignatureToIdentityPlug
require Logger
use Oban.Worker, queue: :federator_incoming, max_attempts: 5, unique: [period: :infinity]
@impl true
def perform(%Job{
args: %{
"op" => "incoming_failed_signature_ap_doc",
"method" => method,
"params" => params,
"req_headers" => req_headers,
"request_path" => request_path,
"query_string" => query_string
}
})
when is_binary(method) and is_map(params) and is_list(req_headers) and
is_binary(request_path) and is_binary(query_string) do
case normalize_req_headers(req_headers) do
{:ok, req_headers} ->
conn_data = %Plug.Conn{
assigns: %{valid_signature: true},
method: method,
params: params,
req_headers: req_headers,
request_path: request_path,
query_string: query_string
}
signature_actor_result = signature_actor_id(conn_data)
with actor_id = Utils.get_ap_id(params["actor"]),
{:signature_actor, {:ok, signature_actor_id}} <-
{:signature_actor, signature_actor_result},
{:same_actor, true} <- {:same_actor, signature_actor_id == actor_id},
{:ok, %User{}} <- User.get_or_fetch_by_ap_id(actor_id),
{:ok, _public_key} <- Signature.refetch_public_key(conn_data),
{:signature, true} <- {:signature, validate_signature(conn_data)},
{:same_actor, true} <- {:same_actor, validate_same_actor(conn_data)},
{:ok, res} <- Federator.perform(:incoming_ap_doc, params) do
unless Instances.reachable?(params["actor"]) do
domain = URI.parse(params["actor"]).host
Oban.insert(Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain}))
end
{:ok, res}
else
e -> process_errors(e, retry_log_context(params, request_path, signature_actor_result))
end
e ->
process_errors(e, retry_log_context(params, request_path, nil))
end
end
def perform(%Job{args: %{"op" => "incoming_failed_signature_ap_doc"} = args}) do
process_errors(
:missing_signature_retry_metadata,
retry_log_context(Map.get(args, "params"), Map.get(args, "request_path"), nil)
)
end
def perform(%Job{args: args}) when is_map(args) do
process_errors(
:missing_signature_retry_metadata,
retry_log_context(Map.get(args, "params"), Map.get(args, "request_path"), nil)
)
end
def perform(%Job{}), do: process_errors(:missing_signature_retry_metadata)
@impl true
def timeout(%_{args: %{"timeout" => timeout}}), do: timeout
def timeout(_job), do: :timer.seconds(5)
defp normalize_req_headers(req_headers) do
req_headers
|> Enum.reduce_while({:ok, []}, fn
{key, value}, {:ok, acc} when is_binary(key) and is_binary(value) ->
{:cont, {:ok, [{key, value} | acc]}}
[key, value], {:ok, acc} when is_binary(key) and is_binary(value) ->
{:cont, {:ok, [{key, value} | acc]}}
_, _ ->
{:halt, {:error, :invalid_signature_retry_metadata}}
end)
|> case do
{:ok, headers} -> {:ok, Enum.reverse(headers)}
error -> error
end
end
defp validate_same_actor(conn_data) do
case MappedSignatureToIdentityPlug.call(conn_data, []) do
%Plug.Conn{assigns: %{valid_signature: true}} ->
true
_ ->
false
end
end
defp validate_signature(conn_data) do
Signature.validate_signature(conn_data)
rescue
_ -> false
catch
_, _ -> false
end
defp signature_actor_id(conn_data) do
Signature.get_actor_id(conn_data)
rescue
_ -> {:error, :invalid_signature}
catch
_, _ -> {:error, :invalid_signature}
end
defp process_errors(errors, context \\ %{})
defp process_errors({:error, {:error, _} = error}, context), do: process_errors(error, context)
defp process_errors(errors, context) do
result =
case errors do
# User fetch failures
{:error, :not_found} = reason ->
{:cancel, reason}
{:error, :forbidden} = reason ->
{:cancel, reason}
# Inactive user
{:error, {:user_active, false} = reason} ->
{:cancel, reason}
# Validator will error and return a changeset error
# e.g., duplicate activities or if the object was deleted
{:error, {:validate, {:error, _changeset} = reason}} ->
{:cancel, reason}
# Duplicate detection during Normalization
{:error, :already_present} ->
{:cancel, :already_present}
# MRFs will return a reject
{:error, {:reject, _} = reason} ->
{:cancel, reason}
# HTTP Sigs
{:signature_actor, {:error, _}} ->
{:cancel, :invalid_signature}
{:signature, false} ->
{:cancel, :invalid_signature}
{:same_actor, false} ->
{:cancel, :actor_signature_mismatch}
# Origin / URL validation failed somewhere possibly due to spoofing
{:error, :origin_containment_failed} ->
{:cancel, :origin_containment_failed}
# Unclear if this can be reached
{:error, {:side_effects, {:error, :no_object_actor}} = reason} ->
{:cancel, reason}
# Fail closed if the retry cannot reconstruct the original request.
:missing_signature_retry_metadata ->
{:cancel, :missing_signature_retry_metadata}
{:error, :invalid_signature_retry_metadata} ->
{:cancel, :invalid_signature_retry_metadata}
# Catchall
{:error, _} = e ->
e
e ->
{:error, e}
end
log_signature_retry_rejection(result, context)
result
end
defp retry_log_context(params, request_path, signature_actor_result) when is_map(params) do
signature_actor =
case signature_actor_result do
{:ok, actor} when is_binary(actor) -> actor
actor when is_binary(actor) -> actor
_ -> nil
end
%{
activity_id: params["id"],
payload_actor: Utils.get_ap_id(params["actor"]),
request_path: request_path,
signature_actor: signature_actor,
type: params["type"]
}
end
defp retry_log_context(_params, request_path, signature_actor_result) do
signature_actor =
case signature_actor_result do
{:ok, actor} when is_binary(actor) -> actor
actor when is_binary(actor) -> actor
_ -> nil
end
%{
activity_id: nil,
payload_actor: nil,
request_path: request_path,
signature_actor: signature_actor,
type: nil
}
end
defp log_signature_retry_rejection({:cancel, reason}, context)
when reason in [
:actor_signature_mismatch,
:invalid_signature,
:invalid_signature_retry_metadata,
:missing_signature_retry_metadata,
:origin_containment_failed
] do
Logger.warning(
"Failed-signature inbox retry rejected " <>
"reason=#{inspect(reason)} " <>
"payload_actor=#{inspect(context[:payload_actor])} " <>
"signature_actor=#{inspect(context[:signature_actor])} " <>
"activity_id=#{inspect(context[:activity_id])} " <>
"type=#{inspect(context[:type])} " <>
"request_path=#{inspect(context[:request_path])}"
)
end
defp log_signature_retry_rejection(_result, _context), do: :ok
end