Merge pull request 'Apply architecture review quick wins' (#7941) from architecture-quick-wins into develop

Reviewed-on: https://git.pleroma.social/pleroma/pleroma/pulls/7941
This commit is contained in:
lain 2026-07-24 14:31:39 +00:00
commit ef2b374850
15 changed files with 175 additions and 66 deletions

View file

@ -0,0 +1 @@
Avoid a duplicate user query during OAuth authentication.

View file

@ -0,0 +1 @@
Avoid creating atoms from serialized PublisherWorker parameters.

View file

@ -0,0 +1 @@
Keep followed users visible in streams when their domain is blocked.

View file

@ -0,0 +1 @@
Fix inconsistent User cache keys and invalidate numeric ID aliases.

View file

@ -272,13 +272,13 @@ defmodule Pleroma.User do
end end
def cached_blocked_users_ap_ids(user) do def cached_blocked_users_ap_ids(user) do
@cachex.fetch!(:user_cache, "blocked_users_ap_ids:#{user.ap_id}", fn _ -> @cachex.fetch!(:user_cache, cache_key(:blocked_users_ap_ids, user), fn _ ->
blocked_users_ap_ids(user) blocked_users_ap_ids(user)
end) end)
end end
def cached_muted_users_ap_ids(user) do def cached_muted_users_ap_ids(user) do
@cachex.fetch!(:user_cache, "muted_users_ap_ids:#{user.ap_id}", fn _ -> @cachex.fetch!(:user_cache, cache_key(:muted_users_ap_ids, user), fn _ ->
muted_users_ap_ids(user) muted_users_ap_ids(user)
end) end)
end end
@ -1253,13 +1253,18 @@ defmodule Pleroma.User do
get_cached_by_nickname(nickname) get_cached_by_nickname(nickname)
end end
defp cache_key(:id, %User{id: id}), do: cache_key(:id, id)
defp cache_key(:nickname, %User{nickname: nickname}), do: cache_key(:nickname, nickname)
defp cache_key(namespace, %User{ap_id: ap_id}), do: cache_key(namespace, ap_id)
defp cache_key(namespace, value), do: "#{namespace}:#{value}"
def set_cache({:ok, user}), do: set_cache(user) def set_cache({:ok, user}), do: set_cache(user)
def set_cache({:error, err}), do: {:error, err} def set_cache({:error, err}), do: {:error, err}
def set_cache(%User{} = user) do def set_cache(%User{} = user) do
@cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) @cachex.put(:user_cache, cache_key(:ap_id, user), user)
@cachex.put(:user_cache, "nickname:#{user.nickname}", user) @cachex.put(:user_cache, cache_key(:nickname, user), user)
@cachex.put(:user_cache, "friends_ap_ids:#{user.nickname}", get_user_friends_ap_ids(user)) @cachex.put(:user_cache, cache_key(:friends_ap_ids, user), get_user_friends_ap_ids(user))
{:ok, user} {:ok, user}
end end
@ -1287,22 +1292,23 @@ defmodule Pleroma.User do
@spec get_cached_user_friends_ap_ids(User.t()) :: [String.t()] @spec get_cached_user_friends_ap_ids(User.t()) :: [String.t()]
def get_cached_user_friends_ap_ids(user) do def get_cached_user_friends_ap_ids(user) do
@cachex.fetch!(:user_cache, "friends_ap_ids:#{user.ap_id}", fn _ -> @cachex.fetch!(:user_cache, cache_key(:friends_ap_ids, user), fn _ ->
get_user_friends_ap_ids(user) get_user_friends_ap_ids(user)
end) end)
end end
def invalidate_cache(user) do def invalidate_cache(user) do
@cachex.del(:user_cache, "ap_id:#{user.ap_id}") @cachex.del(:user_cache, cache_key(:ap_id, user))
@cachex.del(:user_cache, "nickname:#{user.nickname}") @cachex.del(:user_cache, cache_key(:nickname, user))
@cachex.del(:user_cache, "friends_ap_ids:#{user.ap_id}") @cachex.del(:user_cache, cache_key(:id, user))
@cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") @cachex.del(:user_cache, cache_key(:friends_ap_ids, user))
@cachex.del(:user_cache, "muted_users_ap_ids:#{user.ap_id}") @cachex.del(:user_cache, cache_key(:blocked_users_ap_ids, user))
@cachex.del(:user_cache, cache_key(:muted_users_ap_ids, user))
end end
@spec get_cached_by_ap_id(String.t()) :: User.t() | nil @spec get_cached_by_ap_id(String.t()) :: User.t() | nil
def get_cached_by_ap_id(ap_id) do def get_cached_by_ap_id(ap_id) do
key = "ap_id:#{ap_id}" key = cache_key(:ap_id, ap_id)
with {:ok, nil} <- @cachex.get(:user_cache, key), with {:ok, nil} <- @cachex.get(:user_cache, key),
user when not is_nil(user) <- get_by_ap_id(ap_id), user when not is_nil(user) <- get_by_ap_id(ap_id),
@ -1315,14 +1321,14 @@ defmodule Pleroma.User do
end end
def get_cached_by_id(id) do def get_cached_by_id(id) do
key = "id:#{id}" key = cache_key(:id, id)
ap_id = ap_id =
@cachex.fetch!(:user_cache, key, fn _ -> @cachex.fetch!(:user_cache, key, fn _ ->
user = get_by_id(id) user = get_by_id(id)
if user do if user do
@cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) @cachex.put(:user_cache, cache_key(:ap_id, user), user)
{:commit, user.ap_id} {:commit, user.ap_id}
else else
{:ignore, ""} {:ignore, ""}
@ -1333,7 +1339,7 @@ defmodule Pleroma.User do
end end
def get_cached_by_nickname(nickname) do def get_cached_by_nickname(nickname) do
key = "nickname:#{nickname}" key = cache_key(:nickname, nickname)
@cachex.fetch!(:user_cache, key, fn _ -> @cachex.fetch!(:user_cache, key, fn _ ->
case get_or_fetch_by_nickname(nickname) do case get_or_fetch_by_nickname(nickname) do
@ -1651,7 +1657,7 @@ defmodule Pleroma.User do
|> Oban.insert() |> Oban.insert()
end end
@cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") @cachex.del(:user_cache, cache_key(:muted_users_ap_ids, muter))
{:ok, Enum.filter([user_mute, user_notification_mute], & &1)} {:ok, Enum.filter([user_mute, user_notification_mute], & &1)}
end end
@ -1661,7 +1667,7 @@ defmodule Pleroma.User do
with {:ok, user_mute} <- UserRelationship.delete_mute(muter, mutee), with {:ok, user_mute} <- UserRelationship.delete_mute(muter, mutee),
{:ok, user_notification_mute} <- {:ok, user_notification_mute} <-
UserRelationship.delete_notification_mute(muter, mutee) do UserRelationship.delete_notification_mute(muter, mutee) do
@cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") @cachex.del(:user_cache, cache_key(:muted_users_ap_ids, muter))
{:ok, [user_mute, user_notification_mute]} {:ok, [user_mute, user_notification_mute]}
end end
end end
@ -2815,7 +2821,7 @@ defmodule Pleroma.User do
{:ok, UserRelationship.t()} | {:error, Ecto.Changeset.t()} {:ok, UserRelationship.t()} | {:error, Ecto.Changeset.t()}
defp add_to_block(%User{} = user, %User{} = blocked, expires_at) do defp add_to_block(%User{} = user, %User{} = blocked, expires_at) do
with {:ok, relationship} <- UserRelationship.create_block(user, blocked, expires_at) do with {:ok, relationship} <- UserRelationship.create_block(user, blocked, expires_at) do
@cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") @cachex.del(:user_cache, cache_key(:blocked_users_ap_ids, user))
{:ok, relationship} {:ok, relationship}
end end
end end
@ -2824,7 +2830,7 @@ defmodule Pleroma.User do
{:ok, UserRelationship.t()} | {:ok, nil} | {:error, Ecto.Changeset.t()} {:ok, UserRelationship.t()} | {:ok, nil} | {:error, Ecto.Changeset.t()}
defp remove_from_block(%User{} = user, %User{} = blocked) do defp remove_from_block(%User{} = user, %User{} = blocked) do
with {:ok, relationship} <- UserRelationship.delete_block(user, blocked) do with {:ok, relationship} <- UserRelationship.delete_block(user, blocked) do
@cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") @cachex.del(:user_cache, cache_key(:blocked_users_ap_ids, user))
{:ok, relationship} {:ok, relationship}
end end
end end

View file

@ -89,7 +89,8 @@ defmodule Pleroma.Web.Federator do
# Job Worker Callbacks # Job Worker Callbacks
@spec perform(atom(), any()) :: {:ok, any()} | {:error, any()} @impl true
@spec perform(atom(), any()) :: :ok | {:ok, any()} | {:error, any()}
def perform(:publish_one, params) do def perform(:publish_one, params) do
Publisher.prepare_one(params) Publisher.prepare_one(params)
|> Publisher.publish_one() |> Publisher.publish_one()

View file

@ -3,5 +3,6 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Federator.Publishing do defmodule Pleroma.Web.Federator.Publishing do
@callback perform(atom(), any()) :: :ok | {:ok, any()} | {:error, any()}
@callback publish(map()) :: any() @callback publish(map()) :: any()
end end

View file

@ -716,7 +716,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
end end
end end
defp put_application(params, %{assigns: %{token: %Token{user: %User{} = user} = token}} = _conn) do defp put_application(
params,
%{assigns: %{token: %Token{} = token, user: %User{} = user}} = _conn
) do
if user.disclose_client do if user.disclose_client do
%{client_name: client_name, website: website} = Repo.preload(token, :app).app %{client_name: client_name, website: website} = Repo.preload(token, :app).app
Map.put(params, :generator, %{type: "Application", name: client_name, url: website}) Map.put(params, :generator, %{type: "Application", name: client_name, url: website})

View file

@ -52,7 +52,7 @@ defmodule Pleroma.Web.Plugs.OAuthPlug do
where: t.token == ^token where: t.token == ^token
) )
with %Token{user_id: user_id} = token_record <- Repo.one(token_query) |> Repo.preload(:user), with %Token{user_id: user_id} = token_record <- Repo.one(token_query),
false <- is_nil(user_id), false <- is_nil(user_id),
%User{} = user <- User.get_cached_by_id(user_id) do %User{} = user <- User.get_cached_by_id(user_id) do
{:ok, user, token_record} {:ok, user, token_record}

View file

@ -195,6 +195,9 @@ defmodule Pleroma.Web.Streamer do
recipients = MapSet.new(item.recipients) recipients = MapSet.new(item.recipients)
domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks) domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
following_ap_ids =
if user.domain_blocks == [], do: [], else: User.get_cached_user_friends_ap_ids(user)
with parent <- Object.normalize(item, fetch: false) || item, with parent <- Object.normalize(item, fetch: false) || item,
true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)), true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)),
true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids, true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids,
@ -205,8 +208,12 @@ defmodule Pleroma.Web.Streamer do
true <- MapSet.disjoint?(recipients, recipient_blocks), true <- MapSet.disjoint?(recipients, recipient_blocks),
%{host: item_host} <- URI.parse(item.actor), %{host: item_host} <- URI.parse(item.actor),
%{host: parent_host} <- URI.parse(parent.data["actor"]), %{host: parent_host} <- URI.parse(parent.data["actor"]),
false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host), false <-
false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host), item.actor not in following_ap_ids and
Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host),
false <-
parent.data["actor"] not in following_ap_ids and
Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host),
true <- thread_containment(item, user), true <- thread_containment(item, user),
false <- CommonAPI.thread_muted?(parent, user) do false <- CommonAPI.thread_muted?(parent, user) do
false false

View file

@ -4,25 +4,39 @@
defmodule Pleroma.Workers.PublisherWorker do defmodule Pleroma.Workers.PublisherWorker do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Instances alias Pleroma.Instances
alias Pleroma.Web.Federator alias Pleroma.Web.Federator
use Oban.Worker, queue: :federator_outgoing, max_attempts: 13 use Oban.Worker, queue: :federator_outgoing, max_attempts: 13
@publish_one_param_keys %{
"activity_id" => :activity_id,
"cc" => :cc,
"inbox" => :inbox,
"unreachable_since" => :unreachable_since
}
@impl true @impl true
def perform(%Job{args: %{"op" => "publish", "activity_id" => activity_id}}) do def perform(%Job{args: %{"op" => "publish", "activity_id" => activity_id}}) do
activity = Activity.get_by_id(activity_id) activity = Activity.get_by_id(activity_id)
Federator.perform(:publish, activity) federator().perform(:publish, activity)
end end
def perform(%Job{args: %{"op" => "publish_one", "params" => params}} = job) do def perform(%Job{args: %{"op" => "publish_one", "params" => params}} = job) do
params = Map.new(params, fn {k, v} -> {String.to_atom(k), v} end) params =
Enum.reduce(params, %{}, fn {key, value}, acc ->
case @publish_one_param_keys do
%{^key => atom_key} -> Map.put(acc, atom_key, value)
_ -> acc
end
end)
# Cancel / skip the job if this server believed to be unreachable now # Cancel / skip the job if this server believed to be unreachable now
if not Instances.reachable?(params.inbox) do if not Instances.reachable?(params.inbox) do
{:cancel, :unreachable} {:cancel, :unreachable}
else else
case Federator.perform(:publish_one, params) do case federator().perform(:publish_one, params) do
{:ok, _} -> {:ok, _} ->
:ok :ok
@ -44,6 +58,8 @@ defmodule Pleroma.Workers.PublisherWorker do
@impl true @impl true
def timeout(_job), do: :timer.seconds(10) def timeout(_job), do: :timer.seconds(10)
defp federator, do: Config.get([:pipeline, :federator], Federator)
@base_backoff 15 @base_backoff 15
@pow 5 @pow 5
@impl true @impl true

View file

@ -1988,14 +1988,26 @@ defmodule Pleroma.UserTest do
end end
describe "caching" do describe "caching" do
test "set_cache stores friends under the AP ID cache key" do
user = insert(:user)
User.set_cache(user)
assert {:ok, []} = Cachex.get(:user_cache, "friends_ap_ids:#{user.ap_id}")
assert {:ok, nil} = Cachex.get(:user_cache, "friends_ap_ids:#{user.nickname}")
end
test "invalidate_cache works" do test "invalidate_cache works" do
user = insert(:user) user = insert(:user)
User.set_cache(user) User.set_cache(user)
User.get_cached_by_id(user.id)
User.invalidate_cache(user) User.invalidate_cache(user)
{:ok, nil} = Cachex.get(:user_cache, "ap_id:#{user.ap_id}") {:ok, nil} = Cachex.get(:user_cache, "ap_id:#{user.ap_id}")
{:ok, nil} = Cachex.get(:user_cache, "nickname:#{user.nickname}") {:ok, nil} = Cachex.get(:user_cache, "nickname:#{user.nickname}")
{:ok, nil} = Cachex.get(:user_cache, "friends_ap_ids:#{user.ap_id}")
{:ok, nil} = Cachex.get(:user_cache, "id:#{user.id}")
end end
test "User.delete() plugs any possible zombie objects" do test "User.delete() plugs any possible zombie objects" do

View file

@ -44,6 +44,18 @@ defmodule Pleroma.Web.Plugs.OAuthPlugTest do
assert conn.assigns[:user] == opts[:user] assert conn.assigns[:user] == opts[:user]
end end
test "with a valid token, it does not preload the duplicate user association", %{
conn: conn,
token: token
} do
conn =
conn
|> put_req_header("authorization", "bearer #{token.token}")
|> OAuthPlug.call(%{})
assert %Ecto.Association.NotLoaded{} = conn.assigns.token.user
end
test "with valid token (downcase) in url parameters, it assigns the user", opts do test "with valid token (downcase) in url parameters, it assigns the user", opts do
conn = conn =
:get :get

View file

@ -475,6 +475,20 @@ defmodule Pleroma.Web.StreamerTest do
assert Streamer.filtered_by_user?(user, favorite_activity) assert Streamer.filtered_by_user?(user, favorite_activity)
end end
test "it does not filter followed users on blocked domains", %{user: user} do
followed =
insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/friend"})
{:ok, user} = User.block_domain(user, "hecking-lewd-place.com")
{:ok, activity} = CommonAPI.post(followed, %{status: "still a friend"})
assert Streamer.filtered_by_user?(user, activity)
{:ok, user, _followed} = User.follow(user, followed)
refute Streamer.filtered_by_user?(user, activity)
end
test "it sends follow activities to the 'user:notification' stream", %{ test "it sends follow activities to the 'user:notification' stream", %{
user: user, user: user,
token: oauth_token token: oauth_token

View file

@ -6,8 +6,8 @@ defmodule Pleroma.Workers.PublisherWorkerTest do
use Pleroma.DataCase, async: false use Pleroma.DataCase, async: false
use Oban.Testing, repo: Pleroma.Repo use Oban.Testing, repo: Pleroma.Repo
import Mox
import Pleroma.Factory import Pleroma.Factory
import Mock
alias Pleroma.Instances alias Pleroma.Instances
alias Pleroma.Object alias Pleroma.Object
@ -15,6 +15,8 @@ defmodule Pleroma.Workers.PublisherWorkerTest do
alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Federator alias Pleroma.Web.Federator
alias Pleroma.Web.FederatorMock
alias Pleroma.Workers.PublisherWorker
describe "Oban job priority:" do describe "Oban job priority:" do
setup do setup do
@ -55,49 +57,80 @@ defmodule Pleroma.Workers.PublisherWorkerTest do
end end
test "marks server as unreachable only on final failure", %{activity: activity} do test "marks server as unreachable only on final failure", %{activity: activity} do
with_mock Pleroma.Web.Federator, expect(FederatorMock, :perform, 2, fn :publish_one, _params ->
perform: fn :publish_one, _params -> {:error, :connection_error} end do {:error, :connection_error}
# First attempt end)
job = %Oban.Job{
args: %{
"op" => "publish_one",
"params" => %{
"inbox" => "https://example.com/inbox",
"activity_id" => activity.id
}
},
attempt: 1,
max_attempts: 5
}
assert {:error, :connection_error} = Pleroma.Workers.PublisherWorker.perform(job) # First attempt
assert Instances.reachable?("https://example.com/inbox") job = %Oban.Job{
args: %{
"op" => "publish_one",
"params" => %{
"inbox" => "https://example.com/inbox",
"activity_id" => activity.id
}
},
attempt: 1,
max_attempts: 5
}
# Final attempt assert {:error, :connection_error} = PublisherWorker.perform(job)
job = %{job | attempt: 5} assert Instances.reachable?("https://example.com/inbox")
assert {:error, :connection_error} = Pleroma.Workers.PublisherWorker.perform(job)
refute Instances.reachable?("https://example.com/inbox") # Final attempt
end job = %{job | attempt: 5}
assert {:error, :connection_error} = PublisherWorker.perform(job)
refute Instances.reachable?("https://example.com/inbox")
end end
test "does not mark server as unreachable on successful publish", %{activity: activity} do test "does not mark server as unreachable on successful publish", %{activity: activity} do
with_mock Pleroma.Web.Federator, expect(FederatorMock, :perform, fn :publish_one, _params -> {:ok, %{status: 200}} end)
perform: fn :publish_one, _params -> {:ok, %{status: 200}} end do
job = %Oban.Job{
args: %{
"op" => "publish_one",
"params" => %{
"inbox" => "https://example.com/inbox",
"activity_id" => activity.id
}
},
attempt: 1,
max_attempts: 5
}
assert :ok = Pleroma.Workers.PublisherWorker.perform(job) job = %Oban.Job{
assert Instances.reachable?("https://example.com/inbox") args: %{
end "op" => "publish_one",
"params" => %{
"inbox" => "https://example.com/inbox",
"activity_id" => activity.id
}
},
attempt: 1,
max_attempts: 5
}
assert :ok = PublisherWorker.perform(job)
assert Instances.reachable?("https://example.com/inbox")
end
test "does not create atoms from unexpected job params", %{activity: activity} do
unknown_key = "unknown_#{System.unique_integer([:positive])}"
assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_key) end
expect(FederatorMock, :perform, fn :publish_one, params ->
assert params == %{
inbox: "https://example.com/inbox",
activity_id: activity.id
}
{:ok, %{status: 200}}
end)
job = %Oban.Job{
args: %{
"op" => "publish_one",
"params" => %{
"inbox" => "https://example.com/inbox",
"activity_id" => activity.id,
unknown_key => "ignored"
}
},
attempt: 1,
max_attempts: 5
}
assert :ok = PublisherWorker.perform(job)
assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_key) end
end end
test "cancels job if server is unreachable", %{activity: activity} do test "cancels job if server is unreachable", %{activity: activity} do
@ -117,7 +150,7 @@ defmodule Pleroma.Workers.PublisherWorkerTest do
max_attempts: 5 max_attempts: 5
} }
assert {:cancel, :unreachable} = Pleroma.Workers.PublisherWorker.perform(job) assert {:cancel, :unreachable} = PublisherWorker.perform(job)
end end
end end
end end