From 71f5a493f3be15308959a77ad368a61de48721e2 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 1 Jan 2026 10:02:37 +0400 Subject: [PATCH 001/195] Search: Better sorting for user searches. --- lib/pleroma/user/search.ex | 48 +++++++- test/pleroma/user/search_test.exs | 195 ++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 test/pleroma/user/search_test.exs diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index 851745714..abc45f62a 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -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 diff --git a/test/pleroma/user/search_test.exs b/test/pleroma/user/search_test.exs new file mode 100644 index 000000000..c1aca90bc --- /dev/null +++ b/test/pleroma/user/search_test.exs @@ -0,0 +1,195 @@ +# Pleroma: A lightweight social networking server +# Copyright © Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.SearchTest do + use Pleroma.DataCase, async: false + + import Pleroma.Factory + + alias Pleroma.Instances + alias Pleroma.Repo + alias Pleroma.User + + describe "search/2 mention suggestions" do + test "prioritizes followed/follower users before others" do + user = insert(:user) + + related = + insert(:user, + local: false, + nickname: "hj@real.example", + ap_id: "https://real.example/users/hj", + last_status_at: ~N[2020-01-01 00:00:00] + ) + + other = insert(:user, nickname: "hj", last_status_at: ~N[2020-01-02 00:00:00]) + + {:ok, _related, _user} = User.follow(related, user) + + results = User.search("hj", for_user: user) |> Enum.map(& &1.id) + + assert results == [related.id, other.id] + end + + test "orders followed/follower users by most recent activity" do + user = insert(:user) + + older = + insert(:user, + local: false, + nickname: "ali@remote.example", + ap_id: "https://remote.example/users/ali", + last_status_at: ~N[2020-01-01 00:00:00] + ) + + newer = + insert(:user, + local: false, + nickname: "alia@remote.example", + ap_id: "https://remote.example/users/alia", + last_status_at: ~N[2020-01-02 00:00:00] + ) + + {:ok, _user, _older} = User.follow(user, older) + {:ok, _user, _newer} = User.follow(user, newer) + + assert [newer.id, older.id] == + User.search("ali", for_user: user) + |> Enum.map(& &1.id) + end + + test "groups followed/follower users first and sorts them by recency" do + user = insert(:user) + + following_newest = + insert(:user, + local: false, + nickname: "mentiontesta@related.example", + ap_id: "https://related.example/users/mentiontesta", + last_status_at: ~N[2020-01-03 00:00:00] + ) + + follower_middle = + insert(:user, + local: false, + nickname: "mentiontestb@related.example", + ap_id: "https://related.example/users/mentiontestb", + last_status_at: ~N[2020-01-02 00:00:00] + ) + + mutual_oldest = + insert(:user, + local: false, + nickname: "mentiontestc@related.example", + ap_id: "https://related.example/users/mentiontestc", + last_status_at: ~N[2020-01-01 00:00:00] + ) + + unrelated_newer = + insert(:user, + local: false, + nickname: "mentiontestd@unrelated.example", + ap_id: "https://unrelated.example/users/mentiontestd", + last_status_at: ~N[2020-01-04 00:00:00] + ) + + {:ok, _user, _following_newest} = User.follow(user, following_newest) + {:ok, _follower_middle, _user} = User.follow(follower_middle, user) + + {:ok, _user, _mutual_oldest} = User.follow(user, mutual_oldest) + {:ok, _mutual_oldest, _user} = User.follow(mutual_oldest, user) + + results = User.search("mentiontest", for_user: user) + + assert Enum.map(results, & &1.id) == + [following_newest.id, follower_middle.id, mutual_oldest.id, unrelated_newer.id] + end + + test "uses last_active_at when last_status_at is missing" do + user = insert(:user) + + older = + insert(:user, + local: false, + nickname: "activefallbacka@remote.example", + ap_id: "https://remote.example/users/activefallbacka", + last_status_at: nil, + last_active_at: ~N[2020-01-01 00:00:00] + ) + + newer = + insert(:user, + local: false, + nickname: "activefallbackb@remote.example", + ap_id: "https://remote.example/users/activefallbackb", + last_status_at: nil, + last_active_at: ~N[2020-01-02 00:00:00] + ) + + {:ok, _user, _older} = User.follow(user, older) + {:ok, _user, _newer} = User.follow(user, newer) + + assert [newer.id, older.id] == + User.search("activefallback", for_user: user) + |> Enum.map(& &1.id) + end + + test "does not return deactivated users even if related" do + user = insert(:user) + + active = + insert(:user, + local: false, + nickname: "deactivatedtesta@remote.example", + ap_id: "https://remote.example/users/deactivatedtesta", + last_status_at: ~N[2020-01-02 00:00:00] + ) + + deactivated = + insert(:user, + local: false, + nickname: "deactivatedtestb@remote.example", + ap_id: "https://remote.example/users/deactivatedtestb", + last_status_at: ~N[2020-01-03 00:00:00] + ) + + {:ok, _user, _active} = User.follow(user, active) + {:ok, _user, _deactivated} = User.follow(user, deactivated) + Repo.update!(Ecto.Changeset.change(deactivated, is_active: false)) + + results = User.search("deactivatedtest", for_user: user) |> Enum.map(& &1.id) + + assert results == [active.id] + end + + test "does not return users from unreachable instances" do + user = insert(:user) + + {:ok, _instance} = Instances.set_unreachable("dead.example") + + dead = + insert(:user, + local: false, + nickname: "ali@dead.example", + ap_id: "https://dead.example/users/ali", + last_status_at: ~N[2020-01-02 00:00:00] + ) + + alive = + insert(:user, + local: false, + nickname: "ali@alive.example", + ap_id: "https://alive.example/users/ali", + last_status_at: ~N[2020-01-02 00:00:00] + ) + + {:ok, _user, _alive} = User.follow(user, alive) + {:ok, _user, _dead} = User.follow(user, dead) + + results = User.search("ali", for_user: user) |> Enum.map(& &1.id) + + assert results == [alive.id] + end + end +end From 3903f12c78f9192ad27defe9738ca9669473c4c9 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 1 Jan 2026 10:04:55 +0400 Subject: [PATCH 002/195] Add changelog --- changelog.d/user-search-sorting.change | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/user-search-sorting.change diff --git a/changelog.d/user-search-sorting.change b/changelog.d/user-search-sorting.change new file mode 100644 index 000000000..def27e955 --- /dev/null +++ b/changelog.d/user-search-sorting.change @@ -0,0 +1 @@ +Improve user search / autocompletion ordering. From be327ca982654a37d0dc0f977a8e59ded9df1c33 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 14 Jan 2026 03:17:50 +0100 Subject: [PATCH 003/195] Switch Phoenix back to upstream See for `:sec_websocket_protocol` -> `:sec_websocket_headers` --- changelog.d/phoenix-sec-websocket-headers.change | 1 + lib/pleroma/web.ex | 2 +- lib/pleroma/web/endpoint.ex | 2 +- lib/pleroma/web/mastodon_api/websocket_handler.ex | 12 +++++++----- mix.exs | 3 +-- mix.lock | 2 +- 6 files changed, 12 insertions(+), 10 deletions(-) create mode 100644 changelog.d/phoenix-sec-websocket-headers.change diff --git a/changelog.d/phoenix-sec-websocket-headers.change b/changelog.d/phoenix-sec-websocket-headers.change new file mode 100644 index 000000000..b3cb4ad3f --- /dev/null +++ b/changelog.d/phoenix-sec-websocket-headers.change @@ -0,0 +1 @@ +Switch patched Phoenix 1.7.14 back to upstream with Phoenix 1.8.0+ diff --git a/lib/pleroma/web.ex b/lib/pleroma/web.ex index e7e7e96f9..1c1333aac 100644 --- a/lib/pleroma/web.ex +++ b/lib/pleroma/web.ex @@ -30,7 +30,7 @@ defmodule Pleroma.Web do def controller do quote do - use Phoenix.Controller, namespace: Pleroma.Web + use Phoenix.Controller, formats: [json: "View", html: "View"] import Plug.Conn diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index bab3c9fd0..fa0bd6c12 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.Endpoint do websocket: [ path: "/", compress: false, - connect_info: [:sec_websocket_protocol], + connect_info: [:sec_websocket_headers], error_handler: {Pleroma.Web.MastodonAPI.WebsocketHandler, :handle_error, []}, fullsweep_after: 20 ] diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 3ed1cdd6c..616f35912 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -245,12 +245,14 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do Plug.Conn.send_resp(conn, 404, "Not Found") end - defp find_access_token(%{ - connect_info: %{sec_websocket_protocol: [token]} - }), - do: token - defp find_access_token(%{params: %{"access_token" => token}}), do: token + defp find_access_token(%{connect_info: %{sec_websocket_headers: sec_headers}}), + do: + Enum.find_value(sec_headers, fn + {"sec-websocket-protocol", v} -> v + _ -> nil + end) + defp find_access_token(_), do: nil end diff --git a/mix.exs b/mix.exs index 48ec9b68f..9f45864e2 100644 --- a/mix.exs +++ b/mix.exs @@ -123,8 +123,7 @@ defmodule Pleroma.Mixfile do # Type `mix help deps` for examples and options. defp deps do [ - {:phoenix, - git: "https://github.com/feld/phoenix", branch: "v1.7.14-websocket-headers", override: true}, + {:phoenix, "~> 1.8.0"}, {:phoenix_ecto, "~> 4.4"}, {:ecto_sql, "~> 3.10"}, {:ecto_enum, "~> 1.4"}, diff --git a/mix.lock b/mix.lock index c469f4f01..60fa6f6b4 100644 --- a/mix.lock +++ b/mix.lock @@ -102,7 +102,7 @@ "open_api_spex": {:hex, :open_api_spex, "3.22.0", "fbf90dc82681dc042a4ee79853c8e989efbba73d9e87439085daf849bbf8bc20", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "dd751ddbdd709bb4a5313e9a24530da6e66594773c7242a0c2592cbd9f589063"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, - "phoenix": {:git, "https://github.com/feld/phoenix", "fb6dc76c657422e49600896c64aab4253fceaef6", [branch: "v1.7.14-websocket-headers"]}, + "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, From 6f86883cca5024c57ba8b0ddecd6ca1161d62dd5 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 14 Jan 2026 05:15:28 +0100 Subject: [PATCH 004/195] Web: remove legacy :set_put_layout plug Phoenix 1.8 requires a View module with put_layout so can't set it that early. It was introduced in 2019 with commit 1097ce6d9f06a7552652c5990cee12e7b7b3cc59 but nothing seems to provide app.html (anymore?) and it would likely better be set by something like OAuthController / OAuthView. --- lib/pleroma/web.ex | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/pleroma/web.ex b/lib/pleroma/web.ex index 1c1333aac..bf7fa86a3 100644 --- a/lib/pleroma/web.ex +++ b/lib/pleroma/web.ex @@ -39,12 +39,6 @@ defmodule Pleroma.Web do alias Pleroma.Web.Router.Helpers, as: Routes - plug(:set_put_layout) - - defp set_put_layout(conn, _) do - put_layout(conn, Pleroma.Config.get(:app_layout, "app.html")) - end - # Marks plugs intentionally skipped and blocks their execution if present in plugs chain defp skip_plug(conn, plug_modules) do plug_modules From a72f9cff232c6afd7aff8ab41b21d82779d8a086 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 16 Oct 2025 15:03:54 +0200 Subject: [PATCH 005/195] Elixir 1.19: Fix typing violation on struct updates in Pleroma.Marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit warning: a struct for Pleroma.Marker is expected on struct update: %Pleroma.Marker{marker | user: user} but got type: dynamic() where "marker" was given the type: # type: dynamic() # from: lib/pleroma/marker.ex {:ok, marker} when defining the variable "marker", you must also pattern match on "%Pleroma.Marker{}". hint: given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of: user = some_function() %User{user | name: "John Doe"} it is enough to write: %User{} = user = some_function() %{user | name: "John Doe"} typing violation found at: │ 81 │ {:ok, marker} -> %__MODULE__{marker | user: user} │ ~ │ └─ lib/pleroma/marker.ex:81:24: Pleroma.Marker.get_marker/2 --- lib/pleroma/marker.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index 68b054e4d..4f645240e 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -78,7 +78,7 @@ defmodule Pleroma.Marker do defp get_marker(user, timeline) do case Repo.find_resource(get_query(user, timeline)) do - {:ok, marker} -> %__MODULE__{marker | user: user} + {:ok, %__MODULE__{} = marker} -> %__MODULE__{marker | user: user} _ -> %__MODULE__{timeline: timeline, user_id: user.id} end end From 0fc046a75e0499a068cbdee82f9c559407a7521b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 16 Oct 2025 15:06:02 +0200 Subject: [PATCH 006/195] Elixir 1.19: Fix typing violation on struct updates in MFA.Changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit warning: a struct for Pleroma.MFA.Settings is expected on struct update: %Pleroma.MFA.Settings{settings | enabled: false} but got type: dynamic() where "settings" was given the type: # type: dynamic() # from: lib/pleroma/mfa/changeset.ex:11:14 settings = Pleroma.MFA.fetch_settings(Ecto.Changeset.apply_changes(changeset)) when defining the variable "settings", you must also pattern match on "%Pleroma.MFA.Settings{}". hint: given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of: user = some_function() %User{user | name: "John Doe"} it is enough to write: %User{} = user = some_function() %{user | name: "John Doe"} typing violation found at: │ 17 │ put_change(changeset, %Settings{settings | enabled: false}) │ ~ │ └─ lib/pleroma/mfa/changeset.ex:17:29: Pleroma.MFA.Changeset.disable/2 --- warning: a struct for Pleroma.MFA.Settings is expected on struct update: %Pleroma.MFA.Settings{ settings | totp: %Pleroma.MFA.Settings.TOTP{confirmed: false, delivery_type: "app", secret: nil} } but got type: dynamic() where "settings" was given the type: # type: dynamic() # from: lib/pleroma/mfa/changeset.ex:23:74 %Pleroma.User{multi_factor_authentication_settings: settings} = user when defining the variable "settings", you must also pattern match on "%Pleroma.MFA.Settings{}". hint: given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of: user = some_function() %User{user | name: "John Doe"} it is enough to write: %User{} = user = some_function() %{user | name: "John Doe"} typing violation found at: │ 25 │ |> put_change(%Settings{settings | totp: %Settings.TOTP{}}) │ ~ │ └─ lib/pleroma/mfa/changeset.ex:25:19: Pleroma.MFA.Changeset.disable_totp/1 --- lib/pleroma/mfa/changeset.ex | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/mfa/changeset.ex b/lib/pleroma/mfa/changeset.ex index 3ec3cfe91..890cb2193 100644 --- a/lib/pleroma/mfa/changeset.ex +++ b/lib/pleroma/mfa/changeset.ex @@ -8,7 +8,7 @@ defmodule Pleroma.MFA.Changeset do alias Pleroma.User def disable(%Ecto.Changeset{} = changeset, force \\ false) do - settings = + %Settings{} = settings = changeset |> Ecto.Changeset.apply_changes() |> MFA.fetch_settings() @@ -20,20 +20,20 @@ defmodule Pleroma.MFA.Changeset do end end - def disable_totp(%User{multi_factor_authentication_settings: settings} = user) do + def disable_totp(%User{multi_factor_authentication_settings: %Settings{} = settings} = user) do user |> put_change(%Settings{settings | totp: %Settings.TOTP{}}) end - def confirm_totp(%User{multi_factor_authentication_settings: settings} = user) do - totp_settings = %Settings.TOTP{settings.totp | confirmed: true} + def confirm_totp(%User{multi_factor_authentication_settings: %Settings{} = settings} = user) do + totp_settings = %Settings.TOTP{%Settings.TOTP{} = settings.totp | confirmed: true} user |> put_change(%Settings{settings | totp: totp_settings, enabled: true}) end def setup_totp(%User{} = user, attrs) do - mfa_settings = MFA.fetch_settings(user) + %Settings{} = mfa_settings = MFA.fetch_settings(user) totp_settings = %Settings.TOTP{} @@ -46,7 +46,7 @@ defmodule Pleroma.MFA.Changeset do def cast_backup_codes(%User{} = user, codes) do user |> put_change(%Settings{ - user.multi_factor_authentication_settings + %Settings{} = user.multi_factor_authentication_settings | backup_codes: codes }) end From 0d3085937ca728c572f19798d21dce12dcf7b89e Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 16 Oct 2025 15:09:03 +0200 Subject: [PATCH 007/195] Elixir 1.19: Fix typing violation on struct updates in Pleroma.Upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit warning: a struct for Pleroma.Upload is expected on struct update: %Pleroma.Upload{ upload | path: case upload.path do x when x === false or x === nil -> <> x -> x end } but got type: dynamic() where "upload" was given the type: # type: dynamic() # from: lib/pleroma/upload.ex:95:24 {:ok, upload} <- prepare_upload(upload, opts) when defining the variable "upload", you must also pattern match on "%Pleroma.Upload{}". hint: given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of: user = some_function() %User{user | name: "John Doe"} it is enough to write: %User{} = user = some_function() %{user | name: "John Doe"} typing violation found at: │ 96 │ upload = %__MODULE__{upload | path: upload.path || "#{upload.id}/#{upload.name}"}, │ ~ │ └─ lib/pleroma/upload.ex:96:19: Pleroma.Upload.store/2 --- lib/pleroma/upload.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 06d8005bc..350ff6cb3 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -93,7 +93,7 @@ defmodule Pleroma.Upload do def store(upload, opts \\ []) do opts = get_opts(opts) - with {:ok, upload} <- prepare_upload(upload, opts), + with {:ok, %__MODULE__{} = upload} <- prepare_upload(upload, opts), upload = %__MODULE__{upload | path: upload.path || "#{upload.id}/#{upload.name}"}, {:ok, upload} <- Pleroma.Upload.Filter.filter(opts.filters, upload), description = get_description(upload), From 6f62cb0ed38c5d2bbdb6e304a287553bad8584f1 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 16 Oct 2025 15:09:51 +0200 Subject: [PATCH 008/195] Elixir 1.19: Fix typing violation on struct updates in Web.ApiSpec.Cast* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit warning: a struct for Plug.Conn is expected on struct update: %Plug.Conn{conn | query_params: query_params} but got type: dynamic() where "conn" was given the type: # type: dynamic() # from: lib/pleroma/web/api_spec/cast_and_validate.ex:109:43 conn when defining the variable "conn", you must also pattern match on "%Plug.Conn{}". hint: given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of: user = some_function() %User{user | name: "John Doe"} it is enough to write: %User{} = user = some_function() %{user | name: "John Doe"} typing violation found at: │ 133 │ conn = %Conn{conn | query_params: query_params} │ ~ │ └─ lib/pleroma/web/api_spec/cast_and_validate.ex:133:16: Pleroma.Web.ApiSpec.CastAndValidate.cast_and_validate/6 --- lib/pleroma/web/api_spec/cast_and_validate.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex index 672d1c4a1..57ea8e9b3 100644 --- a/lib/pleroma/web/api_spec/cast_and_validate.ex +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -106,7 +106,7 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do OpenApiSpex.cast_and_validate(spec, operation, conn, content_type, cast_opts) end - defp cast_and_validate(spec, operation, conn, content_type, false = _strict, cast_opts) do + defp cast_and_validate(spec, operation, %Conn{} = conn, content_type, false = _strict, cast_opts) do case OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do {:ok, conn} -> {:ok, conn} From 7c79e4416f78b317d8a3bda4cacd23f3d955df46 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 16 Oct 2025 15:11:33 +0200 Subject: [PATCH 009/195] Elixir 1.19: Fix typing violation on struct updates in Web.ApiSpec.Rend* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit warning: a struct for OpenApiSpex.Cast.Error is expected on struct update: %OpenApiSpex.Cast.Error{err | name: err.value} but got type: dynamic(%{..., name: nil, reason: :invalid_enum}) where "err" was given the type: # type: dynamic(%{..., name: nil, reason: :invalid_enum}) # from: lib/pleroma/web/api_spec/render_error.ex:20:45 %{name: nil, reason: :invalid_enum} = err when defining the variable "err", you must also pattern match on "%OpenApiSpex.Cast.Error{}". hint: given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of: user = some_function() %User{user | name: "John Doe"} it is enough to write: %User{} = user = some_function() %{user | name: "John Doe"} typing violation found at: │ 21 │ %OpenApiSpex.Cast.Error{err | name: err.value} │ ~ │ └─ lib/pleroma/web/api_spec/render_error.ex:21:11: Pleroma.Web.ApiSpec.RenderError.call/2 --- lib/pleroma/web/api_spec/render_error.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/api_spec/render_error.ex b/lib/pleroma/web/api_spec/render_error.ex index 3539af6e4..acf510774 100644 --- a/lib/pleroma/web/api_spec/render_error.ex +++ b/lib/pleroma/web/api_spec/render_error.ex @@ -17,10 +17,10 @@ defmodule Pleroma.Web.ApiSpec.RenderError do def call(conn, errors) do errors = Enum.map(errors, fn - %{name: nil, reason: :invalid_enum} = err -> + %OpenApiSpex.Cast.Error{name: nil, reason: :invalid_enum} = err -> %OpenApiSpex.Cast.Error{err | name: err.value} - %{name: nil} = err -> + %OpenApiSpex.Cast.Error{name: nil} = err -> %OpenApiSpex.Cast.Error{err | name: List.last(err.path)} err -> From 6a926bf605b6e33c0fe9242fc0b2c1b8f68b655d Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 16 Oct 2025 15:12:41 +0200 Subject: [PATCH 010/195] Elixir 1.19: Fix typing violation on struct updates in CommonAPI.Activity* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit warning: a struct for Pleroma.Web.CommonAPI.ActivityDraft is expected on struct update: %Pleroma.Web.CommonAPI.ActivityDraft{draft | object: object} but got type: dynamic() where "draft" was given the type: # type: dynamic() # from: lib/pleroma/web/common_api/activity_draft.ex:91:22 draft when defining the variable "draft", you must also pattern match on "%Pleroma.Web.CommonAPI.ActivityDraft{}". hint: given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of: user = some_function() %User{user | name: "John Doe"} it is enough to write: %User{} = user = some_function() %{user | name: "John Doe"} typing violation found at: │ 102 │ %__MODULE__{draft | object: object} │ ~ │ └─ lib/pleroma/web/common_api/activity_draft.ex:102:5: Pleroma.Web.CommonAPI.ActivityDraft.listen_object/1 --- lib/pleroma/web/common_api/activity_draft.ex | 46 ++++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index c0b98508c..c5959276a 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -88,7 +88,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do |> validate() end - defp listen_object(draft) do + defp listen_object(%__MODULE__{} = draft) do object = draft.params |> Map.take([:album, :artist, :title, :length]) @@ -102,20 +102,20 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do %__MODULE__{draft | object: object} end - defp put_params(draft, params) do + defp put_params(%__MODULE__{} = draft, params) do params = Map.put_new(params, :in_reply_to_status_id, params[:in_reply_to_id]) %__MODULE__{draft | params: params} end - defp status(%{params: %{status: status}} = draft) do + defp status(%__MODULE__{params: %{status: status}} = draft) do %__MODULE__{draft | status: String.trim(status)} end - defp summary(%{params: params} = draft) do + defp summary(%__MODULE__{params: params} = draft) do %__MODULE__{draft | summary: Map.get(params, :spoiler_text, "")} end - defp full_payload(%{status: status, summary: summary} = draft) do + defp full_payload(%__MODULE__{status: status, summary: summary} = draft) do full_payload = String.trim(status <> summary) case Utils.validate_character_limit(full_payload, draft.attachments) do @@ -124,7 +124,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do end end - defp attachments(%{params: params} = draft) do + defp attachments(%__MODULE__{params: params} = draft) do attachments = Utils.attachments_from_ids(params, draft.user) draft = %__MODULE__{draft | attachments: attachments} @@ -134,9 +134,9 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do end end - defp in_reply_to(%{params: %{in_reply_to_status_id: ""}} = draft), do: draft + defp in_reply_to(%__MODULE__{params: %{in_reply_to_status_id: ""}} = draft), do: draft - defp in_reply_to(%{params: %{in_reply_to_status_id: id}} = draft) when is_binary(id) do + defp in_reply_to(%__MODULE__{params: %{in_reply_to_status_id: id}} = draft) when is_binary(id) do # If a post was deleted all its activities (except the newly added Delete) are purged too, # thus lookup by Create db ID will yield nil just as if it never existed in the first place. # @@ -166,13 +166,13 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do end end - defp in_reply_to(%{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft) do + defp in_reply_to(%__MODULE__{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft) do %__MODULE__{draft | in_reply_to: in_reply_to} end defp in_reply_to(draft), do: draft - defp quote_post(%{params: %{quoted_status_id: id}} = draft) when not_empty_string(id) do + defp quote_post(%__MODULE__{params: %{quoted_status_id: id}} = draft) when not_empty_string(id) do case Activity.get_by_id_with_object(id) do %Activity{} = activity -> %__MODULE__{draft | quote_post: activity} @@ -188,12 +188,12 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do defp quote_post(draft), do: draft - defp in_reply_to_conversation(draft) do + defp in_reply_to_conversation(%__MODULE__{} = draft) do in_reply_to_conversation = Participation.get(draft.params[:in_reply_to_conversation_id]) %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation} end - defp visibility(%{params: params} = draft) do + defp visibility(%__MODULE__{params: params} = draft) do case CommonAPI.get_visibility(params, draft.in_reply_to, draft.in_reply_to_conversation) do {visibility, "direct"} when visibility != "direct" -> add_error(draft, dgettext("errors", "The message visibility must be direct")) @@ -226,14 +226,14 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do defp quoting_visibility(draft), do: draft - defp expires_at(draft) do + defp expires_at(%__MODULE__{} = draft) do case CommonAPI.check_expiry_date(draft.params[:expires_in]) do {:ok, expires_at} -> %__MODULE__{draft | expires_at: expires_at} {:error, message} -> add_error(draft, message) end end - defp poll(draft) do + defp poll(%__MODULE__{} = draft) do case Utils.make_poll_data(draft.params) do {:ok, {poll, poll_emoji}} -> %__MODULE__{draft | extra: poll, emoji: Map.merge(draft.emoji, poll_emoji)} @@ -243,7 +243,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do end end - defp content(%{mentions: mentions} = draft) do + defp content(%__MODULE__{mentions: mentions} = draft) do {content_html, mentioned_users, tags} = Utils.make_content_html(draft) mentioned_ap_ids = @@ -257,22 +257,22 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do %__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags} end - defp to_and_cc(draft) do + defp to_and_cc(%__MODULE__{} = draft) do {to, cc} = Utils.get_to_and_cc(draft) %__MODULE__{draft | to: to, cc: cc} end - defp context(draft) do + defp context(%__MODULE__{} = draft) do context = Utils.make_context(draft.in_reply_to, draft.in_reply_to_conversation) %__MODULE__{draft | context: context} end - defp sensitive(draft) do + defp sensitive(%__MODULE__{} = draft) do sensitive = draft.params[:sensitive] %__MODULE__{draft | sensitive: sensitive} end - defp language(draft) do + defp language(%__MODULE__{} = draft) do language = with language <- draft.params[:language], true <- good_locale_code?(language) do @@ -284,7 +284,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do %__MODULE__{draft | language: language} end - defp object(draft) do + defp object(%__MODULE__{} = draft) do emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji) # Sometimes people create posts with subject containing emoji, @@ -328,12 +328,12 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do %__MODULE__{draft | object: object} end - defp preview?(draft) do + defp preview?(%__MODULE__{} = draft) do preview? = Pleroma.Web.Utils.Params.truthy_param?(draft.params[:preview]) %__MODULE__{draft | preview?: preview?} end - defp changes(draft) do + defp changes(%__MODULE__{} = draft) do direct? = draft.visibility == "direct" additional = %{"cc" => draft.cc, "directMessage" => direct?} @@ -359,7 +359,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do defp with_valid(%{valid?: true} = draft, func), do: func.(draft) defp with_valid(draft, _func), do: draft - defp add_error(draft, message) do + defp add_error(%__MODULE__{} = draft, message) do %__MODULE__{draft | valid?: false, errors: [message | draft.errors]} end From 9040434886526e082bf675e46e8cadb3f64e7fe9 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 9 Jan 2026 16:40:19 +0100 Subject: [PATCH 011/195] Elixir 1.19: Fix typing violations in ActivityPubTest --- test/pleroma/web/activity_pub/activity_pub_test.exs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 37ec87a9b..13146619a 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -1524,8 +1524,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do %{test_file: test_file} end - test "strips / from filename", %{test_file: file} do - file = %Plug.Upload{file | filename: "../../../../../nested/bad.jpg"} + test "strips / from filename", %{test_file: %Plug.Upload{} = file} do + file = %{file | filename: "../../../../../nested/bad.jpg"} {:ok, %Object{} = object} = ActivityPub.upload(file) [%{"href" => href}] = object.data["url"] assert Regex.match?(~r"/bad.jpg$", href) @@ -1786,10 +1786,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do {:ok, list} = Pleroma.List.create(%{title: "foo"}, user) {:ok, list} = Pleroma.List.follow(list, member) - {:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"}) + {:ok, %Activity{} = activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"}) activity = Repo.preload(activity, :bookmark) - activity = %Activity{activity | thread_muted?: !!activity.thread_muted?} + activity = %{activity | thread_muted?: !!activity.thread_muted?} assert ActivityPub.fetch_activities([], %{user: user}) == [activity] end @@ -1989,7 +1989,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do assert User.following?(follower, old_user) assert User.following?(follower_move_opted_out, old_user) - assert {:ok, activity} = ActivityPub.move(old_user, new_user) + assert {:ok, %Activity{} = activity} = ActivityPub.move(old_user, new_user) assert %Activity{ actor: ^old_ap_id, @@ -2021,7 +2021,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do assert User.following?(follower_move_opted_out, old_user) refute User.following?(follower_move_opted_out, new_user) - activity = %Activity{activity | object: nil} + activity = %{activity | object: nil} assert [%Notification{activity: ^activity}] = Notification.for_user(follower) From 349d858fdade27c7a016a55ac69a435dadb59d2c Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 9 Jan 2026 16:42:26 +0100 Subject: [PATCH 012/195] Elixir 1.19: Fix typing violation in MediaControllerTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit warning: a struct for Plug.Upload is expected on struct update: %Plug.Upload{image | filename: "../../../../../nested/file.jpg"} but got type: dynamic() where "image" was given the type: # type: dynamic() # from: test/pleroma/web/mastodon_api/controllers/media_controller_test.exs:132:42 %{conn: conn, image: image} when defining the variable "image", you must also pattern match on "%Plug.Upload{}". hint: given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of: user = some_function() %User{user | name: "John Doe"} it is enough to write: %User{} = user = some_function() %{user | name: "John Doe"} typing violation found at: │ 133 │ image = %Plug.Upload{ │ ~ │ └─ test/pleroma/web/mastodon_api/controllers/media_controller_test.exs:133:15: Pleroma.Web.MastodonAPI.MediaControllerTest."test Upload media Do not allow nested filename"/1 --- .../web/mastodon_api/controllers/media_controller_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs index ae86078d7..fc083fd0e 100644 --- a/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs @@ -129,8 +129,8 @@ defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do assert :ok == File.rm(Path.absname("test/tmp/large_binary.data")) end - test "Do not allow nested filename", %{conn: conn, image: image} do - image = %Plug.Upload{ + test "Do not allow nested filename", %{conn: conn, image: %Plug.Upload{} = image} do + image = %{ image | filename: "../../../../../nested/file.jpg" } From f3d9880f07e70f4e83e25e13aab537e2ea3c89ba Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 9 Jan 2026 16:44:37 +0100 Subject: [PATCH 013/195] Elixir 1.19: Fix typing violation in RepoTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit warning: a struct for Pleroma.Web.OAuth.Token is expected on struct update: %Pleroma.Web.OAuth.Token{Pleroma.Factory.insert(:oauth_token) | user: user} but got type: dynamic() you must assign "Pleroma.Factory.insert(:oauth_token)" to variable and pattern match on "%Pleroma.Web.OAuth.Token{}". hint: given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of: user = some_function() %User{user | name: "John Doe"} it is enough to write: %User{} = user = some_function() %{user | name: "John Doe"} typing violation found at: │ 27 │ token = %Pleroma.Web.OAuth.Token{insert(:oauth_token) | user: user} │ ~ │ └─ test/pleroma/repo_test.exs:27:15: Pleroma.RepoTest."test get_assoc/2 get assoc from preloaded data"/1 --- test/pleroma/repo_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/pleroma/repo_test.exs b/test/pleroma/repo_test.exs index 9c0f5d028..721175bda 100644 --- a/test/pleroma/repo_test.exs +++ b/test/pleroma/repo_test.exs @@ -24,7 +24,8 @@ defmodule Pleroma.RepoTest do describe "get_assoc/2" do test "get assoc from preloaded data" do user = %User{name: "Agent Smith"} - token = %Pleroma.Web.OAuth.Token{insert(:oauth_token) | user: user} + %Pleroma.Web.OAuth.Token{} = token = insert(:oauth_token) + token = %{token | user: user} assert Repo.get_assoc(token, :user) == {:ok, user} end From d1ce6f9833036aacc585963046043f1a5c4f5b14 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 9 Jan 2026 16:48:55 +0100 Subject: [PATCH 014/195] Elixir 1.19: Fix typing violation in MarkerTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit warning: a struct for Pleroma.Marker is expected on struct update: %Pleroma.Marker{refresh_record(marker) | unread_count: 2} but got type: dynamic() where "marker" was given the type: # type: dynamic() # from: test/pleroma/marker_test.exs:35:14 marker = Pleroma.Factory.insert(:marker, user: user) you must assign "refresh_record(marker)" to variable and pattern match on "%Pleroma.Marker{}". hint: given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of: user = some_function() %User{user | name: "John Doe"} it is enough to write: %User{} = user = some_function() %{user | name: "John Doe"} typing violation found at: │ 43 │ ) == [%Marker{refresh_record(marker) | unread_count: 2}] │ ~ │ └─ test/pleroma/marker_test.exs:43:20: Pleroma.MarkerTest."test get_markers/2 returns user markers"/1 --- test/pleroma/marker_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/pleroma/marker_test.exs b/test/pleroma/marker_test.exs index 819dde9be..7f573ac7a 100644 --- a/test/pleroma/marker_test.exs +++ b/test/pleroma/marker_test.exs @@ -36,11 +36,12 @@ defmodule Pleroma.MarkerTest do insert(:notification, user: user, activity: insert(:note_activity)) insert(:notification, user: user, activity: insert(:note_activity)) insert(:marker, timeline: "home", user: user) + %Marker{} = refreshed_marker = refresh_record(marker) assert Marker.get_markers( user, ["notifications"] - ) == [%Marker{refresh_record(marker) | unread_count: 2}] + ) == [%{refreshed_marker | unread_count: 2}] end end From 5777d8e8d3c33bc70bb3cda6b7bb72592a310e16 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 9 Jan 2026 17:01:36 +0100 Subject: [PATCH 015/195] Elixir 1.19: Fix deprecation warning when invoking ParallelCompiler warning: you must pass return_diagnostics: true when invoking Kernel.ParallelCompiler functions (elixir 1.19.5) lib/kernel/parallel_compiler.ex:324: Kernel.ParallelCompiler.spawn_workers/3 (pleroma 2.10.0-7-ga7a74d5e-elixir-1-19+test) lib/pleroma/html.ex:13: Pleroma.HTML.compile_scrubbers/0 (pleroma 2.10.0-7-ga7a74d5e-elixir-1-19+test) lib/pleroma/application.ex:48: Pleroma.Application.start/2 (kernel 10.5) application_master.erl:299: :application_master.start_it_old/4 warning: you must pass return_diagnostics: true when invoking Kernel.ParallelCompiler functions (elixir 1.19.5) lib/kernel/parallel_compiler.ex:324: Kernel.ParallelCompiler.spawn_workers/3 (pleroma 2.10.0-7-ga7a74d5e-elixir-1-19+test) lib/pleroma/application.ex:121: Pleroma.Application.load_custom_modules/0 (pleroma 2.10.0-7-ga7a74d5e-elixir-1-19+test) lib/pleroma/application.ex:60: Pleroma.Application.start/2 (kernel 10.5) application_master.erl:299: :application_master.start_it_old/4 --- lib/pleroma/utils.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/utils.ex b/lib/pleroma/utils.ex index 73001c987..61d122a47 100644 --- a/lib/pleroma/utils.ex +++ b/lib/pleroma/utils.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Utils do dir |> File.ls!() |> Enum.map(&Path.join(dir, &1)) - |> Kernel.ParallelCompiler.compile() + |> Kernel.ParallelCompiler.compile(return_diagnostics: true) end @doc """ From 94ea3dffd1c49fadc6a80c34127ea59c7fd87311 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 9 Jan 2026 17:05:31 +0100 Subject: [PATCH 016/195] Elixir 1.19: Fix ConfigDBTest regex tests It is not possible match regexes anymore as this worked by accident previously. Instead, at least check that the sources of the regex (the regex itself) match. Notice the +1 difference in the regex Reference below. 1) test to_elixir_types/1 complex keyword with sigil (Pleroma.ConfigDBTest) test/pleroma/config_db_test.exs:460 Assertion with == failed code: assert ConfigDB.to_elixir_types([ %{"tuple" => [":federated_timeline_removal", []]}, %{"tuple" => [":reject", ["~r/comp[lL][aA][iI][nN]er/"]]}, %{"tuple" => [":replace", []]} ]) == [federated_timeline_removal: [], reject: [~r/comp[lL][aA][iI][nN]er/], replace: []] left: [federated_timeline_removal: [], reject: [%Regex{opts: [], re_pattern: {:re_pattern, 0, 0, 0, #Reference<0.230935836.591265794.259515>}, source: "comp[lL][aA][iI][nN]er"}], replace: []] right: [federated_timeline_removal: [], reject: [%Regex{opts: [], re_pattern: {:re_pattern, 0, 0, 0, #Reference<0.230935836.591265794.259516>}, source: "comp[lL][aA][iI][nN]er"}], replace: []] stacktrace: test/pleroma/config_db_test.exs:461: (test) --- test/pleroma/config_db_test.exs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/pleroma/config_db_test.exs b/test/pleroma/config_db_test.exs index d68e4e6fa..59beba48e 100644 --- a/test/pleroma/config_db_test.exs +++ b/test/pleroma/config_db_test.exs @@ -273,24 +273,24 @@ defmodule Pleroma.ConfigDBTest do end test "sigil" do - assert ConfigDB.to_elixir_types("~r[comp[lL][aA][iI][nN]er]") == ~r/comp[lL][aA][iI][nN]er/ + assert ConfigDB.to_elixir_types("~r[comp[lL][aA][iI][nN]er]").source == ~r/comp[lL][aA][iI][nN]er/.source end test "link sigil" do - assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/") == ~r/https:\/\/example.com/ + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/").source == ~r/https:\/\/example.com/.source end test "link sigil with um modifiers" do - assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/um") == - ~r/https:\/\/example.com/um + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/um").source == + ~r/https:\/\/example.com/um.source end test "link sigil with i modifier" do - assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/i") == ~r/https:\/\/example.com/i + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/i").source == ~r/https:\/\/example.com/i.source end test "link sigil with s modifier" do - assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/s") == ~r/https:\/\/example.com/s + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/s").source == ~r/https:\/\/example.com/s.source end test "raise if valid delimiter not found" do @@ -460,11 +460,11 @@ defmodule Pleroma.ConfigDBTest do test "complex keyword with sigil" do assert ConfigDB.to_elixir_types([ %{"tuple" => [":federated_timeline_removal", []]}, - %{"tuple" => [":reject", ["~r/comp[lL][aA][iI][nN]er/"]]}, + %{"tuple" => [":reject", [~r/comp[lL][aA][iI][nN]er/.source]]}, %{"tuple" => [":replace", []]} ]) == [ federated_timeline_removal: [], - reject: [~r/comp[lL][aA][iI][nN]er/], + reject: [~r/comp[lL][aA][iI][nN]er/.source], replace: [] ] end From cbacc0f146eaad98eb31574142a076658ae98f3f Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 9 Jan 2026 17:13:10 +0100 Subject: [PATCH 017/195] Elixir 1.19: Fix MRFTest regex tests It is no longer possible to match regexes. Instead at least match that the sources of the regexes (regexes themselves) are the same. Notice the +1 Reference number below. 2) test subdomain_match/2 wildcard domains with one subdomain (Pleroma.Web.ActivityPub.MRFTest) test/pleroma/web/activity_pub/mrf_test.exs:36 Assertion with == failed code: assert regexes == [~r/^(.*\.)*unsafe.tld$/i] left: [%Regex{opts: [:caseless], re_pattern: {:re_pattern, 1, 0, 0, #Reference<0.378940835.3277193222.129648>}, source: "^(.*\\.)*unsafe.tld$"}] right: [%Regex{opts: [:caseless], re_pattern: {:re_pattern, 1, 0, 0, #Reference<0.378940835.3277193222.129649>}, source: "^(.*\\.)*unsafe.tld$"}] stacktrace: test/pleroma/web/activity_pub/mrf_test.exs:39: (test) --- test/pleroma/web/activity_pub/mrf_test.exs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/test/pleroma/web/activity_pub/mrf_test.exs b/test/pleroma/web/activity_pub/mrf_test.exs index 25548e3da..6656a01b8 100644 --- a/test/pleroma/web/activity_pub/mrf_test.exs +++ b/test/pleroma/web/activity_pub/mrf_test.exs @@ -11,17 +11,21 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do alias Pleroma.Web.ActivityPub.MRF test "subdomains_regex/1" do - assert MRF.subdomains_regex(["unsafe.tld", "*.unsafe.tld"]) == [ - ~r/^unsafe.tld$/i, - ~r/^(.*\.)*unsafe.tld$/i + regexes = MRF.subdomains_regex(["unsafe.tld", "*.unsafe.tld"]) + matchable_regexes = Enum.map(regexes, fn r -> r.source end) + + assert matchable_regexes == [ + ~r/^unsafe.tld$/i.source, + ~r/^(.*\.)*unsafe.tld$/i.source ] end describe "subdomain_match/2" do test "common domains" do regexes = MRF.subdomains_regex(["unsafe.tld", "unsafe2.tld"]) + matchable_regexes = Enum.map(regexes, fn r -> r.source end) - assert regexes == [~r/^unsafe.tld$/i, ~r/^unsafe2.tld$/i] + assert matchable_regexes == [~r/^unsafe.tld$/i.source, ~r/^unsafe2.tld$/i.source] assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "unsafe2.tld") @@ -31,8 +35,9 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do test "wildcard domains with one subdomain" do regexes = MRF.subdomains_regex(["*.unsafe.tld"]) + matchable_regexes = Enum.map(regexes, fn r -> r.source end) - assert regexes == [~r/^(.*\.)*unsafe.tld$/i] + assert matchable_regexes == [~r/^(.*\.)*unsafe.tld$/i.source] assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "sub.unsafe.tld") @@ -42,8 +47,9 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do test "wildcard domains with two subdomains" do regexes = MRF.subdomains_regex(["*.unsafe.tld"]) + matchable_regexes = Enum.map(regexes, fn r -> r.source end) - assert regexes == [~r/^(.*\.)*unsafe.tld$/i] + assert matchable_regexes == [~r/^(.*\.)*unsafe.tld$/i.source] assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "sub.sub.unsafe.tld") @@ -53,8 +59,9 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do test "matches are case-insensitive" do regexes = MRF.subdomains_regex(["UnSafe.TLD", "UnSAFE2.Tld"]) + matchable_regexes = Enum.map(regexes, fn r -> r.source end) - assert regexes == [~r/^UnSafe.TLD$/i, ~r/^UnSAFE2.Tld$/i] + assert matchable_regexes == [~r/^UnSafe.TLD$/i.source, ~r/^UnSAFE2.Tld$/i.source] assert MRF.subdomain_match?(regexes, "UNSAFE.TLD") assert MRF.subdomain_match?(regexes, "UNSAFE2.TLD") From 3c7cd8b043237fb0ece9c314b73e335dfd28ffcb Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 9 Jan 2026 17:28:06 +0100 Subject: [PATCH 018/195] Elixir 1.19: Fix Mastodon StatusControllerTest DateTime difference --- .../web/mastodon_api/controllers/status_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index 298e92366..8477ae03f 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -3068,7 +3068,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do |> json_response_and_validate_schema(:ok) {:ok, a_expires_at, 0} = DateTime.from_iso8601(a_expires_at) - assert DateTime.diff(expires_at, a_expires_at) == 0 + assert DateTime.diff(DateTime.truncate(expires_at, :second), DateTime.truncate(a_expires_at, :second)) == 0 %{conn: conn} = oauth_access(["read:statuses"]) From dd2f0a32d8a0ab918cf840b937158e5b6d9418b3 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 9 Jan 2026 20:16:30 +0100 Subject: [PATCH 019/195] lint --- lib/pleroma/mfa/changeset.ex | 7 ++++--- lib/pleroma/web/api_spec/cast_and_validate.ex | 9 ++++++++- lib/pleroma/web/common_api/activity_draft.ex | 10 +++++++--- test/pleroma/config_db_test.exs | 12 ++++++++---- test/pleroma/web/activity_pub/activity_pub_test.exs | 3 ++- .../controllers/status_controller_test.exs | 6 +++++- 6 files changed, 34 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/mfa/changeset.ex b/lib/pleroma/mfa/changeset.ex index 890cb2193..2045c3a7c 100644 --- a/lib/pleroma/mfa/changeset.ex +++ b/lib/pleroma/mfa/changeset.ex @@ -8,7 +8,8 @@ defmodule Pleroma.MFA.Changeset do alias Pleroma.User def disable(%Ecto.Changeset{} = changeset, force \\ false) do - %Settings{} = settings = + %Settings{} = + settings = changeset |> Ecto.Changeset.apply_changes() |> MFA.fetch_settings() @@ -26,7 +27,7 @@ defmodule Pleroma.MFA.Changeset do end def confirm_totp(%User{multi_factor_authentication_settings: %Settings{} = settings} = user) do - totp_settings = %Settings.TOTP{%Settings.TOTP{} = settings.totp | confirmed: true} + totp_settings = %Settings.TOTP{(%Settings.TOTP{} = settings.totp) | confirmed: true} user |> put_change(%Settings{settings | totp: totp_settings, enabled: true}) @@ -46,7 +47,7 @@ defmodule Pleroma.MFA.Changeset do def cast_backup_codes(%User{} = user, codes) do user |> put_change(%Settings{ - %Settings{} = user.multi_factor_authentication_settings + (%Settings{} = user.multi_factor_authentication_settings) | backup_codes: codes }) end diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex index 57ea8e9b3..95bd4d9cf 100644 --- a/lib/pleroma/web/api_spec/cast_and_validate.ex +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -106,7 +106,14 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do OpenApiSpex.cast_and_validate(spec, operation, conn, content_type, cast_opts) end - defp cast_and_validate(spec, operation, %Conn{} = conn, content_type, false = _strict, cast_opts) do + defp cast_and_validate( + spec, + operation, + %Conn{} = conn, + content_type, + false = _strict, + cast_opts + ) do case OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do {:ok, conn} -> {:ok, conn} diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index c5959276a..6c84ef656 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -136,7 +136,8 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do defp in_reply_to(%__MODULE__{params: %{in_reply_to_status_id: ""}} = draft), do: draft - defp in_reply_to(%__MODULE__{params: %{in_reply_to_status_id: id}} = draft) when is_binary(id) do + defp in_reply_to(%__MODULE__{params: %{in_reply_to_status_id: id}} = draft) + when is_binary(id) do # If a post was deleted all its activities (except the newly added Delete) are purged too, # thus lookup by Create db ID will yield nil just as if it never existed in the first place. # @@ -166,13 +167,16 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do end end - defp in_reply_to(%__MODULE__{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft) do + defp in_reply_to( + %__MODULE__{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft + ) do %__MODULE__{draft | in_reply_to: in_reply_to} end defp in_reply_to(draft), do: draft - defp quote_post(%__MODULE__{params: %{quoted_status_id: id}} = draft) when not_empty_string(id) do + defp quote_post(%__MODULE__{params: %{quoted_status_id: id}} = draft) + when not_empty_string(id) do case Activity.get_by_id_with_object(id) do %Activity{} = activity -> %__MODULE__{draft | quote_post: activity} diff --git a/test/pleroma/config_db_test.exs b/test/pleroma/config_db_test.exs index 59beba48e..98b56a146 100644 --- a/test/pleroma/config_db_test.exs +++ b/test/pleroma/config_db_test.exs @@ -273,11 +273,13 @@ defmodule Pleroma.ConfigDBTest do end test "sigil" do - assert ConfigDB.to_elixir_types("~r[comp[lL][aA][iI][nN]er]").source == ~r/comp[lL][aA][iI][nN]er/.source + assert ConfigDB.to_elixir_types("~r[comp[lL][aA][iI][nN]er]").source == + ~r/comp[lL][aA][iI][nN]er/.source end test "link sigil" do - assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/").source == ~r/https:\/\/example.com/.source + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/").source == + ~r/https:\/\/example.com/.source end test "link sigil with um modifiers" do @@ -286,11 +288,13 @@ defmodule Pleroma.ConfigDBTest do end test "link sigil with i modifier" do - assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/i").source == ~r/https:\/\/example.com/i.source + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/i").source == + ~r/https:\/\/example.com/i.source end test "link sigil with s modifier" do - assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/s").source == ~r/https:\/\/example.com/s.source + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/s").source == + ~r/https:\/\/example.com/s.source end test "raise if valid delimiter not found" do diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 13146619a..9aafc41a5 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -1786,7 +1786,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do {:ok, list} = Pleroma.List.create(%{title: "foo"}, user) {:ok, list} = Pleroma.List.follow(list, member) - {:ok, %Activity{} = activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"}) + {:ok, %Activity{} = activity} = + CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"}) activity = Repo.preload(activity, :bookmark) activity = %{activity | thread_muted?: !!activity.thread_muted?} diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index 8477ae03f..11e96a6ac 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -3068,7 +3068,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do |> json_response_and_validate_schema(:ok) {:ok, a_expires_at, 0} = DateTime.from_iso8601(a_expires_at) - assert DateTime.diff(DateTime.truncate(expires_at, :second), DateTime.truncate(a_expires_at, :second)) == 0 + + assert DateTime.diff( + DateTime.truncate(expires_at, :second), + DateTime.truncate(a_expires_at, :second) + ) == 0 %{conn: conn} = oauth_access(["read:statuses"]) From 926cad3a76972835d340a7cabb0e90b8c01f51eb Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 10 Jan 2026 16:42:09 +0100 Subject: [PATCH 020/195] Elixir 1.19: Refactor Logger configuration to what Elixir 1.15+ expects Elixir 1.19 started emitting warnings about our use of: config :logger, :console and config :logger, backends: [] which got split in 1.15 to :default_handler, :default_formatter, and LoggerBackends.add(backend) respectively. On Pleroma startup it is now necessary to add the additional logger backends at runtime (done with configure_logger() in application.ex). New config key :pleroma, :logger_backends: [] has replaced the older backends array. The logger_backends package is now used to facilitate usage of other logger backends besides the default one (:logger_std_h from OTP). Warnings were added to Pleroma startup to warn about the usage of :logger, backends and additional logic was introduced to make addition of ExSyslogger to admin's configuration less dumb. Ref: https://hexdocs.pm/logger/1.15.0/Logger.html#module-backends-and-backwards-compatibility --- config/config.exs | 9 +++++---- config/dev.exs | 2 +- config/prod.exs | 3 +-- config/test.exs | 6 +++--- docs/configuration/cheatsheet.md | 25 +++++++++++++------------ lib/pleroma/application.ex | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/config/config.exs b/config/config.exs index 5bf2c5c2e..08311146e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -132,11 +132,12 @@ config :pleroma, Pleroma.Web.Endpoint, "SameSite=Lax" ] -# Configures Elixir's Logger -config :logger, backends: [:console] +# config :logger, :console has been changed in 1.15+ and split into +# :default_handler and :default_formatter. It started emitting warnings in 1.19. +# See https://hexdocs.pm/logger/1.19.0/Logger.html#module-backends-and-backwards-compatibility +config :logger, :default_handler, level: :debug -config :logger, :console, - level: :debug, +config :logger, :default_formatter, format: "\n$time $metadata[$level] $message\n", metadata: [:actor, :path, :type, :user] diff --git a/config/dev.exs b/config/dev.exs index 14cf4a6dc..e86bdb7fd 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -36,7 +36,7 @@ config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Local # different ports. # Do not include timestamps in development logs -config :logger, Logger.Backends.Console, format: "$metadata[$level] $message\n" +config :logger, :default_formatter, format: "$metadata[$level] $message\n" # Set a higher stacktrace during development. Avoid configuring such # in production as building large stacktraces may be expensive. diff --git a/config/prod.exs b/config/prod.exs index 2d252bf02..1dfbbe937 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -20,8 +20,7 @@ config :pleroma, Pleroma.Web.Endpoint, config :phoenix, serve_endpoints: true # Do not print debug messages in production -config :logger, Logger.Backends.Console, level: :info -config :logger, :console, level: :info +config :logger, :default_handler, level: :info config :logger, :ex_syslogger, level: :info # ## SSL Support diff --git a/config/test.exs b/config/test.exs index 9652b7d4b..bac5e4df3 100644 --- a/config/test.exs +++ b/config/test.exs @@ -15,9 +15,9 @@ config :pleroma, Pleroma.Captcha, method: Pleroma.Captcha.Mock # Print only warnings and errors during test -config :logger, :console, - level: :warning, - format: "\n[$level] $message\n" +config :logger, :default_handler, level: :warning + +config :logger, :default_formatter, format: "\n[$level] $message\n" config :pleroma, :auth, oauth_consumer_strategies: [] diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 9efa1c8b3..2ab2652b8 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -858,12 +858,14 @@ Web Push Notifications configuration. You can use the mix task `mix web_push.gen * ``private_key``: VAPID private key ## :logger -* `backends`: `:console` is used to send logs to stdout, `{ExSyslogger, :ex_syslogger}` to log to syslog +* Logging to console/stdout is done by default, use `{ExSyslogger, :ex_syslogger}` to log to syslog An example to enable ONLY ExSyslogger (f/ex in ``prod.secret.exs``) with info and debug suppressed: ```elixir -config :logger, - backends: [{ExSyslogger, :ex_syslogger}] +config :pleroma, + logger_backends: [{ExSyslogger, :ex_syslogger}] + +config :logger, default_handler: false config :logger, :ex_syslogger, level: :warning @@ -871,8 +873,8 @@ config :logger, :ex_syslogger, Another example, keeping console output and adding the pid to syslog output: ```elixir -config :logger, - backends: [:console, {ExSyslogger, :ex_syslogger}] +config :pleroma, + logger_backends: [{ExSyslogger, :ex_syslogger}] config :logger, :ex_syslogger, level: :warning, @@ -883,23 +885,22 @@ See: [logger’s documentation](https://hexdocs.pm/logger/Logger.html) and [ex_s An example of logging info to local syslog, but debug to console: ```elixir -config :logger, - backends: [ {ExSyslogger, :ex_syslogger}, :console ], - level: :info +config :pleroma, + logger_backends: [ {ExSyslogger, :ex_syslogger}] config :logger, :ex_syslogger, level: :info, ident: "pleroma", format: "$metadata[$level] $message" -config :logger, :console, - level: :debug, +config :logger, :default_handler, + level: :debug + +config :logger, :default_formatter, format: "\n$time $metadata[$level] $message\n", metadata: [:request_id] ``` - - ## Database options ### RUM indexing for full text search diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 8e1c5de0d..7355a501a 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -43,6 +43,7 @@ defmodule Pleroma.Application do # every time the application is restarted, so we disable module # conflicts at runtime Code.compiler_options(ignore_module_conflict: true) + configure_logger() Pleroma.Telemetry.Logger.attach() Config.Holder.save_default() Pleroma.HTML.compile_scrubbers() @@ -113,6 +114,37 @@ defmodule Pleroma.Application do Supervisor.start_link(children, opts) end + def configure_logger do + if Application.get_env(:logger, :backends) do + Logger.warning( + "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. Use 'config :pleroma, :logger_backends: [...]' instead." + ) + end + + Config.get([:logger_backends], []) + |> Enum.each(fn backend -> + backend = backend_to_logger(backend) + + case LoggerBackends.add(backend) do + {:ok, _} -> + Logger.debug("Successfully added logger backend: #{inspect(backend)}") + + {:error, reason} -> + Logger.error("Failed to add logger backend #{inspect(backend)}: #{inspect(reason)}") + end + end) + end + + defp backend_to_logger({:ex_syslogger = backend, name}) do + Logger.warning( + "Configuration {:#{backend}, :#{name}} is incorrect. Use {ExSyslogger, :#{name}} instead!" + ) + + {ExSyslogger, name} + end + + defp backend_to_logger(backend), do: backend + def load_custom_modules do dir = Config.get([:modules, :runtime_dir]) From 0393f0f92dcd68f7615b98139a35055d272e242a Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 12 Jan 2026 18:03:24 +0100 Subject: [PATCH 021/195] Elixir 1.19: Move Logger backends under a specific config key --- docs/configuration/cheatsheet.md | 12 ++++++------ lib/pleroma/application.ex | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 2ab2652b8..01d81f6ca 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -862,8 +862,8 @@ Web Push Notifications configuration. You can use the mix task `mix web_push.gen An example to enable ONLY ExSyslogger (f/ex in ``prod.secret.exs``) with info and debug suppressed: ```elixir -config :pleroma, - logger_backends: [{ExSyslogger, :ex_syslogger}] +config :pleroma, :logger, + backends: [{ExSyslogger, :ex_syslogger}] config :logger, default_handler: false @@ -873,8 +873,8 @@ config :logger, :ex_syslogger, Another example, keeping console output and adding the pid to syslog output: ```elixir -config :pleroma, - logger_backends: [{ExSyslogger, :ex_syslogger}] +config :pleroma, :logger, + backends: [{ExSyslogger, :ex_syslogger}] config :logger, :ex_syslogger, level: :warning, @@ -885,8 +885,8 @@ See: [logger’s documentation](https://hexdocs.pm/logger/Logger.html) and [ex_s An example of logging info to local syslog, but debug to console: ```elixir -config :pleroma, - logger_backends: [ {ExSyslogger, :ex_syslogger}] +config :pleroma, :logger, + backends: [ {ExSyslogger, :ex_syslogger}] config :logger, :ex_syslogger, level: :info, diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 7355a501a..ce5b4f8d6 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -117,11 +117,11 @@ defmodule Pleroma.Application do def configure_logger do if Application.get_env(:logger, :backends) do Logger.warning( - "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. Use 'config :pleroma, :logger_backends: [...]' instead." + "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. Use 'config :pleroma, :logger, backends: [...]' instead." ) end - Config.get([:logger_backends], []) + Config.get([:logger, :backends], []) |> Enum.each(fn backend -> backend = backend_to_logger(backend) From 3790e6786cce376cbabf2588beed27d0daf9c55c Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 12 Jan 2026 18:21:46 +0100 Subject: [PATCH 022/195] Only warn about bad clear_config usage if warned usage is met --- test/support/helpers.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/support/helpers.ex b/test/support/helpers.ex index 7fa6c31a4..259180e8f 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -73,7 +73,8 @@ defmodule Pleroma.Tests.Helpers do defmacro clear_config(config_path, temp_setting) do # NOTE: `clear_config([section, key], value)` != `clear_config([section], key: value)` (!) # Displaying a warning to prevent unintentional clearing of all but one keys in section - if Keyword.keyword?(temp_setting) and length(temp_setting) == 1 do + if is_list(config_path) and length(config_path) == 1 and Keyword.keyword?(temp_setting) and + Keyword.keys(temp_setting) == [:key] do Logger.warning( "Please change `clear_config([section], key: value)` to `clear_config([section, key], value)`" ) From eee54a9226b1b4642bb2a71daa0921ba0022ce48 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 12 Jan 2026 18:24:11 +0100 Subject: [PATCH 023/195] Elixir 1.19: Add tests for new Logger configuration --- test/pleroma/application_test.exs | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 test/pleroma/application_test.exs diff --git a/test/pleroma/application_test.exs b/test/pleroma/application_test.exs new file mode 100644 index 000000000..bff44f4ff --- /dev/null +++ b/test/pleroma/application_test.exs @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ApplicationTest do + use Pleroma.DataCase + + import ExUnit.CaptureLog + + # clear_config/2 can only manipulate env under :pleroma. + describe "config :logger, backends: []" do + setup do + Application.put_env(:logger, :backends, [:console, {ExSyslogger, :ex_syslogger}]) + + on_exit(fn -> + Application.delete_env(:logger, :backends) end) + end + + test "warns on deprecated syntax" do + assert capture_log(fn -> Pleroma.Application.configure_logger() end) =~ + "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. Use 'config :pleroma, :logger, backends: [...]' instead." + end + end + + describe "config :pleroma, :logger, backends: [{:ex_syslogger, :ex_syslogger}]" do + setup do + clear_config([:logger, :backends], [{:ex_syslogger, :ex_syslogger}]) + end + + test "is handled" do + log = capture_log(fn -> Pleroma.Application.configure_logger() end) + + assert log =~ "Configuration {:ex_syslogger, :ex_syslogger} is incorrect. Use {ExSyslogger, :ex_syslogger} instead!" + assert log =~ "Successfully added logger backend: {ExSyslogger, :ex_syslogger}" + end + end +end From 3ebf4b105629b137f620de7b71167463ad3ca6be Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 12 Jan 2026 19:31:15 +0100 Subject: [PATCH 024/195] Elixir 1.19: Move deprecated logger syntax check to DeprecationWarnings --- lib/pleroma/application.ex | 6 ------ lib/pleroma/config/deprecation_warnings.ex | 17 ++++++++++++++++- test/pleroma/application_test.exs | 15 --------------- .../config/deprecation_warnings_test.exs | 15 +++++++++++++++ 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index ce5b4f8d6..1e80c6249 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -115,12 +115,6 @@ defmodule Pleroma.Application do end def configure_logger do - if Application.get_env(:logger, :backends) do - Logger.warning( - "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. Use 'config :pleroma, :logger, backends: [...]' instead." - ) - end - Config.get([:logger, :backends], []) |> Enum.each(fn backend -> backend = backend_to_logger(backend) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 58d164dc7..8dabd728f 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -218,7 +218,8 @@ defmodule Pleroma.Config.DeprecationWarnings do check_quarantined_instances_tuples(), check_transparency_exclusions_tuples(), check_simple_policy_tuples(), - check_exiftool_filter() + check_exiftool_filter(), + check_deprecated_logger_config() ] |> Enum.reduce(:ok, fn :ok, :ok -> :ok @@ -415,4 +416,18 @@ defmodule Pleroma.Config.DeprecationWarnings do :ok end end + + @spec check_deprecated_logger_config() :: :ok | :error + def check_deprecated_logger_config() do + if Application.get_env(:logger, :backends) do + Logger.warning( + "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. " <> + "Use 'config :pleroma, :logger, backends: [...]' instead." + ) + + :error + else + :ok + end + end end diff --git a/test/pleroma/application_test.exs b/test/pleroma/application_test.exs index bff44f4ff..afe628732 100644 --- a/test/pleroma/application_test.exs +++ b/test/pleroma/application_test.exs @@ -7,21 +7,6 @@ defmodule Pleroma.ApplicationTest do import ExUnit.CaptureLog - # clear_config/2 can only manipulate env under :pleroma. - describe "config :logger, backends: []" do - setup do - Application.put_env(:logger, :backends, [:console, {ExSyslogger, :ex_syslogger}]) - - on_exit(fn -> - Application.delete_env(:logger, :backends) end) - end - - test "warns on deprecated syntax" do - assert capture_log(fn -> Pleroma.Application.configure_logger() end) =~ - "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. Use 'config :pleroma, :logger, backends: [...]' instead." - end - end - describe "config :pleroma, :logger, backends: [{:ex_syslogger, :ex_syslogger}]" do setup do clear_config([:logger, :backends], [{:ex_syslogger, :ex_syslogger}]) diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index fca2324ff..1491f4ea3 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -388,4 +388,19 @@ defmodule Pleroma.Config.DeprecationWarningsTest do end) =~ "Your config is using the old namespace for the Shoutbox configuration." end + + describe "check_deprecated_logger_config" do + setup do + Application.put_env(:logger, :backends, [:console, {ExSyslogger, :ex_syslogger}]) + + on_exit(fn -> + Application.delete_env(:logger, :backends) end) + end + + test "warns on deprecated syntax" do + assert capture_log(fn -> Pleroma.Config.DeprecationWarnings.check_deprecated_logger_config() end) =~ + "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. Use 'config :pleroma, :logger, backends: [...]' instead." + end + end + end From 39409a784df0a726aee3a6e48769344d83cd81f2 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 12 Jan 2026 19:35:12 +0100 Subject: [PATCH 025/195] Elixir 1.19: Warn on adding :console to Logger backends Logger.Backend.Console still exists, but it is unused in our configuration. Instead logging to console is done from Logger's "default_handler". --- lib/pleroma/application.ex | 28 +++++++++++++++++++--------- test/pleroma/application_test.exs | 11 +++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 1e80c6249..0e8723251 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -117,27 +117,37 @@ defmodule Pleroma.Application do def configure_logger do Config.get([:logger, :backends], []) |> Enum.each(fn backend -> - backend = backend_to_logger(backend) + case backend_to_logger(backend) do + {:ok, logger} -> + add_logger(logger) - case LoggerBackends.add(backend) do - {:ok, _} -> - Logger.debug("Successfully added logger backend: #{inspect(backend)}") - - {:error, reason} -> - Logger.error("Failed to add logger backend #{inspect(backend)}: #{inspect(reason)}") + {:error, :console_backend} -> + Logger.warning(":console is no longer considered a backend and is enabled by default") end end) end + defp add_logger(backend) do + case LoggerBackends.add(backend) do + {:ok, _} -> + Logger.debug("Successfully added logger backend: #{inspect(backend)}") + + {:error, reason} -> + Logger.error("Failed to add logger backend #{inspect(backend)}: #{inspect(reason)}") + end + end + + defp backend_to_logger(:console), do: {:error, :console_backend} + defp backend_to_logger({:ex_syslogger = backend, name}) do Logger.warning( "Configuration {:#{backend}, :#{name}} is incorrect. Use {ExSyslogger, :#{name}} instead!" ) - {ExSyslogger, name} + {:ok, {ExSyslogger, name}} end - defp backend_to_logger(backend), do: backend + defp backend_to_logger(backend), do: {:ok, backend} def load_custom_modules do dir = Config.get([:modules, :runtime_dir]) diff --git a/test/pleroma/application_test.exs b/test/pleroma/application_test.exs index afe628732..48f87818f 100644 --- a/test/pleroma/application_test.exs +++ b/test/pleroma/application_test.exs @@ -19,4 +19,15 @@ defmodule Pleroma.ApplicationTest do assert log =~ "Successfully added logger backend: {ExSyslogger, :ex_syslogger}" end end + + describe "config :pleroma, :logger, :backends: [:console]" do + setup do + clear_config([:logger, :backends], [:console]) + end + + test "emits a warning" do + assert capture_log(fn -> Pleroma.Application.configure_logger() end) =~ + ":console is no longer considered a backend and is enabled by default" + end + end end From 6744a86fe3a70834aac0c248f5b1c4d0680278fe Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 13 Jan 2026 00:36:15 +0100 Subject: [PATCH 026/195] Elixir 1.19: Disable logging with colors in test environment Can break some tests capture logs and then match on an expected text. Since color codes end up being in the captured log. --- config/test.exs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/config/test.exs b/config/test.exs index bac5e4df3..5b7f3b1ed 100644 --- a/config/test.exs +++ b/config/test.exs @@ -17,7 +17,11 @@ config :pleroma, Pleroma.Captcha, # Print only warnings and errors during test config :logger, :default_handler, level: :warning -config :logger, :default_formatter, format: "\n[$level] $message\n" +config :logger, :default_formatter, + format: "\n[$level] $message\n", + colors: [ + enabled: false + ] config :pleroma, :auth, oauth_consumer_strategies: [] From 17a563aeb41e4f2ec18788cf5a58a1a928044a6e Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 13 Jan 2026 00:39:48 +0100 Subject: [PATCH 027/195] Elixir 1.19: Inject @compile autoload opt to MRF policies Descriptions of MRF policies are gathered at compile-time and use Pleroma.Docs.Generator.list_behaviour_implementations func, which depends on modules implementing the queried behaviour to be loaded. Otherwise descriptions for MRFs can be missing from the AdminAPI route. Note: @behaviour cannot be injected in the __using__ macro. It causes a loop in the compiler which leads to memory exhaustion. Hence why @behaviour is still defined separately in the MRFs. --- .../web/activity_pub/mrf/activity_expiration_policy.ex | 2 ++ lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex | 6 +++--- lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex | 4 +++- .../web/activity_pub/mrf/anti_mention_spam_policy.ex | 4 +++- lib/pleroma/web/activity_pub/mrf/drop_policy.ex | 4 +++- lib/pleroma/web/activity_pub/mrf/emoji_policy.ex | 4 +++- lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex | 6 ++++-- lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex | 3 +++ .../web/activity_pub/mrf/force_bot_unlisted_policy.ex | 7 +++++-- lib/pleroma/web/activity_pub/mrf/force_mention.ex | 3 ++- .../web/activity_pub/mrf/force_mentions_in_content.ex | 4 +++- lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex | 4 +++- lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex | 7 ++++--- lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex | 2 ++ lib/pleroma/web/activity_pub/mrf/keyword_policy.ex | 6 ++++-- .../web/activity_pub/mrf/media_proxy_warming_policy.ex | 2 ++ lib/pleroma/web/activity_pub/mrf/mention_policy.ex | 3 ++- lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex | 2 ++ lib/pleroma/web/activity_pub/mrf/no_op_policy.ex | 2 ++ .../web/activity_pub/mrf/no_placeholder_text_policy.ex | 2 ++ lib/pleroma/web/activity_pub/mrf/normalize_markup.ex | 4 +++- lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex | 5 ++++- lib/pleroma/web/activity_pub/mrf/object_age_policy.ex | 6 ++++-- lib/pleroma/web/activity_pub/mrf/policy.ex | 8 ++++++++ lib/pleroma/web/activity_pub/mrf/quiet_reply.ex | 4 +++- .../web/activity_pub/mrf/quote_to_link_tag_policy.ex | 2 ++ lib/pleroma/web/activity_pub/mrf/reject_non_public.ex | 3 ++- lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex | 2 ++ lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 2 ++ lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex | 6 ++++-- lib/pleroma/web/activity_pub/mrf/subchain_policy.ex | 4 +++- lib/pleroma/web/activity_pub/mrf/tag_policy.ex | 7 +++++-- .../web/activity_pub/mrf/user_allow_list_policy.ex | 6 ++++-- lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex | 3 ++- 34 files changed, 105 insertions(+), 34 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index 88f6ca028..41abe17c0 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do @moduledoc "Adds expiration to all local Create activities" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + @impl true def filter(activity) do activity = diff --git a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex index 8ea61aec2..990ff31c9 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex @@ -3,11 +3,11 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy do + @moduledoc "Prevent followbots from following with a bit of heuristic" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy alias Pleroma.User - @moduledoc "Prevent followbots from following with a bit of heuristic" - - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy # XXX: this should become User.normalize_by_ap_id() or similar, really. defp normalize_by_ap_id(%{"id" => id}), do: User.get_cached_by_ap_id(id) diff --git a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex index 2be6d8df4..5c3b66e37 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex @@ -3,9 +3,11 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.User - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy require Logger diff --git a/lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex index 1d76a307b..c6f5781b3 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex @@ -3,11 +3,13 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.AntiMentionSpamPolicy do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Config alias Pleroma.User require Pleroma.Constants - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy defp user_has_posted?(%User{} = u), do: u.note_count > 0 diff --git a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex index cf07db7f3..4aa1968f7 100644 --- a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex @@ -3,10 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.DropPolicy do - require Logger @moduledoc "Drop and log everything received" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + require Logger + use Pleroma.Web.ActivityPub.MRF.Policy + @impl true def filter(activity) do Logger.debug("REJECTING #{inspect(activity)}") diff --git a/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex index 1de5280d9..cc5fd49bf 100644 --- a/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex @@ -3,6 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.EmojiPolicy do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + require Pleroma.Constants alias Pleroma.Object.Updater @@ -10,7 +12,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.EmojiPolicy do @moduledoc "Reject or force-unlisted emojis with certain URLs or names" - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy defp config_remove_url do Pleroma.Config.get([:mrf_emoji, :remove_url], []) diff --git a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex index f5983c8a7..6e43e746c 100644 --- a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex +++ b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex @@ -3,11 +3,13 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do - alias Pleroma.Object - @moduledoc "Ensure a re: is prepended on replies to a post with a Subject" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Object + + use Pleroma.Web.ActivityPub.MRF.Policy + @reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless]) def history_awareness, do: :auto diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex index 480a03ef6..4612b2cc5 100644 --- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -4,12 +4,15 @@ defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicy do @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Config alias Pleroma.User alias Pleroma.Web.CommonAPI require Logger + use Pleroma.Web.ActivityPub.MRF.Policy + @impl true def filter(activity) do with follower_nickname <- Config.get([:mrf_follow_bot, :follower_nickname]), diff --git a/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex index 3b3251dc3..58d02f433 100644 --- a/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex @@ -3,9 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy do - alias Pleroma.User - @behaviour Pleroma.Web.ActivityPub.MRF.Policy @moduledoc "Remove bot posts from federated timeline" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.User + + use Pleroma.Web.ActivityPub.MRF.Policy require Pleroma.Constants diff --git a/lib/pleroma/web/activity_pub/mrf/force_mention.ex b/lib/pleroma/web/activity_pub/mrf/force_mention.ex index 4ea23540d..62aff36bc 100644 --- a/lib/pleroma/web/activity_pub/mrf/force_mention.ex +++ b/lib/pleroma/web/activity_pub/mrf/force_mention.ex @@ -3,13 +3,14 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.ForceMention do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy require Pleroma.Constants alias Pleroma.Config alias Pleroma.Object alias Pleroma.User - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy defp get_author(url) do with %Object{data: %{"actor" => actor}} <- Object.normalize(url, fetch: false), diff --git a/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex b/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex index caae365e5..e7c3f2d88 100644 --- a/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex +++ b/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex @@ -3,13 +3,15 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.ForceMentionsInContent do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + require Pleroma.Constants alias Pleroma.Formatter alias Pleroma.Object alias Pleroma.User - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy @impl true def history_awareness, do: :auto diff --git a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex index 72f2274ed..a3fae3bae 100644 --- a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex @@ -3,6 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + require Pleroma.Constants alias Pleroma.Config @@ -14,7 +16,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do Note: This MRF Policy is always enabled, if you want to disable it you have to set empty lists. """ - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy @impl true def history_awareness, do: :manual diff --git a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex index 3a80d0a69..a84a7b0c8 100644 --- a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex @@ -3,13 +3,14 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicy do + @moduledoc "Block activities with too much mentions (configurable)" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.User require Pleroma.Constants - @moduledoc "Block activities with too much mentions (configurable)" - - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy defp delist_activity(activity, threshold) when threshold > 0 do follower_collection = User.get_cached_by_ap_id(activity["actor"]).follower_address diff --git a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex index 51dcd3918..17879b4bd 100644 --- a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy do @moduledoc "Force a quote line into the message content." @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + defp build_inline_quote(template, url) do quote_line = String.replace(template, "{url}", "#{url}") diff --git a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex index 6ba6fd509..44b79596e 100644 --- a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex @@ -3,13 +3,15 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do + @moduledoc "Reject or Word-Replace activities with a keyword or regex" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + require Pleroma.Constants alias Pleroma.Web.ActivityPub.MRF.Utils - @moduledoc "Reject or Word-Replace activities with a keyword or regex" - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy defp string_matches?(string, pattern) when is_binary(pattern) do String.contains?(string, pattern) diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex index 43c2c0449..07046a666 100644 --- a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do @moduledoc "Preloads any attachments in the MediaProxy cache by prefetching them" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.HTTP alias Pleroma.Web.MediaProxy diff --git a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex index f7bff121f..8e108a0ae 100644 --- a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex @@ -4,9 +4,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.MentionPolicy do @moduledoc "Block activities which mention a user" - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + @impl true def filter(%{"type" => "Create"} = activity) do reject_actors = Pleroma.Config.get([:mrf_mention, :actors], []) diff --git a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex index 08dd39878..8283fd504 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do @moduledoc "Filter local activities which have no content" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Web.Endpoint @impl true diff --git a/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex index 64a5872bc..3adbb598a 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoOpPolicy do @moduledoc "Does nothing (lets the messages go through unmodified)" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + @impl true def filter(activity) do {:ok, activity} diff --git a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex index c6f239a5e..2a10c7e97 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy do @moduledoc "Ensure no content placeholder is present (such as the dot from mastodon)" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + @impl true def history_awareness, do: :auto diff --git a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex index 91855ef84..de0f5b619 100644 --- a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex +++ b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex @@ -4,9 +4,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkup do @moduledoc "Scrub configured hypertext markup" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.HTML - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy @impl true def history_awareness, do: :auto diff --git a/lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex b/lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex index 52aaf05aa..7e3ec3de5 100644 --- a/lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex @@ -38,6 +38,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.NsfwApiPolicy do - `unlist`: Unlist all detected NSFW content? Default: `false` - `reject`: Reject all detected NSFW content (takes precedence)? Default: `false` """ + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Config alias Pleroma.Constants alias Pleroma.HTTP @@ -46,7 +48,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.NsfwApiPolicy do require Logger require Pleroma.Constants - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + @policy :mrf_nsfw_api def build_request_url(url) do diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex index 34905fc21..00f4cf84b 100644 --- a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex @@ -3,13 +3,15 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy do + @moduledoc "Filter activities depending on their age" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Config alias Pleroma.User require Pleroma.Constants - @moduledoc "Filter activities depending on their age" - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy defp check_date(%{"object" => %{"published" => published}} = activity) do with %DateTime{} = now <- DateTime.utc_now(), diff --git a/lib/pleroma/web/activity_pub/mrf/policy.ex b/lib/pleroma/web/activity_pub/mrf/policy.ex index 08bcac08a..b9a693c9e 100644 --- a/lib/pleroma/web/activity_pub/mrf/policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/policy.ex @@ -15,4 +15,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.Policy do } @callback history_awareness() :: :auto | :manual @optional_callbacks config_description: 0, history_awareness: 0, id_filter: 1 + + defmacro __using__(_) do + quote do + # Otherwise Pleroma.Docs.Generator.list_behaviour_implementations fails + # since the Elixir compiler doesn't automatically load the module since v1.19. + @compile {:autoload, true} + end + end end diff --git a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex index b3eb0b390..b47e9edb9 100644 --- a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex +++ b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex @@ -6,11 +6,13 @@ defmodule Pleroma.Web.ActivityPub.MRF.QuietReply do @moduledoc """ QuietReply alters the scope of activities from local users when replying by enforcing them to be "Unlisted" or "Quiet Public". This delivers the activity to all the expected recipients and instances, but it will not be published in the Federated / The Whole Known Network timelines. It will still be published to the Home timelines of the user's followers and visible to anyone who opens the thread. """ + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + require Pleroma.Constants alias Pleroma.User - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy @impl true def history_awareness, do: :auto diff --git a/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex index 2a17b6761..e1ad17585 100644 --- a/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.QuoteToLinkTagPolicy do @moduledoc "Force a Link tag for posts quoting another post. (may break outgoing federation of quote posts with older Pleroma versions)" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes require Pleroma.Constants diff --git a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex index 9d4a7a405..7d113dbf6 100644 --- a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex +++ b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex @@ -4,11 +4,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.RejectNonPublic do @moduledoc "Rejects non-public (followers-only, direct) activities" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy alias Pleroma.Config alias Pleroma.User - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy require Pleroma.Constants diff --git a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex index 8422832dd..f9f8d2298 100644 --- a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex @@ -2,6 +2,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do @moduledoc "Drop remote reports if they don't contain enough information." @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Config @impl true diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index a97e8db7b..7045fe90f 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do @moduledoc "Filter activities depending on their origin instance" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Config alias Pleroma.FollowingRelationship alias Pleroma.User diff --git a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex index 54f0e6bc1..f1e99aead 100644 --- a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex @@ -3,12 +3,14 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do + @moduledoc "Detect new emojis by their shortcode and steals them" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + require Logger alias Pleroma.Config - @moduledoc "Detect new emojis by their shortcode and steals them" - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], []) diff --git a/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex index 97acca7e8..1fe7b00e9 100644 --- a/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex @@ -3,12 +3,14 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.SubchainPolicy do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Config alias Pleroma.Web.ActivityPub.MRF require Logger - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy defp lookup_subchain(actor) do with matches <- Config.get([:mrf_subchain, :match_actor]), diff --git a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex index c236a5a99..df5594808 100644 --- a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex @@ -3,8 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.TagPolicy do - alias Pleroma.User - @behaviour Pleroma.Web.ActivityPub.MRF.Policy @moduledoc """ Apply policies based on user tags @@ -18,6 +16,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.TagPolicy do - `mrf_tag:disable-remote-subscription`: Reject non-local follow requests - `mrf_tag:disable-any-subscription`: Reject any follow requests """ + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.User + + use Pleroma.Web.ActivityPub.MRF.Policy require Pleroma.Constants diff --git a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex index 10cc0e09d..a04ca90bd 100644 --- a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex @@ -3,11 +3,13 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy do - alias Pleroma.Config - @moduledoc "Accept-list of users from specified instances" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Config + + use Pleroma.Web.ActivityPub.MRF.Policy + defp filter_by_list(activity, []), do: {:ok, activity} defp filter_by_list(%{"actor" => actor} = activity, allow_list) do diff --git a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex index 5671e4cf3..ecba3a2c6 100644 --- a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex @@ -4,9 +4,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicy do @moduledoc "Filter activities which belong to certain activity vocabularies" - @behaviour Pleroma.Web.ActivityPub.MRF.Policy + use Pleroma.Web.ActivityPub.MRF.Policy + @impl true def filter(%{"type" => "Undo", "object" => object} = activity) do with {:ok, _} <- filter(object) do From 2a23eacee899c69f55d8a3cb6ed8291b56c7187b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 13 Jan 2026 14:58:32 +0100 Subject: [PATCH 028/195] Elixir 1.19: Only match once on structs Second match is not needed and a simple Map update is recommended by the compiler --- lib/pleroma/marker.ex | 2 +- lib/pleroma/web/api_spec/render_error.ex | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index 4f645240e..fab24d183 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -78,7 +78,7 @@ defmodule Pleroma.Marker do defp get_marker(user, timeline) do case Repo.find_resource(get_query(user, timeline)) do - {:ok, %__MODULE__{} = marker} -> %__MODULE__{marker | user: user} + {:ok, %__MODULE__{} = marker} -> %{marker | user: user} _ -> %__MODULE__{timeline: timeline, user_id: user.id} end end diff --git a/lib/pleroma/web/api_spec/render_error.ex b/lib/pleroma/web/api_spec/render_error.ex index acf510774..2ba76f250 100644 --- a/lib/pleroma/web/api_spec/render_error.ex +++ b/lib/pleroma/web/api_spec/render_error.ex @@ -18,10 +18,10 @@ defmodule Pleroma.Web.ApiSpec.RenderError do errors = Enum.map(errors, fn %OpenApiSpex.Cast.Error{name: nil, reason: :invalid_enum} = err -> - %OpenApiSpex.Cast.Error{err | name: err.value} + %{err | name: err.value} %OpenApiSpex.Cast.Error{name: nil} = err -> - %OpenApiSpex.Cast.Error{err | name: List.last(err.path)} + %{err | name: List.last(err.path)} err -> err From c455e0225581e3ebe43e9ef51a7ee393d8e87bdd Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 13 Jan 2026 15:21:41 +0100 Subject: [PATCH 029/195] Elixir 1.19: Add non-test Elixir files in test/ to mix.exs filter This is a new filter introduced in 1.19. --- mix.exs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mix.exs b/mix.exs index dc9fa31f5..60064e846 100644 --- a/mix.exs +++ b/mix.exs @@ -15,6 +15,13 @@ defmodule Pleroma.Mixfile do aliases: aliases(), deps: deps(), test_coverage: [tool: :covertool, summary: true], + test_ignore_filters: [ + "test/credo/check/consistency/file_location.ex", + "test/fixtures/config/temp.exported_from_db.secret.exs", + "test/fixtures/config/temp.secret.exs", + "test/fixtures/modules/good_mrf.ex", + "test/fixtures/modules/runtime_module.ex" + ], # Docs name: "Pleroma", homepage_url: "https://pleroma.social/", From a4c929c527353f99cd11aeb9c08a44f212632f12 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 13 Jan 2026 20:44:26 +0100 Subject: [PATCH 030/195] Lint --- lib/pleroma/config/deprecation_warnings.ex | 4 ++-- lib/pleroma/web/activity_pub/mrf/keyword_policy.ex | 1 - test/pleroma/application_test.exs | 6 ++++-- test/pleroma/config/deprecation_warnings_test.exs | 10 ++++++---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 8dabd728f..8f56a3771 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -418,11 +418,11 @@ defmodule Pleroma.Config.DeprecationWarnings do end @spec check_deprecated_logger_config() :: :ok | :error - def check_deprecated_logger_config() do + def check_deprecated_logger_config do if Application.get_env(:logger, :backends) do Logger.warning( "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. " <> - "Use 'config :pleroma, :logger, backends: [...]' instead." + "Use 'config :pleroma, :logger, backends: [...]' instead." ) :error diff --git a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex index 44b79596e..33f32e781 100644 --- a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex @@ -10,7 +10,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do alias Pleroma.Web.ActivityPub.MRF.Utils - use Pleroma.Web.ActivityPub.MRF.Policy defp string_matches?(string, pattern) when is_binary(pattern) do diff --git a/test/pleroma/application_test.exs b/test/pleroma/application_test.exs index 48f87818f..b4a29fc9b 100644 --- a/test/pleroma/application_test.exs +++ b/test/pleroma/application_test.exs @@ -15,7 +15,9 @@ defmodule Pleroma.ApplicationTest do test "is handled" do log = capture_log(fn -> Pleroma.Application.configure_logger() end) - assert log =~ "Configuration {:ex_syslogger, :ex_syslogger} is incorrect. Use {ExSyslogger, :ex_syslogger} instead!" + assert log =~ + "Configuration {:ex_syslogger, :ex_syslogger} is incorrect. Use {ExSyslogger, :ex_syslogger} instead!" + assert log =~ "Successfully added logger backend: {ExSyslogger, :ex_syslogger}" end end @@ -27,7 +29,7 @@ defmodule Pleroma.ApplicationTest do test "emits a warning" do assert capture_log(fn -> Pleroma.Application.configure_logger() end) =~ - ":console is no longer considered a backend and is enabled by default" + ":console is no longer considered a backend and is enabled by default" end end end diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index 1491f4ea3..5383e2bce 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -394,13 +394,15 @@ defmodule Pleroma.Config.DeprecationWarningsTest do Application.put_env(:logger, :backends, [:console, {ExSyslogger, :ex_syslogger}]) on_exit(fn -> - Application.delete_env(:logger, :backends) end) + Application.delete_env(:logger, :backends) + end) end test "warns on deprecated syntax" do - assert capture_log(fn -> Pleroma.Config.DeprecationWarnings.check_deprecated_logger_config() end) =~ - "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. Use 'config :pleroma, :logger, backends: [...]' instead." + assert capture_log(fn -> + Pleroma.Config.DeprecationWarnings.check_deprecated_logger_config() + end) =~ + "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. Use 'config :pleroma, :logger, backends: [...]' instead." end end - end From a4137243b62795b6299248ccf11f80ccda059d56 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 13 Jan 2026 21:07:06 +0100 Subject: [PATCH 031/195] Elixir 1.19: Reword Logger backends deprecation warning --- lib/pleroma/config/deprecation_warnings.ex | 15 +++++++++++---- test/pleroma/config/deprecation_warnings_test.exs | 12 +++++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 8f56a3771..8b8628862 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -420,10 +420,17 @@ defmodule Pleroma.Config.DeprecationWarnings do @spec check_deprecated_logger_config() :: :ok | :error def check_deprecated_logger_config do if Application.get_env(:logger, :backends) do - Logger.warning( - "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. " <> - "Use 'config :pleroma, :logger, backends: [...]' instead." - ) + Logger.warning(""" + !!!DEPRECATION WARNING!!! + Your configuration is using deprecated syntax for configuring backends of Elixir's logger. + `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir. + Please update your configuration at your earliest convenience to use: + `config :pleroma, :logger, backends: [...]` + Pleroma's default configuration will be updated in the next release which WILL override your settings. + + Note: :console is no longer considered a backend and is used by default, you can disable it using: + `config :logger, :default_handler: false` + """) :error else diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index 5383e2bce..a9a71be36 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -402,7 +402,17 @@ defmodule Pleroma.Config.DeprecationWarningsTest do assert capture_log(fn -> Pleroma.Config.DeprecationWarnings.check_deprecated_logger_config() end) =~ - "'config :logger, backends: [...]' is deprecated syntax due to changes in Elixir. Use 'config :pleroma, :logger, backends: [...]' instead." + """ + !!!DEPRECATION WARNING!!! + Your configuration is using deprecated syntax for configuring backends of Elixir's logger. + `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir. + Please update your configuration at your earliest convenience to use: + `config :pleroma, :logger, backends: [...]` + Pleroma's default configuration will be updated in the next release which WILL override your settings. + + Note: :console is no longer considered a backend and is used by default, you can disable it using: + `config :logger, :default_handler: false` + """ end end end From 6ffd25682d4a0e6cebe9b0c8789c76bb9e8a312d Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 13 Jan 2026 21:18:08 +0100 Subject: [PATCH 032/195] Elixir 1.19: Add deprecation warning for :logger, :console configs --- lib/pleroma/config/deprecation_warnings.ex | 30 +++++++++++--- .../config/deprecation_warnings_test.exs | 41 +++++++++++++------ 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 8b8628862..f38fb7f2f 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -419,22 +419,42 @@ defmodule Pleroma.Config.DeprecationWarnings do @spec check_deprecated_logger_config() :: :ok | :error def check_deprecated_logger_config do - if Application.get_env(:logger, :backends) do + backend = if Application.get_env(:logger, :backends) do Logger.warning(""" !!!DEPRECATION WARNING!!! Your configuration is using deprecated syntax for configuring backends of Elixir's logger. `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir. Please update your configuration at your earliest convenience to use: - `config :pleroma, :logger, backends: [...]` + `config :logger, backends: [...]` Pleroma's default configuration will be updated in the next release which WILL override your settings. - Note: :console is no longer considered a backend and is used by default, you can disable it using: + Note: `:console` is no longer considered a backend and is used by default, you can disable it using: `config :logger, :default_handler: false` """) - :error + true else - :ok + false end + + console = if Application.get_env(:logger, :console) do + Logger.warning(""" + !!!DEPRECATION WARNING!!! + Your configuration is using deprecated syntax for configuring logging to console. + `config :logger, :console` is deprecated syntax due to changes in Elixir. + Please update your configuration at your earliest convenience to use + `config :logger, default_handler` and `config :logger, :default_formatter`. + Pleroma's default configuration will be updated in the next release which WILL override your settings. + + Note: `:default_handler` is used only for the `level` setting. All other configurations go under + `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility + """) + + true + else + false + end + + if backend or console, do: :error, else: :ok end end diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index a9a71be36..f5b9d76d3 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -392,27 +392,44 @@ defmodule Pleroma.Config.DeprecationWarningsTest do describe "check_deprecated_logger_config" do setup do Application.put_env(:logger, :backends, [:console, {ExSyslogger, :ex_syslogger}]) + Application.put_env(:logger, :console, level: :warning) on_exit(fn -> Application.delete_env(:logger, :backends) + Application.delete_env(:logger, :console) end) end test "warns on deprecated syntax" do - assert capture_log(fn -> + log = capture_log(fn -> Pleroma.Config.DeprecationWarnings.check_deprecated_logger_config() - end) =~ - """ - !!!DEPRECATION WARNING!!! - Your configuration is using deprecated syntax for configuring backends of Elixir's logger. - `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir. - Please update your configuration at your earliest convenience to use: - `config :pleroma, :logger, backends: [...]` - Pleroma's default configuration will be updated in the next release which WILL override your settings. + end) - Note: :console is no longer considered a backend and is used by default, you can disable it using: - `config :logger, :default_handler: false` - """ + assert log =~ + """ + !!!DEPRECATION WARNING!!! + Your configuration is using deprecated syntax for configuring backends of Elixir's logger. + `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir. + Please update your configuration at your earliest convenience to use: + `config :logger, backends: [...]` + Pleroma's default configuration will be updated in the next release which WILL override your settings. + + Note: `:console` is no longer considered a backend and is used by default, you can disable it using: + `config :logger, :default_handler: false` + """ + + assert log =~ + """ + !!!DEPRECATION WARNING!!! + Your configuration is using deprecated syntax for configuring logging to console. + `config :logger, :console` is deprecated syntax due to changes in Elixir. + Please update your configuration at your earliest convenience to use + `config :logger, default_handler` and `config :logger, :default_formatter`. + Pleroma's default configuration will be updated in the next release which WILL override your settings. + + Note: `:default_handler` is used only for the `level` setting. All other configurations go under + `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility + """ end end end From a5bd4e0d9230fe9aaad4722681ed12aec0cb5301 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 13 Jan 2026 21:31:07 +0100 Subject: [PATCH 033/195] Partially revert "Elixir 1.19: Refactor Logger configuration to what Elixir 1.15+ expects" This partially reverts commit fc35204cc1d718ba7815cc5e1b08746202b4f7dc. --- config/config.exs | 9 ++++----- config/dev.exs | 2 +- config/prod.exs | 3 ++- config/test.exs | 10 +++------- docs/configuration/cheatsheet.md | 2 +- 5 files changed, 11 insertions(+), 15 deletions(-) diff --git a/config/config.exs b/config/config.exs index 08311146e..5bf2c5c2e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -132,12 +132,11 @@ config :pleroma, Pleroma.Web.Endpoint, "SameSite=Lax" ] -# config :logger, :console has been changed in 1.15+ and split into -# :default_handler and :default_formatter. It started emitting warnings in 1.19. -# See https://hexdocs.pm/logger/1.19.0/Logger.html#module-backends-and-backwards-compatibility -config :logger, :default_handler, level: :debug +# Configures Elixir's Logger +config :logger, backends: [:console] -config :logger, :default_formatter, +config :logger, :console, + level: :debug, format: "\n$time $metadata[$level] $message\n", metadata: [:actor, :path, :type, :user] diff --git a/config/dev.exs b/config/dev.exs index e86bdb7fd..14cf4a6dc 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -36,7 +36,7 @@ config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Local # different ports. # Do not include timestamps in development logs -config :logger, :default_formatter, format: "$metadata[$level] $message\n" +config :logger, Logger.Backends.Console, format: "$metadata[$level] $message\n" # Set a higher stacktrace during development. Avoid configuring such # in production as building large stacktraces may be expensive. diff --git a/config/prod.exs b/config/prod.exs index 1dfbbe937..2d252bf02 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -20,7 +20,8 @@ config :pleroma, Pleroma.Web.Endpoint, config :phoenix, serve_endpoints: true # Do not print debug messages in production -config :logger, :default_handler, level: :info +config :logger, Logger.Backends.Console, level: :info +config :logger, :console, level: :info config :logger, :ex_syslogger, level: :info # ## SSL Support diff --git a/config/test.exs b/config/test.exs index 5b7f3b1ed..9652b7d4b 100644 --- a/config/test.exs +++ b/config/test.exs @@ -15,13 +15,9 @@ config :pleroma, Pleroma.Captcha, method: Pleroma.Captcha.Mock # Print only warnings and errors during test -config :logger, :default_handler, level: :warning - -config :logger, :default_formatter, - format: "\n[$level] $message\n", - colors: [ - enabled: false - ] +config :logger, :console, + level: :warning, + format: "\n[$level] $message\n" config :pleroma, :auth, oauth_consumer_strategies: [] diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 01d81f6ca..b0a7087a7 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -886,7 +886,7 @@ See: [logger’s documentation](https://hexdocs.pm/logger/Logger.html) and [ex_s An example of logging info to local syslog, but debug to console: ```elixir config :pleroma, :logger, - backends: [ {ExSyslogger, :ex_syslogger}] + backends: [{ExSyslogger, :ex_syslogger}] config :logger, :ex_syslogger, level: :info, From aed0eb33331568b38c14b83d753c671b8336cb38 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 13 Jan 2026 23:28:57 +0100 Subject: [PATCH 034/195] Elixir 1.19: Warn on old config of :console Logger and conflicts --- lib/pleroma/config/deprecation_warnings.ex | 122 +++++++++++++----- .../config/deprecation_warnings_test.exs | 47 +++---- 2 files changed, 115 insertions(+), 54 deletions(-) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index f38fb7f2f..ee3965b64 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -419,42 +419,102 @@ defmodule Pleroma.Config.DeprecationWarnings do @spec check_deprecated_logger_config() :: :ok | :error def check_deprecated_logger_config do - backend = if Application.get_env(:logger, :backends) do - Logger.warning(""" - !!!DEPRECATION WARNING!!! - Your configuration is using deprecated syntax for configuring backends of Elixir's logger. - `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir. - Please update your configuration at your earliest convenience to use: - `config :logger, backends: [...]` - Pleroma's default configuration will be updated in the next release which WILL override your settings. + mix_env = Pleroma.Config.get(:env) + config_file_name = "config/#{mix_env}.secret.exs" + config_file = File.read(config_file_name) - Note: `:console` is no longer considered a backend and is used by default, you can disable it using: - `config :logger, :default_handler: false` - """) + config_file = + case config_file do + {:ok, contents} -> + String.split(contents, "\n", trim: true) + |> Enum.reject(fn string -> String.starts_with?(string, "#") end) + |> Enum.join("\n") - true - else - false - end + _ -> + false + end - console = if Application.get_env(:logger, :console) do - Logger.warning(""" - !!!DEPRECATION WARNING!!! - Your configuration is using deprecated syntax for configuring logging to console. - `config :logger, :console` is deprecated syntax due to changes in Elixir. - Please update your configuration at your earliest convenience to use - `config :logger, default_handler` and `config :logger, :default_formatter`. - Pleroma's default configuration will be updated in the next release which WILL override your settings. + declared_deprecated_backend = + if is_binary(config_file) do + String.contains?(config_file, "config :logger, backends") or + String.contains?(config_file, "config :logger,\n backends") + else + nil + end - Note: `:default_handler` is used only for the `level` setting. All other configurations go under - `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility - """) + declared_deprecated_console = + if is_binary(config_file) do + String.contains?(config_file, "config :logger, :console") or + String.contains?(config_file, "config :logger,\n console") + else + nil + end - true - else - false - end + # We can't use Application.get_env/3, because by default default_formatter is populated + declared_new_console = + if is_binary(config_file) do + String.contains?(config_file, "config :logger, :default_handler") or + String.contains?(config_file, "config :logger,\n :default_handler") or + String.contains?(config_file, "config :logger, :default_formatter") or + String.contains?(config_file, "config :logger,\n :default_formatter") + else + nil + end - if backend or console, do: :error, else: :ok + use_new_backend_config = Pleroma.Config.get([:logger, :backends]) + + backend = + if declared_deprecated_backend do + Logger.warning(""" + !!!DEPRECATION WARNING!!! + Your configuration is using deprecated syntax for configuring backends of Elixir's logger. + `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir. + Please update your configuration at your earliest convenience to use: + `config :logger, backends: [...]` + Pleroma's default configuration will be updated in the next release which WILL override your settings. + + Note: `:console` is no longer considered a backend and is used by default, you can disable it using: + `config :logger, :default_handler: false` + """) + + true + else + false + end + + console = + if declared_deprecated_console do + Logger.warning(""" + !!!DEPRECATION WARNING!!! + Your configuration is using deprecated syntax for configuring logging to console. + `config :logger, :console` is deprecated syntax due to changes in Elixir. + Please update your configuration at your earliest convenience to use + `config :logger, default_handler` and `config :logger, :default_formatter`. + Pleroma's default configuration will be updated in the next release which WILL override your settings. + + Note: `:default_handler` is used only for the `level` setting. All other configurations go under + `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility + """) + + true + else + false + end + + conflict = + if (!is_nil(use_new_backend_config) and declared_deprecated_backend) or + (declared_new_console and declared_deprecated_console) do + Logger.warning(""" + !!!CONFIGURATION CONFLICT!!! + Pleroma has detected that both the deprecated way of configuring Logger and the new way are defined in your configuration. + Delete the deprecated configuration syntax. + """) + + true + else + false + end + + if backend or console or conflict, do: :error, else: :ok end end diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index f5b9d76d3..cf2c6b4d2 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -401,35 +401,36 @@ defmodule Pleroma.Config.DeprecationWarningsTest do end test "warns on deprecated syntax" do - log = capture_log(fn -> - Pleroma.Config.DeprecationWarnings.check_deprecated_logger_config() - end) + log = + capture_log(fn -> + Pleroma.Config.DeprecationWarnings.check_deprecated_logger_config() + end) assert log =~ - """ - !!!DEPRECATION WARNING!!! - Your configuration is using deprecated syntax for configuring backends of Elixir's logger. - `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir. - Please update your configuration at your earliest convenience to use: - `config :logger, backends: [...]` - Pleroma's default configuration will be updated in the next release which WILL override your settings. + """ + !!!DEPRECATION WARNING!!! + Your configuration is using deprecated syntax for configuring backends of Elixir's logger. + `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir. + Please update your configuration at your earliest convenience to use: + `config :logger, backends: [...]` + Pleroma's default configuration will be updated in the next release which WILL override your settings. - Note: `:console` is no longer considered a backend and is used by default, you can disable it using: - `config :logger, :default_handler: false` - """ + Note: `:console` is no longer considered a backend and is used by default, you can disable it using: + `config :logger, :default_handler: false` + """ assert log =~ - """ - !!!DEPRECATION WARNING!!! - Your configuration is using deprecated syntax for configuring logging to console. - `config :logger, :console` is deprecated syntax due to changes in Elixir. - Please update your configuration at your earliest convenience to use - `config :logger, default_handler` and `config :logger, :default_formatter`. - Pleroma's default configuration will be updated in the next release which WILL override your settings. + """ + !!!DEPRECATION WARNING!!! + Your configuration is using deprecated syntax for configuring logging to console. + `config :logger, :console` is deprecated syntax due to changes in Elixir. + Please update your configuration at your earliest convenience to use + `config :logger, default_handler` and `config :logger, :default_formatter`. + Pleroma's default configuration will be updated in the next release which WILL override your settings. - Note: `:default_handler` is used only for the `level` setting. All other configurations go under - `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility - """ + Note: `:default_handler` is used only for the `level` setting. All other configurations go under + `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility + """ end end end From bd0c5aa748d226d109aeec7462ac9e6ba8d12f75 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 14 Jan 2026 11:09:57 +0100 Subject: [PATCH 035/195] Elixir 1.19: Move default configs to the new Logger configuration, again Trying to do a two-release migration proved to be very hacky and ugly. Instead this is preparation for moving defaults first and then merging the old configuration left in admin's config file at runtime while still emitting a warning. This hopefully will be a lot nicer and less complicated. --- config/config.exs | 5 ++--- config/dev.exs | 2 +- config/prod.exs | 3 +-- config/test.exs | 5 ++--- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/config/config.exs b/config/config.exs index 5bf2c5c2e..069c0c7c0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -133,10 +133,9 @@ config :pleroma, Pleroma.Web.Endpoint, ] # Configures Elixir's Logger -config :logger, backends: [:console] +config :logger, :default_handler, level: :debug -config :logger, :console, - level: :debug, +config :logger, :default_formatter, format: "\n$time $metadata[$level] $message\n", metadata: [:actor, :path, :type, :user] diff --git a/config/dev.exs b/config/dev.exs index 14cf4a6dc..e86bdb7fd 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -36,7 +36,7 @@ config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Local # different ports. # Do not include timestamps in development logs -config :logger, Logger.Backends.Console, format: "$metadata[$level] $message\n" +config :logger, :default_formatter, format: "$metadata[$level] $message\n" # Set a higher stacktrace during development. Avoid configuring such # in production as building large stacktraces may be expensive. diff --git a/config/prod.exs b/config/prod.exs index 2d252bf02..1dfbbe937 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -20,8 +20,7 @@ config :pleroma, Pleroma.Web.Endpoint, config :phoenix, serve_endpoints: true # Do not print debug messages in production -config :logger, Logger.Backends.Console, level: :info -config :logger, :console, level: :info +config :logger, :default_handler, level: :info config :logger, :ex_syslogger, level: :info # ## SSL Support diff --git a/config/test.exs b/config/test.exs index 9652b7d4b..083759327 100644 --- a/config/test.exs +++ b/config/test.exs @@ -15,9 +15,8 @@ config :pleroma, Pleroma.Captcha, method: Pleroma.Captcha.Mock # Print only warnings and errors during test -config :logger, :console, - level: :warning, - format: "\n[$level] $message\n" +config :logger, :default_handler, level: :warning +config :logger, :default_formatter, format: "\n[$level] $message\n" config :pleroma, :auth, oauth_consumer_strategies: [] From 9450fba83d10fca74e013708bd12406520ef9788 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 14 Jan 2026 13:45:20 +0100 Subject: [PATCH 036/195] Elixir 1.19: Disable colors for logging in test env Color ANSI codes end up in the capture_log output and break existing tests. --- config/test.exs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config/test.exs b/config/test.exs index 083759327..ed5283a26 100644 --- a/config/test.exs +++ b/config/test.exs @@ -16,7 +16,10 @@ config :pleroma, Pleroma.Captcha, # Print only warnings and errors during test config :logger, :default_handler, level: :warning -config :logger, :default_formatter, format: "\n[$level] $message\n" + +config :logger, :default_formatter, + format: "\n[$level] $message\n", + colors: [enabled: false] config :pleroma, :auth, oauth_consumer_strategies: [] From 0f262bb2f3238bf8d999d22e3a4a45bcbc7fc301 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 14 Jan 2026 13:49:31 +0100 Subject: [PATCH 037/195] Elixir 1.19: Replace ugly old logger config checks with config merging Side note: Going up in log levels doesn't work properly. - debug->info works - info->debug does not work --- lib/pleroma/application.ex | 3 + lib/pleroma/config/deprecation_warnings.ex | 88 ++++++------------- .../config/deprecation_warnings_test.exs | 20 ++++- 3 files changed, 51 insertions(+), 60 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 0e8723251..78073b430 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -132,6 +132,9 @@ defmodule Pleroma.Application do {:ok, _} -> Logger.debug("Successfully added logger backend: #{inspect(backend)}") + {:error, :already_present} -> + :ok + {:error, reason} -> Logger.error("Failed to add logger backend #{inspect(backend)}: #{inspect(reason)}") end diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index ee3965b64..4b6281e01 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -20,6 +20,9 @@ defmodule Pleroma.Config.DeprecationWarnings do "\n* `config :pleroma, :instance, mrf_transparency_exclusions` is now `config :pleroma, :mrf, transparency_exclusions`"} ] + @logger_config_knobs [:level, :translator_inspect_opts] + @logger_formatter_config_knows [:colors, :format, :metadata, :truncate, :utc_log] + def check_exiftool_filter do filters = Config.get([Pleroma.Upload]) |> Keyword.get(:filters, []) @@ -417,54 +420,33 @@ defmodule Pleroma.Config.DeprecationWarnings do end end + defp merge_deprecated_logger_config(config) do + handler_config = Enum.filter(config, fn {k, _} -> k in @logger_config_knobs end) + formatter_config = Enum.filter(config, fn {k, _} -> k in @logger_formatter_config_knows end) + formatter = Logger.default_formatter(formatter_config) + + Logger.debug(""" + Reconfiguring console Logger with deprecated configuration syntax. + Handler configuration: + #{inspect(handler_config)} + + Formatter: + #{inspect(formatter)} + """) + + Logger.configure(handler_config) + :logger.update_handler_config(:default, :formatter, formatter) + end + @spec check_deprecated_logger_config() :: :ok | :error def check_deprecated_logger_config do - mix_env = Pleroma.Config.get(:env) - config_file_name = "config/#{mix_env}.secret.exs" - config_file = File.read(config_file_name) - - config_file = - case config_file do - {:ok, contents} -> - String.split(contents, "\n", trim: true) - |> Enum.reject(fn string -> String.starts_with?(string, "#") end) - |> Enum.join("\n") - - _ -> - false - end - - declared_deprecated_backend = - if is_binary(config_file) do - String.contains?(config_file, "config :logger, backends") or - String.contains?(config_file, "config :logger,\n backends") - else - nil - end - - declared_deprecated_console = - if is_binary(config_file) do - String.contains?(config_file, "config :logger, :console") or - String.contains?(config_file, "config :logger,\n console") - else - nil - end - - # We can't use Application.get_env/3, because by default default_formatter is populated - declared_new_console = - if is_binary(config_file) do - String.contains?(config_file, "config :logger, :default_handler") or - String.contains?(config_file, "config :logger,\n :default_handler") or - String.contains?(config_file, "config :logger, :default_formatter") or - String.contains?(config_file, "config :logger,\n :default_formatter") - else - nil - end - - use_new_backend_config = Pleroma.Config.get([:logger, :backends]) + backends_config = Application.get_env(:logger, :backends) + console_config = Application.get_env(:logger, :console) + # Note: No need to merge the old backends config since it still works. + # And new configuration will just add new Logger backends. backend = - if declared_deprecated_backend do + if backends_config do Logger.warning(""" !!!DEPRECATION WARNING!!! Your configuration is using deprecated syntax for configuring backends of Elixir's logger. @@ -483,7 +465,7 @@ defmodule Pleroma.Config.DeprecationWarnings do end console = - if declared_deprecated_console do + if console_config do Logger.warning(""" !!!DEPRECATION WARNING!!! Your configuration is using deprecated syntax for configuring logging to console. @@ -496,25 +478,13 @@ defmodule Pleroma.Config.DeprecationWarnings do `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility """) - true - else - false - end - - conflict = - if (!is_nil(use_new_backend_config) and declared_deprecated_backend) or - (declared_new_console and declared_deprecated_console) do - Logger.warning(""" - !!!CONFIGURATION CONFLICT!!! - Pleroma has detected that both the deprecated way of configuring Logger and the new way are defined in your configuration. - Delete the deprecated configuration syntax. - """) + merge_deprecated_logger_config(console_config) true else false end - if backend or console or conflict, do: :error, else: :ok + if backend or console, do: :error, else: :ok end end diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index cf2c6b4d2..787f9d2fd 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -392,11 +392,12 @@ defmodule Pleroma.Config.DeprecationWarningsTest do describe "check_deprecated_logger_config" do setup do Application.put_env(:logger, :backends, [:console, {ExSyslogger, :ex_syslogger}]) - Application.put_env(:logger, :console, level: :warning) + Application.put_env(:logger, :console, level: :debug) on_exit(fn -> Application.delete_env(:logger, :backends) Application.delete_env(:logger, :console) + :logger.update_handler_config(:default, :formatter, Logger.default_formatter()) end) end @@ -432,5 +433,22 @@ defmodule Pleroma.Config.DeprecationWarningsTest do `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility """ end + + test "reconfigures logger" do + log = + capture_log(fn -> + Pleroma.Config.DeprecationWarnings.check_deprecated_logger_config() + end) + + # Formatter has inconsitent color formatting order + assert log =~ + """ + Reconfiguring console Logger with deprecated configuration syntax. + Handler configuration: + [level: :debug] + + Formatter: + """ + end end end From 02fc770e3638de20592fdaab115a041cda05f952 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 14 Jan 2026 15:02:51 +0100 Subject: [PATCH 038/195] Elixir 1.19: Improve DeprecationWarningsTest Logger reconfiguration test --- .../config/deprecation_warnings_test.exs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index 787f9d2fd..a6ccf063c 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -11,6 +11,8 @@ defmodule Pleroma.Config.DeprecationWarningsTest do alias Pleroma.Config alias Pleroma.Config.DeprecationWarnings + require Logger + describe "filter exiftool" do test "gives warning when still used" do clear_config( @@ -392,11 +394,12 @@ defmodule Pleroma.Config.DeprecationWarningsTest do describe "check_deprecated_logger_config" do setup do Application.put_env(:logger, :backends, [:console, {ExSyslogger, :ex_syslogger}]) - Application.put_env(:logger, :console, level: :debug) + Application.put_env(:logger, :console, level: :info) on_exit(fn -> Application.delete_env(:logger, :backends) Application.delete_env(:logger, :console) + Logger.configure(level: :debug) :logger.update_handler_config(:default, :formatter, Logger.default_formatter()) end) end @@ -435,20 +438,18 @@ defmodule Pleroma.Config.DeprecationWarningsTest do end test "reconfigures logger" do - log = + empty_log = capture_log(fn -> - Pleroma.Config.DeprecationWarnings.check_deprecated_logger_config() + Logger.debug("This should not be logged") end) - # Formatter has inconsitent color formatting order - assert log =~ - """ - Reconfiguring console Logger with deprecated configuration syntax. - Handler configuration: - [level: :debug] + logged = + capture_log(fn -> + Logger.info("This should be logged") + end) - Formatter: - """ + assert empty_log =~ "" + assert logged =~ "This should be logged" end end end From 829a60ce9365ddc69655cc8e88f625dfa17c5e96 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 15 Jan 2026 16:03:44 +0100 Subject: [PATCH 039/195] Elixir 1.19 MRFTest: Replace matchable_regexes with regexes_match! func --- test/pleroma/web/activity_pub/mrf_test.exs | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/pleroma/web/activity_pub/mrf_test.exs b/test/pleroma/web/activity_pub/mrf_test.exs index 6656a01b8..401d4ebb3 100644 --- a/test/pleroma/web/activity_pub/mrf_test.exs +++ b/test/pleroma/web/activity_pub/mrf_test.exs @@ -10,22 +10,25 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do alias Pleroma.Web.ActivityPub.MRF + defp regexes_match!([],[]), do: true + + defp regexes_match!([authority | authority_rest], [checked | checked_rest]) do + authority.source == checked.source and regexes_match!(authority_rest, checked_rest) + end + + defp regexes_match!(_, _), do: false + test "subdomains_regex/1" do regexes = MRF.subdomains_regex(["unsafe.tld", "*.unsafe.tld"]) - matchable_regexes = Enum.map(regexes, fn r -> r.source end) - assert matchable_regexes == [ - ~r/^unsafe.tld$/i.source, - ~r/^(.*\.)*unsafe.tld$/i.source - ] + assert regexes_match!(regexes, [~r/^unsafe.tld$/i, ~r/^(.*\.)*unsafe.tld$/i]) end describe "subdomain_match/2" do test "common domains" do regexes = MRF.subdomains_regex(["unsafe.tld", "unsafe2.tld"]) - matchable_regexes = Enum.map(regexes, fn r -> r.source end) - assert matchable_regexes == [~r/^unsafe.tld$/i.source, ~r/^unsafe2.tld$/i.source] + assert regexes_match!(regexes, [~r/^unsafe.tld$/i, ~r/^unsafe2.tld$/i]) assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "unsafe2.tld") @@ -35,9 +38,8 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do test "wildcard domains with one subdomain" do regexes = MRF.subdomains_regex(["*.unsafe.tld"]) - matchable_regexes = Enum.map(regexes, fn r -> r.source end) - assert matchable_regexes == [~r/^(.*\.)*unsafe.tld$/i.source] + assert regexes_match!(regexes, [~r/^(.*\.)*unsafe.tld$/i]) assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "sub.unsafe.tld") @@ -47,9 +49,8 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do test "wildcard domains with two subdomains" do regexes = MRF.subdomains_regex(["*.unsafe.tld"]) - matchable_regexes = Enum.map(regexes, fn r -> r.source end) - assert matchable_regexes == [~r/^(.*\.)*unsafe.tld$/i.source] + assert regexes_match!(regexes, [~r/^(.*\.)*unsafe.tld$/i]) assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "sub.sub.unsafe.tld") @@ -59,9 +60,8 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do test "matches are case-insensitive" do regexes = MRF.subdomains_regex(["UnSafe.TLD", "UnSAFE2.Tld"]) - matchable_regexes = Enum.map(regexes, fn r -> r.source end) - assert matchable_regexes == [~r/^UnSafe.TLD$/i.source, ~r/^UnSAFE2.Tld$/i.source] + assert regexes_match!(regexes, [~r/^UnSafe.TLD$/i, ~r/^UnSAFE2.Tld$/i]) assert MRF.subdomain_match?(regexes, "UNSAFE.TLD") assert MRF.subdomain_match?(regexes, "UNSAFE2.TLD") From ad7ab7741b1d426554765401c92857500f432c6c Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 15 Jan 2026 16:36:59 +0100 Subject: [PATCH 040/195] Elixir 1.19: Update dependencies in docs Elixir 1.15 support only OTP>=24 --- docs/installation/generic_dependencies.include | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/installation/generic_dependencies.include b/docs/installation/generic_dependencies.include index 588a6c019..382f07a6c 100644 --- a/docs/installation/generic_dependencies.include +++ b/docs/installation/generic_dependencies.include @@ -1,8 +1,8 @@ ## Required dependencies * PostgreSQL >=11.0 -* Elixir >=1.15.0 <1.19 -* Erlang OTP >=23.0.0 (supported: <28) +* Elixir >=1.15.0, <1.20 +* Erlang OTP >=24.0.0 (supported: <29) * git * file / libmagic * gcc or clang From b75f8f7c9addbd73f88ac468473e23aef86b55fa Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 15 Jan 2026 16:56:01 +0100 Subject: [PATCH 041/195] Elixir 1.19: Added changelogs --- changelog.d/elixir-1-19.fix | 1 + changelog.d/logger-backends.change | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog.d/elixir-1-19.fix create mode 100644 changelog.d/logger-backends.change diff --git a/changelog.d/elixir-1-19.fix b/changelog.d/elixir-1-19.fix new file mode 100644 index 000000000..53079b0f7 --- /dev/null +++ b/changelog.d/elixir-1-19.fix @@ -0,0 +1 @@ +Fixed Elixir 1.19 warnings diff --git a/changelog.d/logger-backends.change b/changelog.d/logger-backends.change new file mode 100644 index 000000000..5f044c2c7 --- /dev/null +++ b/changelog.d/logger-backends.change @@ -0,0 +1 @@ +Changed how Logger is configured due to changes in Elixir and added deprecation warnings for the old configuration format From 0ac1b65ba95b9bc3382494a84432bb8ecf088771 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 21 Jan 2026 18:39:15 +0100 Subject: [PATCH 042/195] Elixir 1.19: Fix improper deprecation warning wording --- lib/pleroma/config/deprecation_warnings.ex | 4 +--- test/pleroma/config/deprecation_warnings_test.exs | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 4b6281e01..4f68c141c 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -452,8 +452,7 @@ defmodule Pleroma.Config.DeprecationWarnings do Your configuration is using deprecated syntax for configuring backends of Elixir's logger. `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir. Please update your configuration at your earliest convenience to use: - `config :logger, backends: [...]` - Pleroma's default configuration will be updated in the next release which WILL override your settings. + `config :pleroma, :logger, backends: [...]` Note: `:console` is no longer considered a backend and is used by default, you can disable it using: `config :logger, :default_handler: false` @@ -472,7 +471,6 @@ defmodule Pleroma.Config.DeprecationWarnings do `config :logger, :console` is deprecated syntax due to changes in Elixir. Please update your configuration at your earliest convenience to use `config :logger, default_handler` and `config :logger, :default_formatter`. - Pleroma's default configuration will be updated in the next release which WILL override your settings. Note: `:default_handler` is used only for the `level` setting. All other configurations go under `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index a6ccf063c..221609da0 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -416,8 +416,7 @@ defmodule Pleroma.Config.DeprecationWarningsTest do Your configuration is using deprecated syntax for configuring backends of Elixir's logger. `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir. Please update your configuration at your earliest convenience to use: - `config :logger, backends: [...]` - Pleroma's default configuration will be updated in the next release which WILL override your settings. + `config :pleroma, :logger, backends: [...]` Note: `:console` is no longer considered a backend and is used by default, you can disable it using: `config :logger, :default_handler: false` @@ -430,7 +429,6 @@ defmodule Pleroma.Config.DeprecationWarningsTest do `config :logger, :console` is deprecated syntax due to changes in Elixir. Please update your configuration at your earliest convenience to use `config :logger, default_handler` and `config :logger, :default_formatter`. - Pleroma's default configuration will be updated in the next release which WILL override your settings. Note: `:default_handler` is used only for the `level` setting. All other configurations go under `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility From 1743a837dd31e7756a7e3293a170234b2d94fc57 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 21 Jan 2026 20:02:26 +0100 Subject: [PATCH 043/195] Elixir 1.19: Set merged logger config correctly, remove log level test --- lib/pleroma/config/deprecation_warnings.ex | 29 +++++++++--------- .../config/deprecation_warnings_test.exs | 30 +++++-------------- 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 4f68c141c..e77785483 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -20,8 +20,7 @@ defmodule Pleroma.Config.DeprecationWarnings do "\n* `config :pleroma, :instance, mrf_transparency_exclusions` is now `config :pleroma, :mrf, transparency_exclusions`"} ] - @logger_config_knobs [:level, :translator_inspect_opts] - @logger_formatter_config_knows [:colors, :format, :metadata, :truncate, :utc_log] + @logger_formatter_config_knobs [:colors, :format, :metadata, :truncate, :utc_log] def check_exiftool_filter do filters = Config.get([Pleroma.Upload]) |> Keyword.get(:filters, []) @@ -421,21 +420,23 @@ defmodule Pleroma.Config.DeprecationWarnings do end defp merge_deprecated_logger_config(config) do - handler_config = Enum.filter(config, fn {k, _} -> k in @logger_config_knobs end) - formatter_config = Enum.filter(config, fn {k, _} -> k in @logger_formatter_config_knows end) + formatter_config = Enum.filter(config, fn {k, _} -> k in @logger_formatter_config_knobs end) formatter = Logger.default_formatter(formatter_config) + log_level = Keyword.get(config, :level, nil) - Logger.debug(""" - Reconfiguring console Logger with deprecated configuration syntax. - Handler configuration: - #{inspect(handler_config)} + if Config.get(:env) != :test do + Logger.debug(""" + Reconfiguring console Logger with deprecated configuration syntax. + Handler configuration: + log_level: #{inspect(log_level)} - Formatter: - #{inspect(formatter)} - """) + Formatter: + #{inspect(formatter)} + """) - Logger.configure(handler_config) - :logger.update_handler_config(:default, :formatter, formatter) + if log_level, do: :logger.update_handler_config(:default, :level, log_level) + :logger.update_handler_config(:default, :formatter, formatter) + end end @spec check_deprecated_logger_config() :: :ok | :error @@ -443,7 +444,7 @@ defmodule Pleroma.Config.DeprecationWarnings do backends_config = Application.get_env(:logger, :backends) console_config = Application.get_env(:logger, :console) - # Note: No need to merge the old backends config since it still works. + # NOTE: No need to merge the old backends config since it still works. # And new configuration will just add new Logger backends. backend = if backends_config do diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index 221609da0..475a88c4d 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Config.DeprecationWarningsTest do - use ExUnit.Case + use ExUnit.Case, async: false use Pleroma.Tests.Helpers import ExUnit.CaptureLog @@ -393,14 +393,15 @@ defmodule Pleroma.Config.DeprecationWarningsTest do describe "check_deprecated_logger_config" do setup do - Application.put_env(:logger, :backends, [:console, {ExSyslogger, :ex_syslogger}]) - Application.put_env(:logger, :console, level: :info) + initial_console = Application.get_env(:logger, :console, nil) + initial_backends = Application.get_env(:logger, :backends, nil) + + Application.put_env(:logger, :console, level: :all) + Application.put_env(:logger, :backends, [:console]) on_exit(fn -> - Application.delete_env(:logger, :backends) - Application.delete_env(:logger, :console) - Logger.configure(level: :debug) - :logger.update_handler_config(:default, :formatter, Logger.default_formatter()) + if initial_console, do: Application.put_env(:logger, :console, initial_console), else: Application.delete_env(:logger, :console) + if initial_backends, do: Application.put_env(:logger, :backends, initial_backends), else: Application.delete_env(:logger, :backends) end) end @@ -434,20 +435,5 @@ defmodule Pleroma.Config.DeprecationWarningsTest do `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility """ end - - test "reconfigures logger" do - empty_log = - capture_log(fn -> - Logger.debug("This should not be logged") - end) - - logged = - capture_log(fn -> - Logger.info("This should be logged") - end) - - assert empty_log =~ "" - assert logged =~ "This should be logged" - end end end From f44774d387c88ce6289ff691a4aa5662c0966964 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 21 Jan 2026 21:13:23 +0100 Subject: [PATCH 044/195] lint --- test/pleroma/config/deprecation_warnings_test.exs | 13 +++++++++++-- test/pleroma/web/activity_pub/mrf_test.exs | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index 475a88c4d..72dd63100 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -400,8 +400,17 @@ defmodule Pleroma.Config.DeprecationWarningsTest do Application.put_env(:logger, :backends, [:console]) on_exit(fn -> - if initial_console, do: Application.put_env(:logger, :console, initial_console), else: Application.delete_env(:logger, :console) - if initial_backends, do: Application.put_env(:logger, :backends, initial_backends), else: Application.delete_env(:logger, :backends) + if initial_console do + Application.put_env(:logger, :console, initial_console) + else + Application.delete_env(:logger, :console) + end + + if initial_backends do + Application.put_env(:logger, :backends, initial_backends) + else + Application.delete_env(:logger, :backends) + end end) end diff --git a/test/pleroma/web/activity_pub/mrf_test.exs b/test/pleroma/web/activity_pub/mrf_test.exs index 401d4ebb3..2d0f3b317 100644 --- a/test/pleroma/web/activity_pub/mrf_test.exs +++ b/test/pleroma/web/activity_pub/mrf_test.exs @@ -10,7 +10,7 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do alias Pleroma.Web.ActivityPub.MRF - defp regexes_match!([],[]), do: true + defp regexes_match!([], []), do: true defp regexes_match!([authority | authority_rest], [checked | checked_rest]) do authority.source == checked.source and regexes_match!(authority_rest, checked_rest) From a12f9777f857002318a6d6da17473bb6a52276b7 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 12 Feb 2026 23:46:08 +0100 Subject: [PATCH 045/195] Elixir 1.19: Bump Woodpecker test pipeline to 1.19 --- .woodpecker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 4ceb1cab5..f6b06dc61 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -4,7 +4,7 @@ when: steps: test: - image: elixir:1.15-alpine + image: elixir:1.19-alpine environment: MIX_ENV: test DB_HOST: postgres From 01b139c6331977f5d622866127be9b4291cd4732 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sun, 22 Feb 2026 21:50:28 +0100 Subject: [PATCH 046/195] Elixir 1.19: Add note to deprecation warnings test The Logger does not get reconfigured when running in the test env, the added and on_exit removed settings below should have no effect besides triggering the deprecation warnings. To make breakage even less possible, async is turned off for these tests. TransferTask test does almost the same with the Application env. --- test/pleroma/config/deprecation_warnings_test.exs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index 72dd63100..b65b0e630 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -396,6 +396,9 @@ defmodule Pleroma.Config.DeprecationWarningsTest do initial_console = Application.get_env(:logger, :console, nil) initial_backends = Application.get_env(:logger, :backends, nil) + # NOTE: The Logger does not get reconfigured when running in the test env, + # the added and on_exit removed settings below should have no effect besides + # triggering the deprecation warnings Application.put_env(:logger, :console, level: :all) Application.put_env(:logger, :backends, [:console]) From 08ba0d46d77cbb19f218b464a1cbeb700227e6a8 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sun, 22 Feb 2026 22:27:48 +0100 Subject: [PATCH 047/195] ActivityDraft: Add missing __MODULE__ matches and drop unneeded ones --- lib/pleroma/web/common_api/activity_draft.ex | 48 ++++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index 6c84ef656..16489663a 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -99,34 +99,34 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do |> Map.put("cc", draft.cc) |> Map.put("actor", draft.user.ap_id) - %__MODULE__{draft | object: object} + %{draft | object: object} end defp put_params(%__MODULE__{} = draft, params) do params = Map.put_new(params, :in_reply_to_status_id, params[:in_reply_to_id]) - %__MODULE__{draft | params: params} + %{draft | params: params} end defp status(%__MODULE__{params: %{status: status}} = draft) do - %__MODULE__{draft | status: String.trim(status)} + %{draft | status: String.trim(status)} end defp summary(%__MODULE__{params: params} = draft) do - %__MODULE__{draft | summary: Map.get(params, :spoiler_text, "")} + %{draft | summary: Map.get(params, :spoiler_text, "")} end defp full_payload(%__MODULE__{status: status, summary: summary} = draft) do full_payload = String.trim(status <> summary) case Utils.validate_character_limit(full_payload, draft.attachments) do - :ok -> %__MODULE__{draft | full_payload: full_payload} + :ok -> %{draft | full_payload: full_payload} {:error, message} -> add_error(draft, message) end end defp attachments(%__MODULE__{params: params} = draft) do attachments = Utils.attachments_from_ids(params, draft.user) - draft = %__MODULE__{draft | attachments: attachments} + draft = %{draft | attachments: attachments} case Utils.validate_attachments_count(attachments) do :ok -> draft @@ -149,7 +149,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do with %Activity{} = activity <- Activity.get_by_id(id), true <- Visibility.visible_for_user?(activity, draft.user), {_, type} when type in ["Create", "Announce"] <- {:type, activity.data["type"]} do - %__MODULE__{draft | in_reply_to: activity} + %{draft | in_reply_to: activity} else nil -> add_error(draft, dgettext("errors", "Cannot reply to a deleted status")) @@ -170,7 +170,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do defp in_reply_to( %__MODULE__{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft ) do - %__MODULE__{draft | in_reply_to: in_reply_to} + %{draft | in_reply_to: in_reply_to} end defp in_reply_to(draft), do: draft @@ -179,14 +179,14 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do when not_empty_string(id) do case Activity.get_by_id_with_object(id) do %Activity{} = activity -> - %__MODULE__{draft | quote_post: activity} + %{draft | quote_post: activity} _ -> draft end end - defp quote_post(%{params: %{quote_id: id}} = draft) when not_empty_string(id) do + defp quote_post(%__MODULE__{params: %{quote_id: id}} = draft) when not_empty_string(id) do quote_post(%{draft | params: Map.put(draft.params, :quoted_status_id, id)}) end @@ -194,7 +194,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do defp in_reply_to_conversation(%__MODULE__{} = draft) do in_reply_to_conversation = Participation.get(draft.params[:in_reply_to_conversation_id]) - %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation} + %{draft | in_reply_to_conversation: in_reply_to_conversation} end defp visibility(%__MODULE__{params: params} = draft) do @@ -203,7 +203,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do add_error(draft, dgettext("errors", "The message visibility must be direct")) {visibility, _} -> - %__MODULE__{draft | visibility: visibility} + %{draft | visibility: visibility} end end @@ -219,7 +219,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do false end - defp quoting_visibility(%{quote_post: %Activity{}} = draft) do + defp quoting_visibility(%__MODULE__{quote_post: %Activity{}} = draft) do with %Object{} = object <- Object.normalize(draft.quote_post, fetch: false), true <- can_quote?(draft, object, Visibility.get_visibility(object)) do draft @@ -232,7 +232,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do defp expires_at(%__MODULE__{} = draft) do case CommonAPI.check_expiry_date(draft.params[:expires_in]) do - {:ok, expires_at} -> %__MODULE__{draft | expires_at: expires_at} + {:ok, expires_at} -> %{draft | expires_at: expires_at} {:error, message} -> add_error(draft, message) end end @@ -240,7 +240,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do defp poll(%__MODULE__{} = draft) do case Utils.make_poll_data(draft.params) do {:ok, {poll, poll_emoji}} -> - %__MODULE__{draft | extra: poll, emoji: Map.merge(draft.emoji, poll_emoji)} + %{draft | extra: poll, emoji: Map.merge(draft.emoji, poll_emoji)} {:error, message} -> add_error(draft, message) @@ -258,22 +258,22 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do |> Kernel.++(mentioned_ap_ids) |> Utils.get_addressed_users(draft.params[:to]) - %__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags} + %{draft | content_html: content_html, mentions: mentions, tags: tags} end defp to_and_cc(%__MODULE__{} = draft) do {to, cc} = Utils.get_to_and_cc(draft) - %__MODULE__{draft | to: to, cc: cc} + %{draft | to: to, cc: cc} end defp context(%__MODULE__{} = draft) do context = Utils.make_context(draft.in_reply_to, draft.in_reply_to_conversation) - %__MODULE__{draft | context: context} + %{draft | context: context} end defp sensitive(%__MODULE__{} = draft) do sensitive = draft.params[:sensitive] - %__MODULE__{draft | sensitive: sensitive} + %{draft | sensitive: sensitive} end defp language(%__MODULE__{} = draft) do @@ -285,7 +285,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do _ -> LanguageDetector.detect(draft.content_html <> " " <> draft.summary) end - %__MODULE__{draft | language: language} + %{draft | language: language} end defp object(%__MODULE__{} = draft) do @@ -329,12 +329,12 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do |> Map.put("generator", draft.params[:generator]) |> Map.put("language", draft.language) - %__MODULE__{draft | object: object} + %{draft | object: object} end defp preview?(%__MODULE__{} = draft) do preview? = Pleroma.Web.Utils.Params.truthy_param?(draft.params[:preview]) - %__MODULE__{draft | preview?: preview?} + %{draft | preview?: preview?} end defp changes(%__MODULE__{} = draft) do @@ -357,14 +357,14 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do } |> Utils.maybe_add_list_data(draft.user, draft.visibility) - %__MODULE__{draft | changes: changes} + %{draft | changes: changes} end defp with_valid(%{valid?: true} = draft, func), do: func.(draft) defp with_valid(draft, _func), do: draft defp add_error(%__MODULE__{} = draft, message) do - %__MODULE__{draft | valid?: false, errors: [message | draft.errors]} + %{draft | valid?: false, errors: [message | draft.errors]} end defp validate(%{valid?: true} = draft), do: {:ok, draft} From af175fbdfc589be7f80adcb9613cfa3d67a217a8 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sun, 3 May 2026 21:19:24 +0400 Subject: [PATCH 048/195] Woodpecker CI: Build armv7 Docker images --- .woodpecker/docker-combine.yaml | 2 +- .woodpecker/docker.yaml | 47 +++++++++++++++++++++++++++++++++ Dockerfile | 6 +++-- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/.woodpecker/docker-combine.yaml b/.woodpecker/docker-combine.yaml index 6c91d26e9..9b9f89fdb 100644 --- a/.woodpecker/docker-combine.yaml +++ b/.woodpecker/docker-combine.yaml @@ -27,7 +27,7 @@ steps: settings: &docker_settings registry: "git.pleroma.social" image: "pleroma/pleroma" - architectures: [amd64, arm64] + architectures: [amd64, arm64, armv7] tags: - latest - develop diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml index abc6bfa3b..5f174fb99 100644 --- a/.woodpecker/docker.yaml +++ b/.woodpecker/docker.yaml @@ -11,6 +11,7 @@ when: matrix: platform: - linux/amd64 + - linux/arm - linux/arm64 # This is needed for the when clauses below. @@ -27,6 +28,13 @@ variables: password: from_secret: pleroma-ci-password kaniko_image: &kaniko_image woodpeckerci/plugin-kaniko:2.3.1 + # HexPM Elixir images are not published for arm/v7. + armv7_build_args: &armv7_build_args + - ELIXIR_IMG=arm32v7/elixir + - ELIXIR_TAG=1.17.3-alpine + - ALPINE_IMG=arm32v7/alpine + - ALPINE_VER=3.22 + armv7_extra_opts: &armv7_extra_opts --custom-platform=linux/arm/v7 steps: docker-develop-amd64: @@ -51,6 +59,19 @@ steps: - develop-arm64 - ${CI_COMMIT_SHA:0:8}-arm64 + docker-develop-armv7: + image: *kaniko_image + when: + - evaluate: 'platform == "linux/arm" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + settings: + <<: *docker_variables + build_args: *armv7_build_args + extra_opts: *armv7_extra_opts + tags: + - latest-armv7 + - develop-armv7 + - ${CI_COMMIT_SHA:0:8}-armv7 + docker-stable-amd64: image: *kaniko_image when: @@ -84,6 +105,19 @@ steps: - stable-arm64 - ${CI_COMMIT_SHA:0:8}-arm64 + docker-stable-armv7: + image: *kaniko_image + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""' + settings: + <<: *docker_variables + build_args: *armv7_build_args + extra_opts: *armv7_extra_opts + tags: &armv7_tags + - latest-armv7 + - stable-armv7 + - ${CI_COMMIT_SHA:0:8}-armv7 + docker-stable-tag-arm64: image: *kaniko_image when: @@ -94,3 +128,16 @@ steps: tags: - <<: *arm64_tags - ${CI_COMMIT_TAG}-arm64 + + docker-stable-tag-armv7: + image: *kaniko_image + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "tag"' + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG != ""' + settings: + <<: *docker_variables + build_args: *armv7_build_args + extra_opts: *armv7_extra_opts + tags: + - <<: *armv7_tags + - ${CI_COMMIT_TAG}-armv7 diff --git a/Dockerfile b/Dockerfile index 11a17b7ad..5fd1cc0a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,10 @@ ARG ELIXIR_IMG=hexpm/elixir ARG ELIXIR_VER=1.17.3 ARG ERLANG_VER=26.2.5.6 ARG ALPINE_VER=3.17.9 +ARG ELIXIR_TAG=${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER} +ARG ALPINE_IMG=alpine -FROM ${ELIXIR_IMG}:${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER} AS build +FROM ${ELIXIR_IMG}:${ELIXIR_TAG} AS build COPY . . @@ -20,7 +22,7 @@ RUN apk add git gcc g++ musl-dev make cmake file-dev vips-dev &&\ mkdir release &&\ mix release --path release -FROM alpine:${ALPINE_VER} +FROM ${ALPINE_IMG}:${ALPINE_VER} ARG BUILD_DATE ARG VCS_REF From 684e9ef24702bf2c509d42445fc665ceb91070d4 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 4 May 2026 09:41:06 +0400 Subject: [PATCH 049/195] Woodpecker CI: Isolate armv7 Docker builds --- .woodpecker/docker-armv7.yaml | 84 +++++++++++++++++++++++++++++++++ .woodpecker/docker-combine.yaml | 2 +- .woodpecker/docker.yaml | 47 ------------------ 3 files changed, 85 insertions(+), 48 deletions(-) create mode 100644 .woodpecker/docker-armv7.yaml diff --git a/.woodpecker/docker-armv7.yaml b/.woodpecker/docker-armv7.yaml new file mode 100644 index 000000000..19f3049b3 --- /dev/null +++ b/.woodpecker/docker-armv7.yaml @@ -0,0 +1,84 @@ +when: + # Temporary PR validation while arm/v7 Docker builds are being brought up. + - event: pull_request + path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**", "Dockerfile" ] + - event: push + branch: ${CI_REPO_DEFAULT_BRANCH} + path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**", "Dockerfile" ] + - event: tag + - event: manual + branch: ${CI_REPO_DEFAULT_BRANCH} + - event: manual + branch: stable + +# Target the CAX21 arm64 host with native arm32 userspace support, not generic arm64 runners. +labels: + armv7: native + +variables: + docker_variables: &docker_variables + repo: pleroma/pleroma + registry: git.pleroma.social + armv7_build_settings: &armv7_build_settings + <<: *docker_variables + build_args: + - ELIXIR_IMG=arm32v7/elixir + - ELIXIR_TAG=1.17.3-otp-26-alpine + - ALPINE_IMG=arm32v7/alpine + - ALPINE_VER=3.20 + extra_opts: --custom-platform=linux/arm/v7 + armv7_push_settings: &armv7_push_settings + <<: *armv7_build_settings + username: + from_secret: pleroma-ci-user + password: + from_secret: pleroma-ci-password + +steps: + docker-armv7-pr: + image: woodpeckerci/plugin-kaniko:2.3.1 + when: + - event: pull_request + settings: + <<: *armv7_build_settings + dry_run: true + tags: + - armv7-pr-check + + docker-develop-armv7: + image: woodpeckerci/plugin-kaniko:2.3.1 + when: + - event: push + branch: ${CI_REPO_DEFAULT_BRANCH} + - event: manual + branch: ${CI_REPO_DEFAULT_BRANCH} + settings: + <<: *armv7_push_settings + tags: + - latest-armv7 + - develop-armv7 + - ${CI_COMMIT_SHA:0:8}-armv7 + + docker-stable-armv7: + image: woodpeckerci/plugin-kaniko:2.3.1 + when: + - evaluate: 'CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""' + settings: + <<: *armv7_push_settings + tags: + - latest-armv7 + - stable-armv7 + - ${CI_COMMIT_SHA:0:8}-armv7 + + docker-stable-tag-armv7: + image: woodpeckerci/plugin-kaniko:2.3.1 + when: + - event: tag + - evaluate: 'CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG != ""' + settings: + <<: *armv7_push_settings + tags: + - latest-armv7 + - stable-armv7 + - ${CI_COMMIT_SHA:0:8}-armv7 + - ${CI_COMMIT_TAG}-armv7 diff --git a/.woodpecker/docker-combine.yaml b/.woodpecker/docker-combine.yaml index 9b9f89fdb..6c91d26e9 100644 --- a/.woodpecker/docker-combine.yaml +++ b/.woodpecker/docker-combine.yaml @@ -27,7 +27,7 @@ steps: settings: &docker_settings registry: "git.pleroma.social" image: "pleroma/pleroma" - architectures: [amd64, arm64, armv7] + architectures: [amd64, arm64] tags: - latest - develop diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml index 5f174fb99..abc6bfa3b 100644 --- a/.woodpecker/docker.yaml +++ b/.woodpecker/docker.yaml @@ -11,7 +11,6 @@ when: matrix: platform: - linux/amd64 - - linux/arm - linux/arm64 # This is needed for the when clauses below. @@ -28,13 +27,6 @@ variables: password: from_secret: pleroma-ci-password kaniko_image: &kaniko_image woodpeckerci/plugin-kaniko:2.3.1 - # HexPM Elixir images are not published for arm/v7. - armv7_build_args: &armv7_build_args - - ELIXIR_IMG=arm32v7/elixir - - ELIXIR_TAG=1.17.3-alpine - - ALPINE_IMG=arm32v7/alpine - - ALPINE_VER=3.22 - armv7_extra_opts: &armv7_extra_opts --custom-platform=linux/arm/v7 steps: docker-develop-amd64: @@ -59,19 +51,6 @@ steps: - develop-arm64 - ${CI_COMMIT_SHA:0:8}-arm64 - docker-develop-armv7: - image: *kaniko_image - when: - - evaluate: 'platform == "linux/arm" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' - settings: - <<: *docker_variables - build_args: *armv7_build_args - extra_opts: *armv7_extra_opts - tags: - - latest-armv7 - - develop-armv7 - - ${CI_COMMIT_SHA:0:8}-armv7 - docker-stable-amd64: image: *kaniko_image when: @@ -105,19 +84,6 @@ steps: - stable-arm64 - ${CI_COMMIT_SHA:0:8}-arm64 - docker-stable-armv7: - image: *kaniko_image - when: - - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""' - settings: - <<: *docker_variables - build_args: *armv7_build_args - extra_opts: *armv7_extra_opts - tags: &armv7_tags - - latest-armv7 - - stable-armv7 - - ${CI_COMMIT_SHA:0:8}-armv7 - docker-stable-tag-arm64: image: *kaniko_image when: @@ -128,16 +94,3 @@ steps: tags: - <<: *arm64_tags - ${CI_COMMIT_TAG}-arm64 - - docker-stable-tag-armv7: - image: *kaniko_image - when: - - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "tag"' - - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG != ""' - settings: - <<: *docker_variables - build_args: *armv7_build_args - extra_opts: *armv7_extra_opts - tags: - - <<: *armv7_tags - - ${CI_COMMIT_TAG}-armv7 From 394db0dce0c6ca3e55f2039cca35b1bb8e651875 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 4 May 2026 10:09:44 +0400 Subject: [PATCH 050/195] Woodpecker CI: Target armv7 Docker runner --- .woodpecker/docker-armv7.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.woodpecker/docker-armv7.yaml b/.woodpecker/docker-armv7.yaml index 19f3049b3..c921a56f9 100644 --- a/.woodpecker/docker-armv7.yaml +++ b/.woodpecker/docker-armv7.yaml @@ -13,6 +13,7 @@ when: # Target the CAX21 arm64 host with native arm32 userspace support, not generic arm64 runners. labels: + platform: linux/arm armv7: native variables: From 3c63877e618468834a4ff31374396723dc8ba313 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 5 May 2026 21:04:13 +0200 Subject: [PATCH 051/195] Add custom Docker image for FE E2E tests Woodpecker cannot do custom commands for services. --- .woodpecker/docker.yaml | 12 ++++++ Dockerfile-e2e | 61 +++++++++++++++++++++++++++ changelog.d/docker-e2e-image.skip | 0 docker-entrypoint-e2e.sh | 70 +++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 Dockerfile-e2e create mode 100644 changelog.d/docker-e2e-image.skip create mode 100755 docker-entrypoint-e2e.sh diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml index abc6bfa3b..9a5be78e2 100644 --- a/.woodpecker/docker.yaml +++ b/.woodpecker/docker.yaml @@ -62,6 +62,18 @@ steps: - stable-amd64 - ${CI_COMMIT_SHA:0:8}-amd64 + # FE workflow runs only on linux/amd64 + docker-stable-e2e-amd64: + image: *kaniko_image + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "tag"' + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable"' + settings: + <<: *docker_variables + dockerfile: Dockerfile-e2e + tags: + - stable-e2e + docker-stable-tag-amd64: image: *kaniko_image when: diff --git a/Dockerfile-e2e b/Dockerfile-e2e new file mode 100644 index 000000000..df8bb39aa --- /dev/null +++ b/Dockerfile-e2e @@ -0,0 +1,61 @@ +# https://hub.docker.com/r/hexpm/elixir/tags +ARG ELIXIR_IMG=docker.io/hexpm/elixir +ARG ALPINE_IMG=docker.io/alpine +ARG ELIXIR_VER=1.18.4 +ARG ERLANG_VER=27.3.4.11 +ARG ALPINE_VER=3.23.4 + +FROM ${ELIXIR_IMG}:${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER} AS build + +COPY . . + +ENV MIX_ENV=prod +ENV VIX_COMPILATION_MODE=PLATFORM_PROVIDED_LIBVIPS + +RUN apk add git gcc g++ musl-dev make cmake file-dev vips-dev &&\ + echo "import Config" > config/prod.secret.exs &&\ + mix local.hex --force &&\ + mix local.rebar --force &&\ + mix deps.clean --all &&\ + mix deps.get --only prod &&\ + mkdir release &&\ + mix release --path release + +FROM ${ALPINE_IMG}:${ALPINE_VER} + +ARG BUILD_DATE +ARG VCS_REF + +LABEL maintainer="ops@pleroma.social" \ + org.opencontainers.image.title="pleroma" \ + org.opencontainers.image.description="Pleroma FE E2E test image" \ + org.opencontainers.image.authors="ops@pleroma.social" \ + org.opencontainers.image.vendor="pleroma.social" \ + org.opencontainers.image.documentation="https://git.pleroma.social/pleroma/pleroma" \ + org.opencontainers.image.licenses="AGPL-3.0" \ + org.opencontainers.image.url="https://pleroma.social" \ + org.opencontainers.image.revision=$VCS_REF \ + org.opencontainers.image.created=$BUILD_DATE + +ARG HOME=/opt/pleroma +ARG DATA=/var/lib/pleroma + +RUN apk update &&\ + apk add exiftool ffmpeg vips libmagic ncurses postgresql-client &&\ + adduser --system --shell /bin/false --home ${HOME} pleroma &&\ + mkdir -p ${DATA}/uploads &&\ + mkdir -p ${DATA}/static &&\ + chown -R pleroma ${DATA} &&\ + mkdir -p /etc/pleroma &&\ + chown -R pleroma /etc/pleroma + +USER pleroma + +COPY --from=build --chown=pleroma:0 /release ${HOME} + +COPY --chown=pleroma --chmod=640 ./config/docker.exs /etc/pleroma/config.exs +COPY ./docker-entrypoint-e2e.sh ${HOME} + +EXPOSE 4000 + +ENTRYPOINT ["/opt/pleroma/docker-entrypoint-e2e.sh"] diff --git a/changelog.d/docker-e2e-image.skip b/changelog.d/docker-e2e-image.skip new file mode 100644 index 000000000..e69de29bb diff --git a/docker-entrypoint-e2e.sh b/docker-entrypoint-e2e.sh new file mode 100755 index 000000000..c83c44a80 --- /dev/null +++ b/docker-entrypoint-e2e.sh @@ -0,0 +1,70 @@ +#!/bin/ash +set -eu + +SEED_SENTINEL_PATH=/var/lib/pleroma/.e2e_seeded +CONFIG_OVERRIDE_PATH=/var/lib/pleroma/config.exs + +echo '-- Waiting for database...' +while ! pg_isready -U ${DB_USER:-pleroma} -d postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma} -t 1; do + sleep 1s +done + +echo '-- Writing E2E config overrides...' +cat > $CONFIG_OVERRIDE_PATH </dev/null; then + kill -TERM $PLEROMA_PID + wait $PLEROMA_PID || true + fi +} + +trap cleanup INT TERM + +echo '-- Waiting for API...' +api_ok=false +for _i in $(seq 1 120); do + if wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null 2>&1; then + api_ok=true + break + fi + sleep 1s +done + +if [ $api_ok != true ]; then + echo 'Timed out waiting for Pleroma API to become available' + exit 1 +fi + +if [ ! -f $SEED_SENTINEL_PATH ]; then + if [ -n ${E2E_ADMIN_USERNAME:-} ] && [ -n ${E2E_ADMIN_PASSWORD:-} ] && [ -n ${E2E_ADMIN_EMAIL:-} ]; then + echo '-- Seeding admin user' $E2E_ADMIN_USERNAME '...' + if ! /opt/pleroma/bin/pleroma_ctl user new $E2E_ADMIN_USERNAME $E2E_ADMIN_EMAIL --admin --password $E2E_ADMIN_PASSWORD -y; then + echo '-- User already exists or creation failed, ensuring admin + confirmed...' + /opt/pleroma/bin/pleroma_ctl user set $E2E_ADMIN_USERNAME --admin --confirmed + fi + else + echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)' + fi + + touch $SEED_SENTINEL_PATH +fi + +wait $PLEROMA_PID From ab9fd33762cb8a0cada9e526296d1e96f039b10b Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 11 May 2026 22:10:08 +0400 Subject: [PATCH 052/195] Fix Phoenix upstream migration regressions --- lib/pleroma/web.ex | 9 ++++++++ .../web/mastodon_api/websocket_handler.ex | 22 ++++++++++++------- .../integration/mastodon_websocket_test.exs | 20 +++++++++++++++++ .../static_fe/static_fe_controller_test.exs | 9 ++++++++ 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web.ex b/lib/pleroma/web.ex index bf7fa86a3..70e7f6458 100644 --- a/lib/pleroma/web.ex +++ b/lib/pleroma/web.ex @@ -39,6 +39,15 @@ defmodule Pleroma.Web do alias Pleroma.Web.Router.Helpers, as: Routes + plug(:set_put_layout) + + defp set_put_layout(conn, _) do + case Pleroma.Config.get(:app_layout, "app.html") do + false -> put_layout(conn, false) + layout -> put_layout(conn, {Pleroma.Web.LayoutView, layout}) + end + end + # Marks plugs intentionally skipped and blocks their execution if present in plugs chain defp skip_plug(conn, plug_modules) do plug_modules diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 616f35912..2b698bd5d 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -245,14 +245,20 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do Plug.Conn.send_resp(conn, 404, "Not Found") end - defp find_access_token(%{params: %{"access_token" => token}}), do: token + defp find_access_token(%{connect_info: %{sec_websocket_headers: sec_headers}} = transport_info) do + find_sec_websocket_protocol(sec_headers) || find_access_token_from_params(transport_info) + end - defp find_access_token(%{connect_info: %{sec_websocket_headers: sec_headers}}), - do: - Enum.find_value(sec_headers, fn - {"sec-websocket-protocol", v} -> v - _ -> nil - end) + defp find_access_token(transport_info), do: find_access_token_from_params(transport_info) - defp find_access_token(_), do: nil + defp find_sec_websocket_protocol(sec_headers) do + Enum.find_value(sec_headers, fn + {"sec-websocket-protocol", token} -> token + _ -> nil + end) + end + + defp find_access_token_from_params(%{params: %{"access_token" => token}}), do: token + + defp find_access_token_from_params(_), do: nil end diff --git a/test/pleroma/integration/mastodon_websocket_test.exs b/test/pleroma/integration/mastodon_websocket_test.exs index 47f6f5f76..604382be2 100644 --- a/test/pleroma/integration/mastodon_websocket_test.exs +++ b/test/pleroma/integration/mastodon_websocket_test.exs @@ -279,6 +279,26 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do end) end + test "accepts Phoenix 1.8 sec-websocket headers connect info before query params", %{ + token: token, + user: user + } do + assert {:ok, state} = + Pleroma.Web.MastodonAPI.WebsocketHandler.connect(%{ + params: %{"stream" => "user", "access_token" => "invalid"}, + connect_info: %{ + sec_websocket_headers: [ + {"sec-websocket-version", "13"}, + {"sec-websocket-protocol", token.token} + ] + } + }) + + assert state.user.id == user.id + assert state.oauth_token.id == token.id + assert state.topics != [] + end + test "accepts valid token on client-sent event", %{token: token} do assert {:ok, pid} = start_socket() diff --git a/test/pleroma/web/static_fe/static_fe_controller_test.exs b/test/pleroma/web/static_fe/static_fe_controller_test.exs index 2fae83305..68ded6906 100644 --- a/test/pleroma/web/static_fe/static_fe_controller_test.exs +++ b/test/pleroma/web/static_fe/static_fe_controller_test.exs @@ -28,6 +28,15 @@ defmodule Pleroma.Web.StaticFE.StaticFEControllerTest do assert html_response(conn, 200) =~ user.nickname end + test "renders profile HTML inside the default app layout", %{conn: conn, user: user} do + conn = get(conn, "/users/#{user.nickname}") + + html = html_response(conn, 200) + assert html =~ "" + assert html =~ ~s(class="instance-header") + assert html =~ ~s() + end + test "404 when user not found", %{conn: conn} do conn = get(conn, "/users/limpopo") From 8a56cf5c0fe58904ad2e6ae03ee7cddf7d2adc09 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 11 May 2026 22:19:06 +0400 Subject: [PATCH 053/195] Clarify websocket token precedence test --- test/pleroma/integration/mastodon_websocket_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pleroma/integration/mastodon_websocket_test.exs b/test/pleroma/integration/mastodon_websocket_test.exs index 604382be2..078bb643c 100644 --- a/test/pleroma/integration/mastodon_websocket_test.exs +++ b/test/pleroma/integration/mastodon_websocket_test.exs @@ -279,7 +279,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do end) end - test "accepts Phoenix 1.8 sec-websocket headers connect info before query params", %{ + test "prefers sec-websocket-protocol token over query access_token", %{ token: token, user: user } do From 71afba4825eaa009e506a74e11dfece99dc08e39 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 12 May 2026 08:52:42 +0400 Subject: [PATCH 054/195] Signature: Treat HTTP signature errors as invalid --- lib/pleroma/signature.ex | 2 +- test/pleroma/signature_test.exs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index fca61799b..382cf9db3 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -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() diff --git a/test/pleroma/signature_test.exs b/test/pleroma/signature_test.exs index 572d7acc3..0c7c4c840 100644 --- a/test/pleroma/signature_test.exs +++ b/test/pleroma/signature_test.exs @@ -11,6 +11,7 @@ defmodule Pleroma.SignatureTest do import Mock alias Pleroma.Signature + alias Pleroma.StubbedHTTPSignaturesMock, as: HTTPSignaturesMock setup do mock(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -103,6 +104,18 @@ defmodule Pleroma.SignatureTest do end end + describe "validate_signature/1" do + test "treats HTTP signature errors as failed validation" do + conn = %Plug.Conn{method: "GET", request_path: "/inbox", req_headers: []} + + Mox.expect(HTTPSignaturesMock, :validate_conn, fn _conn -> + {:error, :request_target_header} + end) + + assert Signature.validate_signature(conn) == false + end + end + describe "key_id_to_actor_id/1" do test "it properly deduces the actor id for misskey" do assert Signature.key_id_to_actor_id("https://example.com/users/1234/publickey") == From ea886dc36b3727d7bd22ede1bdb90de3dfb8668e Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 12 May 2026 11:37:01 +0200 Subject: [PATCH 055/195] EnsureHostMatchesPlug: Ensure Host header matches instance URI --- changelog.d/host-header-verification.security | 1 + .../web/plugs/ensure_host_matches_plug.ex | 68 +++++++++++++++++++ lib/pleroma/web/router.ex | 1 + 3 files changed, 70 insertions(+) create mode 100644 changelog.d/host-header-verification.security create mode 100644 lib/pleroma/web/plugs/ensure_host_matches_plug.ex diff --git a/changelog.d/host-header-verification.security b/changelog.d/host-header-verification.security new file mode 100644 index 000000000..11a0784b2 --- /dev/null +++ b/changelog.d/host-header-verification.security @@ -0,0 +1 @@ +Ensure Host header is present and matches instance URI diff --git a/lib/pleroma/web/plugs/ensure_host_matches_plug.ex b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex new file mode 100644 index 000000000..87c4c0531 --- /dev/null +++ b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex @@ -0,0 +1,68 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlug do + @moduledoc "Ensures Host header matches instance" + + alias Pleroma.Web.Endpoint + + import Plug.Conn + + def init(options), do: options + + @spec call(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t() + def call(%Plug.Conn{assigns: %{valid_signature: true}} = conn, _opts) do + # Host header is scheme-less, URI.parse needs the // + host_header = get_req_header(conn, "host") + host_uri = URI.parse("//#{host_header}") + instance_uri = URI.parse(Endpoint.url()) + instance_scheme_port = URI.default_port(instance_uri.scheme) + + case host_header do + [host] -> + cond do + host == "" -> + resp(conn, 400, "Host header not provided") |> halt() + + true -> + if host_matches?(host_uri, instance_uri, instance_scheme_port), + do: assign(conn, :valid_host_header, true), + else: resp(conn, 400, "Host header does not match this instance") |> halt() + end + + [_head | _rest] -> + conn + |> resp(400, "More than one Host header provided") + |> halt() + + [] -> + conn + |> resp(400, "Host header not provided") + |> halt() + end + end + + # Host header may not be provided, but signature verification failed anyway + def call(conn, _opts), do: conn + + defp case_insensitive_compare(checked, authority) do + String.downcase(checked) == String.downcase(authority) + end + + # Host header did not provide port + # Host header is scheme-less, URI.parse does not provide default port + defp host_matches?(%URI{host: req_host, port: nil}, %URI{host: instance_host}, _), + do: case_insensitive_compare(req_host, instance_host) + + # Host header provided a port, reverse proxy configuration (port cannot match Endpoint port) + # Both port 80 and 443 are valid based on Endpoint configuration + defp host_matches?(%URI{host: req_host, port: port}, %URI{host: instance_host}, port), + do: case_insensitive_compare(req_host, instance_host) + + # Host header provided port, configuration without reverse proxy (port matches Endpoint port) + defp host_matches?(%URI{host: req_host, port: port}, %URI{host: instance_host, port: port}, _), + do: case_insensitive_compare(req_host, instance_host) + + defp host_matches?(_, _, _), do: false +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index c91ca8c97..813c5c482 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -216,6 +216,7 @@ defmodule Pleroma.Web.Router do pipeline :http_signature do plug(Pleroma.Web.Plugs.HTTPSignaturePlug) plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug) + plug(Pleroma.Web.Plugs.EnsureHostMatchesPlug) end pipeline :inbox_guard do From d6d0ce72604ee450560916bb29297188579faa0e Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 12 May 2026 14:00:28 +0200 Subject: [PATCH 056/195] EnsureHostMatchesPlug: Add tests --- .../plugs/ensure_host_matches_plug_test.exs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 test/pleroma/web/plugs/ensure_host_matches_plug_test.exs diff --git a/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs b/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs new file mode 100644 index 000000000..fbebc21d9 --- /dev/null +++ b/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs @@ -0,0 +1,123 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlugTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Plugs.EnsureHostMatchesPlug + + import Mock + import Plug.Conn + import Tesla.Mock + + setup do + mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + + defp set_host(conn, host), do: %{conn | req_headers: conn.req_headers ++ [{"host", host}]} + + describe "EnsureHostMatchesPlug" do + setup do + conn = build_conn(:post, "/cofe") |> assign(:valid_signature, true) + [conn: conn] + end + + test "gracefully handles no Host header", %{conn: conn} do + conn = EnsureHostMatchesPlug.call(conn, %{}) + + assert conn.status == 400 + assert conn.halted == true + assert conn.resp_body == "Host header not provided" + end + + test "gracefully handles empty Host header", %{conn: conn} do + conn = + conn + |> set_host("") + |> EnsureHostMatchesPlug.call(%{}) + + assert conn.status == 400 + assert conn.halted == true + assert conn.resp_body == "Host header not provided" + end + + test "it rejects Host header not matching Endpoint URL", %{conn: conn} do + conn = + conn + |> set_host("invalid.example.com") + |> EnsureHostMatchesPlug.call(%{}) + + assert conn.status == 400 + assert conn.halted == true + assert conn.resp_body == "Host header does not match this instance" + end + + test "it rejects Host header not matching Endpoint port", %{conn: conn} do + endpoint = URI.parse(Endpoint.url()) + + conn = + conn + |> set_host("#{endpoint.host}:25") + |> EnsureHostMatchesPlug.call(%{}) + + assert conn.status == 400 + assert conn.halted == true + assert conn.resp_body == "Host header does not match this instance" + end + + test "it rejects multiple Host headers", %{conn: conn} do + conn = + conn + |> set_host("host1.example.com") + |> set_host("host2.example.com") + |> EnsureHostMatchesPlug.call(%{}) + + assert conn.status == 400 + assert conn.halted == true + assert conn.resp_body == "More than one Host header provided" + end + + test "it works for Host header with port as 80", %{conn: conn} do + endpoint = URI.parse(Endpoint.url()) + + conn = + conn + |> set_host("#{endpoint.host}:80") + |> EnsureHostMatchesPlug.call(%{}) + + assert conn.halted == false + assert Map.get(conn.assigns, :valid_host_header, nil) + end + + test "it works for Host header with port as 443", %{conn: conn} do + with_mock Pleroma.Web.Endpoint, url: fn -> "https://localhost:4001" end do + endpoint = URI.parse(Endpoint.url()) + + conn = + conn + |> set_host("#{endpoint.host}:443") + |> EnsureHostMatchesPlug.call(%{}) + + assert conn.halted == false + assert Map.get(conn.assigns, :valid_host_header, nil) + end + end + + test "it works for Host header with port as same as Endpoint (no reverse proxy config)", %{ + conn: conn + } do + endpoint = URI.parse(Endpoint.url()) + + conn = + conn + |> set_host("#{endpoint.host}:#{endpoint.port}") + |> EnsureHostMatchesPlug.call(%{}) + + assert conn.halted == false + assert Map.get(conn.assigns, :valid_host_header, nil) + end + end +end From 90e390e45b1c1af4bbc8e2e68affb41fd621ea3e Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 12 May 2026 16:48:49 +0200 Subject: [PATCH 057/195] fix tests --- test/support/conn_case.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index f010fec33..c01516169 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -119,7 +119,10 @@ defmodule Pleroma.Web.ConnCase do DataCase.stub_pipeline() Mox.verify_on_exit!() + endpoint = URI.parse(Pleroma.Web.Endpoint.url()) + conn = Phoenix.ConnTest.build_conn() + conn = %{conn | req_headers: [{"host", "#{endpoint.host}:#{endpoint.port}"}]} - {:ok, conn: Phoenix.ConnTest.build_conn()} + {:ok, conn: conn} end end From 35b5447f3f21a0a30370c86efffee531f56f1ea7 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 12 May 2026 17:02:28 +0200 Subject: [PATCH 058/195] EnsureHostMatchesPlug: Add more tests --- .../plugs/ensure_host_matches_plug_test.exs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs b/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs index fbebc21d9..67252e7a7 100644 --- a/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs +++ b/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs @@ -55,6 +55,19 @@ defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlugTest do assert conn.resp_body == "Host header does not match this instance" end + test "it rejects Host header not matching Endpoint with port", %{conn: conn} do + endpoint = URI.parse(Endpoint.url()) + + conn = + conn + |> set_host("invalid.example.com:#{endpoint.port}") + |> EnsureHostMatchesPlug.call(%{}) + + assert conn.status == 400 + assert conn.halted == true + assert conn.resp_body == "Host header does not match this instance" + end + test "it rejects Host header not matching Endpoint port", %{conn: conn} do endpoint = URI.parse(Endpoint.url()) @@ -80,6 +93,18 @@ defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlugTest do assert conn.resp_body == "More than one Host header provided" end + test "it works for Host header without port", %{conn: conn} do + endpoint = URI.parse(Endpoint.url()) + + conn = + conn + |> set_host("#{endpoint.host}") + |> EnsureHostMatchesPlug.call(%{}) + + assert conn.halted == false + assert Map.get(conn.assigns, :valid_host_header, nil) + end + test "it works for Host header with port as 80", %{conn: conn} do endpoint = URI.parse(Endpoint.url()) From 0cf865f0254f33fba312a9342c635b7300ea0291 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 12 May 2026 23:50:30 +0400 Subject: [PATCH 059/195] Reject third-party remote reports --- changelog.d/reject-third-party-reports.fix | 1 + lib/pleroma/web/activity_pub/transmogrifier.ex | 7 +++++++ .../web/activity_pub/transmogrifier_test.exs | 17 +++++++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 changelog.d/reject-third-party-reports.fix diff --git a/changelog.d/reject-third-party-reports.fix b/changelog.d/reject-third-party-reports.fix new file mode 100644 index 000000000..7d4e87b94 --- /dev/null +++ b/changelog.d/reject-third-party-reports.fix @@ -0,0 +1 @@ +Reject incoming reports when both the reporter and reported account are remote diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 4421da26c..261272d08 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -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) @@ -447,6 +453,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do %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 %{ diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index c1e01557d..b20f15cbd 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -86,6 +86,23 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do assert activity.data["cc"] == [user.ap_id] end + test "it rejects Flag activities when both reporter and reported account are remote" do + reporter = insert(:user, local: false, domain: "mastodon.cat") + reported = insert(:user, local: false, domain: "nicecrew.digital") + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "actor" => reporter.ap_id, + "content" => "blocked AND reported!!!", + "object" => [reported.ap_id, "https://nicecrew.digital/objects/report-status"], + "type" => "Flag" + } + + assert {:reject, reason} = Transmogrifier.handle_incoming(message) + assert reason =~ "third-party report" + refute "Flag" |> Pleroma.Activity.Queries.by_type() |> Pleroma.Repo.one() + end + test "it accepts Move activities" do old_user = insert(:user) new_user = insert(:user) From 6f415cf3fcdad8fc8d440a00c75e8388d99677e5 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 12 May 2026 23:31:55 +0200 Subject: [PATCH 060/195] EnsureHostMatchesPlug: Remove match against default scheme port Checking against the default port of the Endpoint URL scheme is redundant as normal instances will have the combination https/443 by default created by pleroma.instance gen, Tor-only instances should have combination http/80 and local testing instances httt/XXXX. The default scheme port doesn't add anything usefull in these configs. --- .../web/plugs/ensure_host_matches_plug.ex | 17 ++++------- .../plugs/ensure_host_matches_plug_test.exs | 29 +------------------ 2 files changed, 7 insertions(+), 39 deletions(-) diff --git a/lib/pleroma/web/plugs/ensure_host_matches_plug.ex b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex index 87c4c0531..ab2129f9e 100644 --- a/lib/pleroma/web/plugs/ensure_host_matches_plug.ex +++ b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex @@ -17,7 +17,6 @@ defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlug do host_header = get_req_header(conn, "host") host_uri = URI.parse("//#{host_header}") instance_uri = URI.parse(Endpoint.url()) - instance_scheme_port = URI.default_port(instance_uri.scheme) case host_header do [host] -> @@ -26,7 +25,7 @@ defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlug do resp(conn, 400, "Host header not provided") |> halt() true -> - if host_matches?(host_uri, instance_uri, instance_scheme_port), + if host_matches?(host_uri, instance_uri), do: assign(conn, :valid_host_header, true), else: resp(conn, 400, "Host header does not match this instance") |> halt() end @@ -52,17 +51,13 @@ defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlug do # Host header did not provide port # Host header is scheme-less, URI.parse does not provide default port - defp host_matches?(%URI{host: req_host, port: nil}, %URI{host: instance_host}, _), + defp host_matches?(%URI{host: req_host, port: nil}, %URI{host: instance_host}), do: case_insensitive_compare(req_host, instance_host) - # Host header provided a port, reverse proxy configuration (port cannot match Endpoint port) - # Both port 80 and 443 are valid based on Endpoint configuration - defp host_matches?(%URI{host: req_host, port: port}, %URI{host: instance_host}, port), + # Host header provided a port + # Any port specified in the Endpoint url configuration is valid here + defp host_matches?(%URI{host: req_host, port: port}, %URI{host: instance_host, port: port}), do: case_insensitive_compare(req_host, instance_host) - # Host header provided port, configuration without reverse proxy (port matches Endpoint port) - defp host_matches?(%URI{host: req_host, port: port}, %URI{host: instance_host, port: port}, _), - do: case_insensitive_compare(req_host, instance_host) - - defp host_matches?(_, _, _), do: false + defp host_matches?(_, _), do: false end diff --git a/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs b/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs index 67252e7a7..8ace74dfb 100644 --- a/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs +++ b/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs @@ -8,7 +8,6 @@ defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlugTest do alias Pleroma.Web.Endpoint alias Pleroma.Web.Plugs.EnsureHostMatchesPlug - import Mock import Plug.Conn import Tesla.Mock @@ -105,33 +104,7 @@ defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlugTest do assert Map.get(conn.assigns, :valid_host_header, nil) end - test "it works for Host header with port as 80", %{conn: conn} do - endpoint = URI.parse(Endpoint.url()) - - conn = - conn - |> set_host("#{endpoint.host}:80") - |> EnsureHostMatchesPlug.call(%{}) - - assert conn.halted == false - assert Map.get(conn.assigns, :valid_host_header, nil) - end - - test "it works for Host header with port as 443", %{conn: conn} do - with_mock Pleroma.Web.Endpoint, url: fn -> "https://localhost:4001" end do - endpoint = URI.parse(Endpoint.url()) - - conn = - conn - |> set_host("#{endpoint.host}:443") - |> EnsureHostMatchesPlug.call(%{}) - - assert conn.halted == false - assert Map.get(conn.assigns, :valid_host_header, nil) - end - end - - test "it works for Host header with port as same as Endpoint (no reverse proxy config)", %{ + test "it works for Host header with port same as Endpoint", %{ conn: conn } do endpoint = URI.parse(Endpoint.url()) From 6c2d8209c9b3f86db6b89d48421b7a0d70a33d00 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 13 May 2026 00:31:50 +0200 Subject: [PATCH 061/195] SignatureRetryWorker: require validated host header --- lib/pleroma/workers/signature_retry_worker.ex | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/pleroma/workers/signature_retry_worker.ex b/lib/pleroma/workers/signature_retry_worker.ex index 2c4c097dd..28958faff 100644 --- a/lib/pleroma/workers/signature_retry_worker.ex +++ b/lib/pleroma/workers/signature_retry_worker.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Workers.SignatureRetryWorker do alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Federator + alias Pleroma.Web.Plugs.EnsureHostMatchesPlug alias Pleroma.Web.Plugs.MappedSignatureToIdentityPlug require Logger @@ -48,6 +49,7 @@ defmodule Pleroma.Workers.SignatureRetryWorker do {: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)}, + {:host_header, true} <- {:host_header, validate_host_header(conn_data)}, {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do unless Instances.reachable?(params["actor"]) do domain = URI.parse(params["actor"]).host @@ -103,6 +105,16 @@ defmodule Pleroma.Workers.SignatureRetryWorker do end end + defp validate_host_header(conn_data) do + case EnsureHostMatchesPlug.call(conn_data, []) do + %Plug.Conn{assigns: %{valid_signature: true, valid_host_header: true}} -> + true + + _ -> + false + end + end + defp validate_same_actor(conn_data) do case MappedSignatureToIdentityPlug.call(conn_data, []) do %Plug.Conn{assigns: %{valid_signature: true}} -> @@ -170,6 +182,10 @@ defmodule Pleroma.Workers.SignatureRetryWorker do {:same_actor, false} -> {:cancel, :actor_signature_mismatch} + # Host header from request not for us + {:host_header, false} -> + {:cancel, :host_header_mismatch} + # Origin / URL validation failed somewhere possibly due to spoofing {:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed} @@ -234,6 +250,7 @@ defmodule Pleroma.Workers.SignatureRetryWorker do defp log_signature_retry_rejection({:cancel, reason}, context) when reason in [ :actor_signature_mismatch, + :host_header_mismatch, :invalid_signature, :invalid_signature_retry_metadata, :missing_signature_retry_metadata, From 95b15190dedab255c102db023af2d90eacf3a259 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 13 May 2026 00:32:16 +0200 Subject: [PATCH 062/195] ActivityPubController: require validated host header --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 2bfff6968..415aa4f68 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -303,7 +303,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end end - def inbox(%{assigns: %{valid_signature: true}} = conn, %{"nickname" => nickname} = params) do + def inbox(%{assigns: %{valid_signature: true, valid_host_header: true}} = conn, %{"nickname" => nickname} = params) do with {:recipient_exists, %User{} = recipient} <- {:recipient_exists, User.get_cached_by_nickname(nickname)}, {:sender_exists, {:ok, %User{} = actor}} <- @@ -342,7 +342,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end end - def inbox(%{assigns: %{valid_signature: true}} = conn, params) do + def inbox(%{assigns: %{valid_signature: true, valid_host_header: true}} = conn, params) do Federator.incoming_ap_doc(params) json(conn, "ok") end From c19bdf38143fcbe3a91a3b6053acefb8925773ac Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 13 May 2026 00:33:09 +0200 Subject: [PATCH 063/195] SignatureRetryWorker: add mismatched host test, fix tests --- .../workers/signature_retry_worker_test.exs | 66 ++++++++++++++++++- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/test/pleroma/workers/signature_retry_worker_test.exs b/test/pleroma/workers/signature_retry_worker_test.exs index 94dd5f6c1..7f1351f4a 100644 --- a/test/pleroma/workers/signature_retry_worker_test.exs +++ b/test/pleroma/workers/signature_retry_worker_test.exs @@ -16,12 +16,13 @@ defmodule Pleroma.Workers.SignatureRetryWorkerTest do alias Pleroma.Signature alias Pleroma.User alias Pleroma.Web.ActivityPub.UserView + alias Pleroma.Web.Endpoint alias Pleroma.Web.Federator alias Pleroma.Workers.SignatureRetryWorker defp signature_headers_for(%User{} = signer) do [ - {"host", "local.test"}, + {"host", "#{URI.parse(Endpoint.url()).host}"}, {"date", "Thu, 25 Jul 2024 13:33:31 GMT"}, {"digest", "SHA-256=fake-digest"}, {"content-type", "application/activity+json"}, @@ -245,6 +246,65 @@ defmodule Pleroma.Workers.SignatureRetryWorkerTest do refute Activity.get_by_ap_id(create["id"]) end + test "cancels when the Host header does not match Endpoint" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + + create = %{ + "type" => "Create", + "actor" => alice.ap_id, + "id" => "https://one.com/activities/invalid-signature-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => "https://one.com/objects/invalid-signature-note", + "actor" => alice.ap_id, + "attributedTo" => alice.ap_id, + "content" => "forged post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + expect_signature_from(alice) + + headers = + [ + {"host", "invalid.example.com"}, + {"date", "Thu, 25 Jul 2024 13:33:31 GMT"}, + {"digest", "SHA-256=fake-digest"}, + {"content-type", "application/activity+json"}, + { + "signature", + "keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\"" + } + ] + + assert {:ok, oban_job} = Federator.incoming_failed_signature_ap_doc(%{ + method: "POST", + req_headers: headers, + request_path: "/inbox", + params: create, + query_string: "" + }) + + log = + capture_log([level: :warning], fn -> + assert {:cancel, :host_header_mismatch} = SignatureRetryWorker.perform(oban_job) + end) + + assert log =~ "Failed-signature inbox retry rejected" + assert log =~ "reason=:host_header_mismatch" + assert log =~ "payload_actor=\"https://one.com/users/alice\"" + assert log =~ "signature_actor=\"https://one.com/users/alice\"" + assert log =~ "activity_id=\"https://one.com/activities/invalid-signature-create\"" + assert log =~ "type=\"Create\"" + assert log =~ "request_path=\"/inbox\"" + + refute Activity.get_by_ap_id(create["id"]) + end + test "processes the activity after refetching a valid matching signature" do alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") @@ -309,11 +369,11 @@ defmodule Pleroma.Workers.SignatureRetryWorkerTest do "content-type" => "application/activity+json", date: date, digest: digest, - host: "local.test" + host: "#{URI.parse(Endpoint.url()).host}" }) req_headers = [ - ["host", "local.test"], + ["host", "#{URI.parse(Endpoint.url()).host}"], ["date", date], ["digest", digest], ["content-type", "application/activity+json"], From 95eef879d76df69e4d113f79afa9fbd2188aa5b0 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 13 May 2026 00:40:53 +0200 Subject: [PATCH 064/195] ActivityPubController: add mismatched host test --- .../activity_pub_controller_test.exs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index 3988c3912..64f79dab5 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -950,6 +950,49 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do refute Activity.get_by_ap_id(data["id"]) end + test "does not process post with Host header not for us", %{conn: conn} do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + object_id = "https://one.com/objects/inbox-forged-note" + + data = %{ + "type" => "Create", + "actor" => alice.ap_id, + "id" => "https://one.com/activities/inbox-forged-create", + "context" => "https://one.com/contexts/inbox-forged-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => object_id, + "actor" => alice.ap_id, + "attributedTo" => alice.ap_id, + "context" => "https://one.com/contexts/inbox-forged-create", + "content" => "forged post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + expect_signature_retry_from(alice) + + conn = %{conn | req_headers: [{"host", "invalid.example.com"}]} + + conn = + conn + |> assign(:valid_signature, false) + |> put_req_header("content-type", "application/activity+json") + |> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"") + |> post("/inbox", data) + + assert "Host header does not match this instance" == conn.resp_body + assert 400 == conn.status + assert true == conn.halted + + refute Activity.get_by_ap_id(data["id"]) + refute Object.get_by_ap_id(object_id) + end + test "accept follow activity", %{conn: conn} do clear_config([:instance, :federating], true) relay = Relay.get_actor() From 2b3ac2d7fee91cdcbe53ca53760559def250b7d7 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 13 May 2026 00:44:33 +0200 Subject: [PATCH 065/195] lint --- .../web/activity_pub/activity_pub_controller.ex | 7 ++++++- .../workers/signature_retry_worker_test.exs | 15 ++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 415aa4f68..3d1875599 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -303,7 +303,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end end - def inbox(%{assigns: %{valid_signature: true, valid_host_header: true}} = conn, %{"nickname" => nickname} = params) do + def inbox( + %{ + assigns: %{valid_signature: true, valid_host_header: true} + } = conn, + %{"nickname" => nickname} = params + ) do with {:recipient_exists, %User{} = recipient} <- {:recipient_exists, User.get_cached_by_nickname(nickname)}, {:sender_exists, {:ok, %User{} = actor}} <- diff --git a/test/pleroma/workers/signature_retry_worker_test.exs b/test/pleroma/workers/signature_retry_worker_test.exs index 7f1351f4a..3806ecac9 100644 --- a/test/pleroma/workers/signature_retry_worker_test.exs +++ b/test/pleroma/workers/signature_retry_worker_test.exs @@ -281,13 +281,14 @@ defmodule Pleroma.Workers.SignatureRetryWorkerTest do } ] - assert {:ok, oban_job} = Federator.incoming_failed_signature_ap_doc(%{ - method: "POST", - req_headers: headers, - request_path: "/inbox", - params: create, - query_string: "" - }) + assert {:ok, oban_job} = + Federator.incoming_failed_signature_ap_doc(%{ + method: "POST", + req_headers: headers, + request_path: "/inbox", + params: create, + query_string: "" + }) log = capture_log([level: :warning], fn -> From 4d3aea1fce1a3d9b268aad2de8cae75b625ff5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 13 May 2026 00:55:47 +0200 Subject: [PATCH 066/195] Handle reports with just actor ap id as the object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/iceshrimpnet-reports-fix.fix | 1 + .../web/activity_pub/transmogrifier.ex | 1 + .../web/activity_pub/transmogrifier_test.exs | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+) create mode 100644 changelog.d/iceshrimpnet-reports-fix.fix diff --git a/changelog.d/iceshrimpnet-reports-fix.fix b/changelog.d/iceshrimpnet-reports-fix.fix new file mode 100644 index 000000000..7487ae47e --- /dev/null +++ b/changelog.d/iceshrimpnet-reports-fix.fix @@ -0,0 +1 @@ +Handle reports with just actor ap id as the object diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 4421da26c..eba588cf3 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -444,6 +444,7 @@ 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), diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index c1e01557d..2e3da8598 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -86,6 +86,26 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do assert activity.data["cc"] == [user.ap_id] end + test "it accepts Flag activities with just actor id as object" do + user = insert(:user) + other_user = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "cc" => [user.ap_id], + "object" => user.ap_id, + "type" => "Flag", + "content" => "blocked AND reported!!!", + "actor" => other_user.ap_id + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(message) + + assert activity.data["content"] == "blocked AND reported!!!" + assert activity.data["actor"] == other_user.ap_id + assert activity.data["cc"] == [user.ap_id] + end + test "it accepts Move activities" do old_user = insert(:user) new_user = insert(:user) From 4810d2536e2fd366364c1ec39d88b21265166f88 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 13 May 2026 12:10:10 +0200 Subject: [PATCH 067/195] ActivityPubController: Use valid signatures in Host header test --- .../activity_pub/activity_pub_controller_test.exs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index 64f79dab5..68e39b3a0 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -974,16 +974,17 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do } } - expect_signature_retry_from(alice) - - conn = %{conn | req_headers: [{"host", "invalid.example.com"}]} - + # Plug will complain when replacing raw host header with put_req_header. + # The Plug way is updating conn.host, but that isn't the raw header + # and that isn't used in the EnsureHostMatchesPlug, because it doesn't include the port. conn = conn - |> assign(:valid_signature, false) + |> assign_valid_signature_for_actor(alice) + |> delete_req_header("host") |> put_req_header("content-type", "application/activity+json") - |> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"") - |> post("/inbox", data) + + conn = %{conn | req_headers: conn.req_headers ++ [{"host", "invalid.example.com"}]} + conn = post(conn, "/inbox", data) assert "Host header does not match this instance" == conn.resp_body assert 400 == conn.status From 9e16332d9d208abc4d3992fec27b317c932f7e07 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 13 May 2026 18:39:40 +0400 Subject: [PATCH 068/195] Update majic to 1.2.0 --- changelog.d/majic-1.2.0.change | 1 + mix.exs | 4 +++- mix.lock | 2 +- test/pleroma/dependency_version_test.exs | 16 ++++++++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 changelog.d/majic-1.2.0.change create mode 100644 test/pleroma/dependency_version_test.exs diff --git a/changelog.d/majic-1.2.0.change b/changelog.d/majic-1.2.0.change new file mode 100644 index 000000000..bc8d5eb3c --- /dev/null +++ b/changelog.d/majic-1.2.0.change @@ -0,0 +1 @@ +Update majic to 1.2.0. diff --git a/mix.exs b/mix.exs index 9c10cacc0..872929c2c 100644 --- a/mix.exs +++ b/mix.exs @@ -191,7 +191,9 @@ defmodule Pleroma.Mixfile do git: "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", ref: "e7b7cc34cc16b383461b966484c297e4ec9aeef6"}, {:restarter, path: "./restarter"}, - {:majic, "~> 1.1"}, + {:majic, "1.2.0", + git: "https://git.pleroma.social/pleroma-elixir-libraries/majic.git", + ref: "1f2df25cccdd9548fd59dbfa402c996268ae99ec"}, {:open_api_spex, "~> 3.22"}, {:ecto_psql_extras, "~> 0.8"}, {:vix, "~> 0.36"}, diff --git a/mix.lock b/mix.lock index da0c1c60c..780dea4c0 100644 --- a/mix.lock +++ b/mix.lock @@ -73,7 +73,7 @@ "linkify": {:hex, :linkify, "0.5.3", "5f8143d8f61f5ff08d3aeeff47ef6509492b4948d8f08007fbf66e4d2246a7f2", [:mix], [], "hexpm", "3ef35a1377d47c25506e07c1c005ea9d38d700699d92ee92825f024434258177"}, "logger_backends": {:hex, :logger_backends, "1.0.0", "09c4fad6202e08cb0fbd37f328282f16539aca380f512523ce9472b28edc6bdf", [:mix], [], "hexpm", "1faceb3e7ec3ef66a8f5746c5afd020e63996df6fd4eb8cdb789e5665ae6c9ce"}, "mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"}, - "majic": {:hex, :majic, "1.1.1", "16092a3a3376cc5e13d207e82ec06e05a5561170465e50cc11cc4df8a5747938", [:make, :mix], [{:elixir_make, "~> 0.8.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "7fbb0372f0447b3f777056177d6ab3f009742e68474f850521ff56b84bd85b96"}, + "majic": {:git, "https://git.pleroma.social/pleroma-elixir-libraries/majic.git", "1f2df25cccdd9548fd59dbfa402c996268ae99ec", [ref: "1f2df25cccdd9548fd59dbfa402c996268ae99ec"]}, "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, diff --git a/test/pleroma/dependency_version_test.exs b/test/pleroma/dependency_version_test.exs new file mode 100644 index 000000000..0c9c1e939 --- /dev/null +++ b/test/pleroma/dependency_version_test.exs @@ -0,0 +1,16 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.DependencyVersionTest do + use ExUnit.Case, async: true + + test "uses majic 1.2" do + majic_version = + :majic + |> Application.spec(:vsn) + |> to_string() + + assert Version.match?(majic_version, "~> 1.2") + end +end From d8e3ea69b17189aad1a3d3e60587ccf16bd3154f Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 13 May 2026 19:00:16 +0400 Subject: [PATCH 069/195] Use Hex release for majic 1.2.0 --- mix.exs | 4 +--- mix.lock | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mix.exs b/mix.exs index 872929c2c..2cf5137a5 100644 --- a/mix.exs +++ b/mix.exs @@ -191,9 +191,7 @@ defmodule Pleroma.Mixfile do git: "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", ref: "e7b7cc34cc16b383461b966484c297e4ec9aeef6"}, {:restarter, path: "./restarter"}, - {:majic, "1.2.0", - git: "https://git.pleroma.social/pleroma-elixir-libraries/majic.git", - ref: "1f2df25cccdd9548fd59dbfa402c996268ae99ec"}, + {:majic, "~> 1.2"}, {:open_api_spex, "~> 3.22"}, {:ecto_psql_extras, "~> 0.8"}, {:vix, "~> 0.36"}, diff --git a/mix.lock b/mix.lock index 780dea4c0..9214478c7 100644 --- a/mix.lock +++ b/mix.lock @@ -73,7 +73,7 @@ "linkify": {:hex, :linkify, "0.5.3", "5f8143d8f61f5ff08d3aeeff47ef6509492b4948d8f08007fbf66e4d2246a7f2", [:mix], [], "hexpm", "3ef35a1377d47c25506e07c1c005ea9d38d700699d92ee92825f024434258177"}, "logger_backends": {:hex, :logger_backends, "1.0.0", "09c4fad6202e08cb0fbd37f328282f16539aca380f512523ce9472b28edc6bdf", [:mix], [], "hexpm", "1faceb3e7ec3ef66a8f5746c5afd020e63996df6fd4eb8cdb789e5665ae6c9ce"}, "mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"}, - "majic": {:git, "https://git.pleroma.social/pleroma-elixir-libraries/majic.git", "1f2df25cccdd9548fd59dbfa402c996268ae99ec", [ref: "1f2df25cccdd9548fd59dbfa402c996268ae99ec"]}, + "majic": {:hex, :majic, "1.2.0", "414b69c1460ece692fa892a0ed6669b8db6a44c42bb3071cb723854f22e7bd78", [:make, :mix], [{:elixir_make, "~> 0.8.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0bd0c65f8e4dcc595757d957577f6151b59246da9614ba87debfdc641ccc0514"}, "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, From d0c2d04356bbed3b2be2b9873a6c207f54021285 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 13 May 2026 19:10:15 +0400 Subject: [PATCH 070/195] Use Hex release for oban_plugins_lazarus --- changelog.d/oban-plugins-lazarus-hex.change | 1 + mix.exs | 4 +--- mix.lock | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 changelog.d/oban-plugins-lazarus-hex.change diff --git a/changelog.d/oban-plugins-lazarus-hex.change b/changelog.d/oban-plugins-lazarus-hex.change new file mode 100644 index 000000000..9259df0f5 --- /dev/null +++ b/changelog.d/oban-plugins-lazarus-hex.change @@ -0,0 +1 @@ +Use the Hex package for oban_plugins_lazarus. diff --git a/mix.exs b/mix.exs index 2cf5137a5..946bf0ab0 100644 --- a/mix.exs +++ b/mix.exs @@ -137,9 +137,7 @@ defmodule Pleroma.Mixfile do {:tzdata, "~> 1.0.5"}, {:plug_cowboy, "~> 2.7"}, {:oban, "~> 2.19.4"}, - {:oban_plugins_lazarus, - git: "https://git.pleroma.social/pleroma/elixir-libraries/oban_plugins_lazarus.git", - ref: "e49fc355baaf0e435208bf5f534d31e26e897711"}, + {:oban_plugins_lazarus, "~> 0.1.1"}, {:oban_web, "~> 2.11"}, {:gettext, "~> 0.24"}, {:bcrypt_elixir, "~> 2.3"}, diff --git a/mix.lock b/mix.lock index 9214478c7..f33c22482 100644 --- a/mix.lock +++ b/mix.lock @@ -96,7 +96,7 @@ "oban": {:hex, :oban, "2.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"}, "oban_live_dashboard": {:hex, :oban_live_dashboard, "0.1.1", "8aa4ceaf381c818f7d5c8185cc59942b8ac82ef0cf559881aacf8d3f8ac7bdd3", [:mix], [{:oban, "~> 2.15", [hex: :oban, repo: "hexpm", optional: false]}, {:phoenix_live_dashboard, "~> 0.7", [hex: :phoenix_live_dashboard, repo: "hexpm", optional: false]}], "hexpm", "16dc4ce9c9a95aa2e655e35ed4e675652994a8def61731a18af85e230e1caa63"}, "oban_met": {:hex, :oban_met, "1.0.5", "bb633ab06448dab2ef9194f6688d33b3d07fc3f2ad793a1a08f4dfbb2cc9fe50", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "64664d50805bbfd3903aeada1f3c39634652a87844797ee400b0bcc95a28f5ea"}, - "oban_plugins_lazarus": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/oban_plugins_lazarus.git", "e49fc355baaf0e435208bf5f534d31e26e897711", [ref: "e49fc355baaf0e435208bf5f534d31e26e897711"]}, + "oban_plugins_lazarus": {:hex, :oban_plugins_lazarus, "0.1.1", "a5141d569e5b9f3bec8f4a958231d2c538af097d3c1ad06274f096fc06956821", [:mix], [{:oban, "< 3.0.0", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "7e9c51bc44d33f71c0a52cf0cd5c8d4c70380ede065c1042013f5123f2fc9729"}, "oban_web": {:hex, :oban_web, "2.11.6", "53933cb4253c4d9f1098ee311c06f07935259f0e564dcf2d66bae4cc98e317fe", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.0", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "576d94b705688c313694c2c114ca21aa0f8f2ad1b9ca45c052c5ba316d3e8d10"}, "octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"}, "open_api_spex": {:hex, :open_api_spex, "3.22.0", "fbf90dc82681dc042a4ee79853c8e989efbba73d9e87439085daf849bbf8bc20", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "dd751ddbdd709bb4a5313e9a24530da6e66594773c7242a0c2592cbd9f589063"}, From c92d23323346e0538fc04e79e9a110fe6bb9198e Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 13 May 2026 20:04:12 +0400 Subject: [PATCH 071/195] Use upstream remote_ip package --- changelog.d/remote-ip-hex.change | 1 + config/config.exs | 1 + config/description.exs | 8 ++++- lib/pleroma/web/plugs/remote_ip.ex | 42 ++++++++++++++++++++--- mix.exs | 5 ++- mix.lock | 4 +-- test/pleroma/web/plugs/remote_ip_test.exs | 34 ++++++++++++++++++ 7 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 changelog.d/remote-ip-hex.change diff --git a/changelog.d/remote-ip-hex.change b/changelog.d/remote-ip-hex.change new file mode 100644 index 000000000..44f6bc1bf --- /dev/null +++ b/changelog.d/remote-ip-hex.change @@ -0,0 +1 @@ +Use upstream Hex releases for the `remote_ip` dependency and expose client IP ranges for remote IP resolution. diff --git a/config/config.exs b/config/config.exs index 2d38e3ebe..3f880af53 100644 --- a/config/config.exs +++ b/config/config.exs @@ -738,6 +738,7 @@ config :pleroma, Pleroma.Workers.PurgeExpiredActivity, enabled: true, min_lifeti config :pleroma, Pleroma.Web.Plugs.RemoteIp, enabled: true, headers: ["x-forwarded-for"], + clients: [], proxies: [], reserved: [ "127.0.0.0/8", diff --git a/config/description.exs b/config/description.exs index 6e4348907..7c377588c 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2910,7 +2910,7 @@ config :pleroma, :config_description, [ key: Pleroma.Web.Plugs.RemoteIp, type: :group, description: """ - `Pleroma.Web.Plugs.RemoteIp` is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration. + `Pleroma.Web.Plugs.RemoteIp` is a shim to call [`RemoteIp`](https://hex.pm/packages/remote_ip) but with runtime configuration. **If your instance is not behind at least one reverse proxy, you should not enable this plug.** """, children: [ @@ -2926,6 +2926,12 @@ config :pleroma, :config_description, [ A list of strings naming the HTTP headers to use when deriving the true client IP. Default: `["x-forwarded-for"]`. """ }, + %{ + key: :clients, + type: {:list, :string}, + description: + "A list of client IPs or subnets in CIDR notation. These will not be treated as proxies or reserved ranges. Defaults to `[]`. IPv4 entries without a bitmask will be assumed to be /32 and IPv6 /128." + }, %{ key: :proxies, type: {:list, :string}, diff --git a/lib/pleroma/web/plugs/remote_ip.ex b/lib/pleroma/web/plugs/remote_ip.ex index 3a4bffb50..4c2ea099d 100644 --- a/lib/pleroma/web/plugs/remote_ip.ex +++ b/lib/pleroma/web/plugs/remote_ip.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.Plugs.RemoteIp do @moduledoc """ - This is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration. + This is a shim to call [`RemoteIp`](https://hex.pm/packages/remote_ip) but with runtime configuration. """ alias Pleroma.Config @@ -17,15 +17,29 @@ defmodule Pleroma.Web.Plugs.RemoteIp do def call(%{remote_ip: original_remote_ip} = conn, _) do if Config.get([__MODULE__, :enabled]) do - %{remote_ip: new_remote_ip} = conn = RemoteIp.call(conn, remote_ip_opts()) + new_remote_ip = remote_ip(conn) || original_remote_ip + + conn = %{conn | remote_ip: new_remote_ip} assign(conn, :remote_ip_found, original_remote_ip != new_remote_ip) else conn end end + defp remote_ip(conn) do + opts = remote_ip_opts() + + # Do not use RemoteIp.from/2 here: upstream remote_ip always applies its + # built-in reserved ranges. Pleroma keeps :reserved configurable, so reuse + # only the header parsing and apply Pleroma's own block classification. + conn.req_headers + |> RemoteIp.Headers.take(opts[:headers]) + |> RemoteIp.Headers.parse() + |> Enum.reverse() + |> Enum.find(&client?(&1, opts)) + end + defp remote_ip_opts do - headers = Config.get([__MODULE__, :headers], []) |> MapSet.new() reserved = Config.get([__MODULE__, :reserved], []) proxies = @@ -33,6 +47,26 @@ defmodule Pleroma.Web.Plugs.RemoteIp do |> Enum.concat(reserved) |> Enum.map(&InetHelper.parse_cidr/1) - {headers, proxies} + clients = + Config.get([__MODULE__, :clients], []) + |> Enum.map(&InetHelper.parse_cidr/1) + + [ + headers: Config.get([__MODULE__, :headers], []), + clients: clients, + proxies: proxies + ] + end + + defp client?(ip, opts) do + client_ip?(ip, opts[:clients]) || !proxy_ip?(ip, opts[:proxies]) + end + + defp client_ip?(ip, clients) do + Enum.any?(clients, &InetCidr.contains?(&1, ip)) + end + + defp proxy_ip?(ip, proxies) do + Enum.any?(proxies, &InetCidr.contains?(&1, ip)) end end diff --git a/mix.exs b/mix.exs index 946bf0ab0..90586b2d3 100644 --- a/mix.exs +++ b/mix.exs @@ -182,9 +182,8 @@ defmodule Pleroma.Mixfile do {:plug_static_index_html, "~> 1.0.0"}, {:flake_id, "~> 0.1.0"}, {:concurrent_limiter, "~> 0.1.1"}, - {:remote_ip, - git: "https://git.pleroma.social/pleroma/remote_ip.git", - ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"}, + {:remote_ip, "~> 1.2.0"}, + {:inet_cidr, "~> 1.0"}, {:captcha, git: "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", ref: "e7b7cc34cc16b383461b966484c297e4ec9aeef6"}, diff --git a/mix.lock b/mix.lock index f33c22482..04de2825e 100644 --- a/mix.lock +++ b/mix.lock @@ -65,7 +65,7 @@ "http_signatures": {:hex, :http_signatures, "0.1.3", "19f26aee35b4684e37efdce3ac4638605e6e41a04368186bd39d2b6138a60ea9", [:mix], [{:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "20313a65516db88006f85b090f6f76cc5b04e9609b45943657e6781eb91174f4"}, "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "inet_cidr": {:hex, :inet_cidr, "1.0.8", "d26bb7bdbdf21ae401ead2092bf2bb4bf57fe44a62f5eaa5025280720ace8a40", [:mix], [], "hexpm", "d5b26da66603bb56c933c65214c72152f0de9a6ea53618b56d63302a68f6a90e"}, + "inet_cidr": {:hex, :inet_cidr, "1.0.9", "e0ef72a2942529da78c8e4147d53f2ef5f6f5293335c3637b0fdf83c012cc816", [:mix], [], "hexpm", "172da15ff7cf635b1feaf14f5818be28c811b37cc5fb7c5f7c01058c1c1066cc"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, @@ -132,7 +132,7 @@ "quic": {:hex, :quic, "0.10.2", "4b390507a85f65ce47808f3df1a864e0baf9adb7a1b4ea9f4dcd66fe9d0cb166", [:rebar3], [], "hexpm", "7c196a66973c877a59768a5687f0a0610ff11817254d0a4e45cc4e3a16b1d00b"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, - "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8", [ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"]}, + "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, "rustler": {:hex, :rustler, "0.30.0", "cefc49922132b072853fa9b0ca4dc2ffcb452f68fb73b779042b02d545e097fb", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "9ef1abb6a7dda35c47cfc649e6a5a61663af6cf842a55814a554a84607dee389"}, "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, diff --git a/test/pleroma/web/plugs/remote_ip_test.exs b/test/pleroma/web/plugs/remote_ip_test.exs index 37b751370..19e786f8a 100644 --- a/test/pleroma/web/plugs/remote_ip_test.exs +++ b/test/pleroma/web/plugs/remote_ip_test.exs @@ -106,4 +106,38 @@ defmodule Pleroma.Web.Plugs.RemoteIpTest do assert conn.remote_ip == {1, 1, 1, 1} end + + test "reserved ranges are configurable" do + clear_config([RemoteIp, :reserved], []) + + conn = + conn(:get, "/") + |> put_req_header("x-forwarded-for", "1.1.1.1, 10.0.0.3") + |> RemoteIp.call(nil) + + assert conn.remote_ip == {10, 0, 0, 3} + end + + test "clients override reserved ranges" do + clear_config([RemoteIp, :clients], ["10.0.0.0/8"]) + + conn = + conn(:get, "/") + |> put_req_header("x-forwarded-for", "1.1.1.1, 10.0.0.3") + |> RemoteIp.call(nil) + + assert conn.remote_ip == {10, 0, 0, 3} + end + + test "clients override proxies" do + clear_config([RemoteIp, :clients], ["10.0.0.3"]) + clear_config([RemoteIp, :proxies], ["10.0.0.0/8"]) + + conn = + conn(:get, "/") + |> put_req_header("x-forwarded-for", "1.1.1.1, 10.0.0.3") + |> RemoteIp.call(nil) + + assert conn.remote_ip == {10, 0, 0, 3} + end end From 7ab9e2c7cecf5f53fb5d46037dd52260fe42d762 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 13 May 2026 23:05:25 +0400 Subject: [PATCH 072/195] Use pleroma_captcha Hex package --- changelog.d/captcha-package.fix | 1 + mix.exs | 4 +--- mix.lock | 2 +- test/pleroma/captcha_test.exs | 8 ++++++++ 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 changelog.d/captcha-package.fix diff --git a/changelog.d/captcha-package.fix b/changelog.d/captcha-package.fix new file mode 100644 index 000000000..98c2f6525 --- /dev/null +++ b/changelog.d/captcha-package.fix @@ -0,0 +1 @@ +Switch native captcha to the published pleroma_captcha Hex package. diff --git a/mix.exs b/mix.exs index 946bf0ab0..17ca278be 100644 --- a/mix.exs +++ b/mix.exs @@ -185,9 +185,7 @@ defmodule Pleroma.Mixfile do {:remote_ip, git: "https://git.pleroma.social/pleroma/remote_ip.git", ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"}, - {:captcha, - git: "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", - ref: "e7b7cc34cc16b383461b966484c297e4ec9aeef6"}, + {:captcha, "~> 1.0.3", hex: :pleroma_captcha}, {:restarter, path: "./restarter"}, {:majic, "~> 1.2"}, {:open_api_spex, "~> 3.22"}, diff --git a/mix.lock b/mix.lock index f33c22482..d20cc408d 100644 --- a/mix.lock +++ b/mix.lock @@ -10,7 +10,7 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cachex": {:hex, :cachex, "3.6.0", "14a1bfbeee060dd9bec25a5b6f4e4691e3670ebda28c8ba2884b12fe30b36bf8", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "ebf24e373883bc8e0c8d894a63bbe102ae13d918f790121f5cfe6e485cc8e2e2"}, "calendar": {:hex, :calendar, "1.0.0", "f52073a708528482ec33d0a171954ca610fe2bd28f1e871f247dc7f1565fa807", [:mix], [{:tzdata, "~> 0.1.201603 or ~> 0.5.20 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "990e9581920c82912a5ee50e62ff5ef96da6b15949a2ee4734f935fdef0f0a6f"}, - "captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "e7b7cc34cc16b383461b966484c297e4ec9aeef6", [ref: "e7b7cc34cc16b383461b966484c297e4ec9aeef6"]}, + "captcha": {:hex, :pleroma_captcha, "1.0.3", "6cc1440e91a092653fe909f028cc4c5d83ea858b1439e3b9a85e446382e2b9a3", [:make, :mix], [], "hexpm", "477f62c1a845a9458c546658223295f958f98136acef89c05beb278b1b6f4a14"}, "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, diff --git a/test/pleroma/captcha_test.exs b/test/pleroma/captcha_test.exs index ec4c713f8..0f0a9bc87 100644 --- a/test/pleroma/captcha_test.exs +++ b/test/pleroma/captcha_test.exs @@ -50,6 +50,14 @@ defmodule Pleroma.CaptchaTest do end describe "Native" do + test "uses the published pleroma_captcha package" do + deps = Mix.Project.config() |> Keyword.fetch!(:deps) + assert {:captcha, "~> 1.0.3", opts} = Enum.find(deps, &(elem(&1, 0) == :captcha)) + assert opts[:hex] == :pleroma_captcha + refute Keyword.has_key?(opts, :compile) + refute Keyword.has_key?(opts, :git) + end + test "new and validate" do new = Native.new() From 9ae1249ccb821607a1babf23cf12eba3d633e6e9 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 13 May 2026 23:09:29 +0400 Subject: [PATCH 073/195] Remove captcha dependency shape test --- test/pleroma/captcha_test.exs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/pleroma/captcha_test.exs b/test/pleroma/captcha_test.exs index 0f0a9bc87..ec4c713f8 100644 --- a/test/pleroma/captcha_test.exs +++ b/test/pleroma/captcha_test.exs @@ -50,14 +50,6 @@ defmodule Pleroma.CaptchaTest do end describe "Native" do - test "uses the published pleroma_captcha package" do - deps = Mix.Project.config() |> Keyword.fetch!(:deps) - assert {:captcha, "~> 1.0.3", opts} = Enum.find(deps, &(elem(&1, 0) == :captcha)) - assert opts[:hex] == :pleroma_captcha - refute Keyword.has_key?(opts, :compile) - refute Keyword.has_key?(opts, :git) - end - test "new and validate" do new = Native.new() From 143f426e840de86e9a90a436f8446cc8534e165b Mon Sep 17 00:00:00 2001 From: Irrlicht Date: Wed, 9 Apr 2025 23:59:32 +0000 Subject: [PATCH 074/195] Translated using Weblate (French) Currently translated at 10.8% (106 of 974 strings) Translation: Pleroma/Pleroma Backend (domain config_descriptions) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-config_descriptions/fr/ --- .../fr/LC_MESSAGES/config_descriptions.po | 116 ++++++++++-------- 1 file changed, 66 insertions(+), 50 deletions(-) diff --git a/priv/gettext/fr/LC_MESSAGES/config_descriptions.po b/priv/gettext/fr/LC_MESSAGES/config_descriptions.po index c24ab6751..56ea1bdf3 100644 --- a/priv/gettext/fr/LC_MESSAGES/config_descriptions.po +++ b/priv/gettext/fr/LC_MESSAGES/config_descriptions.po @@ -3,8 +3,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-07-22 02:09+0300\n" -"PO-Revision-Date: 2024-10-13 21:03+0000\n" -"Last-Translator: Codimp \n" +"PO-Revision-Date: 2025-04-11 01:04+0000\n" +"Last-Translator: Irrlicht \n" "Language-Team: French \n" "Language: fr\n" @@ -402,7 +402,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:shout" msgid "Pleroma shout settings" -msgstr "" +msgstr "Paramètre de Pleroma shout" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -416,7 +416,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:streamer" msgid "Settings for notifications streamer" -msgstr "" +msgstr "Paramètres des streamers de notifications" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -429,12 +429,14 @@ msgstr "Paramètres liés au schémas d'URI" msgctxt "config description at :pleroma-:web_cache_ttl" msgid "The expiration time for the web responses cache. Values should be in milliseconds or `nil` to disable expiration." msgstr "" +"Temps d'expiration pour le cache des réponses web. Les valeurs doivent être " +"en millisecondes ou `nil`pour désactiver l'expiration." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:welcome" msgid "Welcome messages settings" -msgstr "" +msgstr "Paramètres de messages de bienvenue" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -458,133 +460,147 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Captcha" msgid "Captcha-related settings" -msgstr "" +msgstr "Paramètres liés au Captcha" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Captcha.Kocaptcha" msgid "Kocaptcha is a very simple captcha service with a single API endpoint, the source code is here: https://github.com/koto-bank/kocaptcha. The default endpoint (https://captcha.kotobank.ch) is hosted by the developer." msgstr "" +"Kocaptcha est un service de captcha très simple avec une API n'utilisant " +"qu'une seule ressource, le code source est ici: https://github.com/koto-bank/" +"kocaptcha.\n" +"La ressource par défaut (https://captcha.kotobank.ch) est hébergée par son " +"développeur." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Emails.Mailer" msgid "Mailer-related settings" -msgstr "" +msgstr "Paramètres liés à l'envoyeur d'email" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Emails.NewUsersDigestEmail" msgid "New users admin email digest" -msgstr "" +msgstr "Email à l'administrateur de résumé des nouveaux utilisateurs" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Emails.UserEmail" msgid "Email template settings" -msgstr "" +msgstr "Paramètres de template d'email" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Formatter" msgid "Configuration for Pleroma's link formatter which parses mentions, hashtags, and URLs." msgstr "" +"Configuration du formateur de lien Pleroma qui interprète les mentions, " +"hashtags et URLs." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.ScheduledActivity" msgid "Scheduled activities settings" -msgstr "" +msgstr "Paramètres des activités planifiées" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Upload" msgid "Upload general settings" -msgstr "" +msgstr "Paramètres généraux d'upload" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Upload.Filter.AnonymizeFilename" msgid "Filter replaces the filename of the upload" -msgstr "" +msgstr "Filtre de remplacement du nom de fichier de l'upload" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Upload.Filter.Mogrify" msgid "Uploads mogrify filter settings" -msgstr "" +msgstr "Paramètres de filtre mogrify pour les uploads" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Uploaders.Local" msgid "Local uploader-related settings" -msgstr "" +msgstr "Paramètres liés aux uploads locaux" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Uploaders.S3" msgid "S3 uploader-related settings" -msgstr "" +msgstr "Paramètres liés à l'uploader S3" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.User.Backup" msgid "Account Backup" -msgstr "" +msgstr "Sauvegarde de Comptes" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Web.MediaProxy.Invalidation.Http" msgid "HTTP invalidate settings" -msgstr "" +msgstr "Paramètres d'invalidation HTTP" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Web.MediaProxy.Invalidation.Script" msgid "Invalidation script settings" -msgstr "" +msgstr "Paramètres du script d'invalidation" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Web.Metadata" msgid "Metadata-related settings" -msgstr "" +msgstr "Paramètres liés aux métadonnées" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Web.Plugs.RemoteIp" msgid "`Pleroma.Web.Plugs.RemoteIp` is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration.\n**If your instance is not behind at least one reverse proxy, you should not enable this plug.**\n" msgstr "" +"`Pleroma.Web.Plugs.RemoteIp` est un shim pour invoquer " +"[`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) mais avec une " +"configuration au runtime.\n" +"**Si votre instance n'est pas derrière au moins un proxy inverse, vous ne " +"devriez pas activer ce plug.**\n" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Web.Preload" msgid "Preload-related settings" -msgstr "" +msgstr "Paramètres liés au préchargement" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Workers.PurgeExpiredActivity" msgid "Expired activities settings" -msgstr "" +msgstr "Paramètres des activités expirées" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :prometheus-Pleroma.Web.Endpoint.MetricsExporter" msgid "Prometheus app metrics endpoint configuration" -msgstr "" +msgstr "Configuration des endpoints de métriques Prometheus" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :web_push_encryption-:vapid_details" msgid "Web Push Notifications configuration. You can use the mix task mix web_push.gen.keypair to generate it." msgstr "" +"Configuration des notifications Web Push. Vous pouvez utiliser la tâche mix " +"web_push.gen.keypair pour le générer." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :ex_aws-:s3" msgid "S3" -msgstr "" +msgstr "S3" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -596,13 +612,13 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config label at :logger-:ex_syslogger" msgid "ExSyslogger" -msgstr "" +msgstr "ExSyslogger" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:activitypub" msgid "ActivityPub" -msgstr "" +msgstr "ActivityPub" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -620,151 +636,151 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:connections_pool" msgid "Connections pool" -msgstr "" +msgstr "Pool de connexions" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:email_notifications" msgid "Email notifications" -msgstr "" +msgstr "Notifications email" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:emoji" msgid "Emoji" -msgstr "" +msgstr "Emoji" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:features" msgid "Features" -msgstr "" +msgstr "Fonctionnalités" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:feed" msgid "Feed" -msgstr "" +msgstr "Flux" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:frontend_configurations" msgid "Frontend configurations" -msgstr "" +msgstr "Configurations des frontends" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:frontends" msgid "Frontends" -msgstr "" +msgstr "Frontends" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:gopher" msgid "Gopher" -msgstr "" +msgstr "Gopher" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:hackney_pools" msgid "Hackney pools" -msgstr "" +msgstr "Bacs (pools) d'Hackney" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:http" msgid "HTTP" -msgstr "" +msgstr "HTTP" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:http_security" msgid "HTTP security" -msgstr "" +msgstr "Sécurité HTTP" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:instance" msgid "Instance" -msgstr "" +msgstr "Instance" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:instances_favicons" msgid "Instances favicons" -msgstr "" +msgstr "Favicons des instances" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:ldap" msgid "LDAP" -msgstr "" +msgstr "LDAP" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:majic_pool" msgid "Majic pool" -msgstr "" +msgstr "Bac (pool) de Majic" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:manifest" msgid "Manifest" -msgstr "" +msgstr "Manifeste" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:markup" msgid "Markup Settings" -msgstr "" +msgstr "Paramètres des Balises" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:media_preview_proxy" msgid "Media preview proxy" -msgstr "" +msgstr "Proxy de prévisualisation média" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:media_proxy" msgid "Media proxy" -msgstr "" +msgstr "Proxy média" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:modules" msgid "Modules" -msgstr "" +msgstr "Modules" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf" msgid "MRF" -msgstr "" +msgstr "MRF" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_activity_expiration" msgid "MRF Activity Expiration Policy" -msgstr "" +msgstr "Politique MRF d'Expiration des Activités" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_follow_bot" msgid "MRF FollowBot Policy" -msgstr "" +msgstr "Politique MRF FollowBot" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_hashtag" msgid "MRF Hashtag" -msgstr "" +msgstr "Politique MRF hashtag" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_hellthread" msgid "MRF Hellthread" -msgstr "" +msgstr "MRF Hellthread" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format From 7fff19cbe4f337deb2e4b58bdb0223d4b553c120 Mon Sep 17 00:00:00 2001 From: Neko Nekowazarashi Date: Wed, 30 Jul 2025 13:30:45 +0000 Subject: [PATCH 075/195] Translated using Weblate (Indonesian) Currently translated at 77.8% (74 of 95 strings) Translation: Pleroma/Pleroma Backend (domain errors) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-errors/id/ --- priv/gettext/id/LC_MESSAGES/errors.po | 58 +++++++++++++-------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/priv/gettext/id/LC_MESSAGES/errors.po b/priv/gettext/id/LC_MESSAGES/errors.po index bc98273be..5bb9d56c6 100644 --- a/priv/gettext/id/LC_MESSAGES/errors.po +++ b/priv/gettext/id/LC_MESSAGES/errors.po @@ -3,16 +3,16 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-08-15 15:40+0000\n" -"PO-Revision-Date: 2021-09-03 06:45+0000\n" -"Last-Translator: @liimee \n" +"PO-Revision-Date: 2025-07-31 05:58+0000\n" +"Last-Translator: Neko Nekowazarashi \n" "Language-Team: Indonesian \n" +"pleroma-backend-domain-errors/id/>\n" "Language: id\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -"X-Generator: Weblate 4.6.2\n" +"X-Generator: Weblate 4.13.1\n" ## This file is a PO Template file. ## @@ -41,7 +41,7 @@ msgstr "memiliki format yang tidak valid" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" -msgstr "" +msgstr "ada yang tidak valid" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" @@ -49,7 +49,7 @@ msgstr "" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" -msgstr "" +msgstr "tidak sama dengan konfirmasi" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" @@ -61,19 +61,19 @@ msgstr "" ## From Ecto.Changeset.validate_length/3 msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" -msgstr[0] "harus memiliki %{count} karakter" +msgstr[0] "harus ada %{count} karakter" msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" -msgstr[0] "harus memiliki %{count} item" +msgstr[0] "harus ada %{count} item" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" -msgstr[0] "harus memiliki sekurang-kurangnya %{count} karakter" +msgstr[0] "harus ada sekurang-kurangnya %{count} karakter" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" -msgstr[0] "harus memiliki sekurang-kurangnya %{count} item" +msgstr[0] "harus ada sekurang-kurangnya %{count} item" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" @@ -112,7 +112,7 @@ msgstr "Sudah memilih" #: lib/pleroma/web/oauth/oauth_controller.ex:359 #, elixir-format msgid "Bad request" -msgstr "Permintaan buruk (bad request)" +msgstr "Permintaan buruk" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:426 #, elixir-format @@ -133,7 +133,7 @@ msgstr "Tidak dapat mencari pengguna" #: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:61 #, elixir-format msgid "Can't get favorites" -msgstr "Tidak dapat mendapatkan favorit" +msgstr "Tidak dapat mengambil favorit" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:438 #, elixir-format @@ -143,7 +143,7 @@ msgstr "Tidak dapat menyukai objek" #: lib/pleroma/web/common_api/utils.ex:563 #, elixir-format msgid "Cannot post an empty status without attachments" -msgstr "Tidak dapat memposting status kosong tanpa lampiran" +msgstr "Tidak dapat mempos status kosong tanpa lampiran" #: lib/pleroma/web/common_api/utils.ex:511 #, elixir-format @@ -206,7 +206,7 @@ msgstr "CAPTCHA tidak valid" #: lib/pleroma/web/oauth/oauth_controller.ex:568 #, elixir-format msgid "Invalid credentials" -msgstr "Kredensian tidak valid" +msgstr "Kredensial tidak valid" #: lib/pleroma/plugs/ensure_authenticated_plug.ex:38 #, elixir-format @@ -279,12 +279,12 @@ msgstr "" #: lib/pleroma/web/ostatus/ostatus_controller.ex:149 #, elixir-format msgid "Something went wrong" -msgstr "Sesuatu yang salah terjadi" +msgstr "Ada sesuatu yang salah" #: lib/pleroma/web/common_api/activity_draft.ex:107 #, elixir-format msgid "The message visibility must be direct" -msgstr "Visibilitas pesan harus langsung" +msgstr "Ketampakan pesan harus langsung" #: lib/pleroma/web/common_api/utils.ex:573 #, elixir-format @@ -294,7 +294,7 @@ msgstr "Status lebih dari batas karakter" #: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 #, elixir-format msgid "This resource requires authentication." -msgstr "" +msgstr "Autentikasi diperlukan untuk hal ini." #: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 #, elixir-format @@ -314,7 +314,7 @@ msgstr "" #: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:485 #, elixir-format msgid "You can't revoke your own admin status." -msgstr "" +msgstr "Anda tidak dapat mencabut status admin Anda." #: lib/pleroma/web/oauth/oauth_controller.ex:221 #: lib/pleroma/web/oauth/oauth_controller.ex:308 @@ -382,7 +382,7 @@ msgstr "Gagal" #: lib/pleroma/web/oauth/oauth_controller.ex:410 #, elixir-format msgid "Failed to authenticate: %{message}." -msgstr "Gagal mengotentikasi: %{message}." +msgstr "Gagal mengautentikasi: %{message}." #: lib/pleroma/web/oauth/oauth_controller.ex:441 #, elixir-format @@ -392,7 +392,7 @@ msgstr "Gagal menyiapkan akun pengguna." #: lib/pleroma/plugs/oauth_scopes_plug.ex:38 #, elixir-format msgid "Insufficient permissions: %{permissions}." -msgstr "" +msgstr "Izin tidak cukup: %{permissions}." #: lib/pleroma/plugs/uploaded_media.ex:104 #, elixir-format @@ -418,12 +418,12 @@ msgstr "" #: lib/pleroma/web/oauth/oauth_controller.ex:172 #, elixir-format msgid "This action is outside the authorized scopes" -msgstr "" +msgstr "Tindakan ini diluar jangkauan yang terotorisasi" #: lib/pleroma/web/oauth/fallback_controller.ex:14 #, elixir-format msgid "Unknown error, please check the details and try again." -msgstr "Kesalahan tidak dikenal, harap periksa keterangannya dan coba lagi." +msgstr "Kesalahan tidak dikenal, harap periksa detailnya dan coba lagi." #: lib/pleroma/web/oauth/oauth_controller.ex:119 #: lib/pleroma/web/oauth/oauth_controller.ex:158 @@ -444,7 +444,7 @@ msgstr "" #: lib/pleroma/web/uploader_controller.ex:23 #, elixir-format msgid "bad request" -msgstr "permintaan buruk (bad request)" +msgstr "permintaan buruk" #: lib/pleroma/web/twitter_api/twitter_api.ex:103 #, elixir-format @@ -469,7 +469,7 @@ msgstr "CAPTCHA Tidak Valid (Parameter kurang: %{name})" #: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 #, elixir-format msgid "List not found" -msgstr "" +msgstr "Daftar tidak ditemukan" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:123 #, elixir-format @@ -480,7 +480,7 @@ msgstr "Parameter kurang: %{name}" #: lib/pleroma/web/oauth/oauth_controller.ex:321 #, elixir-format msgid "Password reset is required" -msgstr "" +msgstr "Diperlukan atur ulang kata sandi" #: lib/pleroma/tests/auth_test_controller.ex:9 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:6 @@ -522,7 +522,7 @@ msgstr "" #: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 #, elixir-format msgid "Two-factor authentication enabled, you must use a access token." -msgstr "Otentikasi dua-faktor diaktifkan, Anda harus menggunakan token akses." +msgstr "Autentikasi dua langkah diaktifkan, Anda harus menggunakan token akses." #: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:210 #, elixir-format @@ -552,7 +552,7 @@ msgstr "" #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 #, elixir-format msgid "Web push subscription is disabled on this Pleroma instance" -msgstr "" +msgstr "Langganan push web dinonaktifkan untuk peladen Pleroma ini" #: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:451 #, elixir-format @@ -562,7 +562,7 @@ msgstr "Anda tidak bisa mencabut status admin/moderator Anda sendiri." #: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:126 #, elixir-format msgid "authorization required for timeline view" -msgstr "" +msgstr "Otorisasi diperlukan untuk tampilan linimasa" #: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:24 #, elixir-format @@ -572,7 +572,7 @@ msgstr "Akses ditolak" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:282 #, elixir-format msgid "This API requires an authenticated user" -msgstr "" +msgstr "API ini memerlukan pengguna terautentikasi" #: lib/pleroma/plugs/user_is_admin_plug.ex:21 #, elixir-format From 526365364bf98da6172f60732396e5a87178b1f7 Mon Sep 17 00:00:00 2001 From: Neko Nekowazarashi Date: Mon, 11 Aug 2025 15:40:00 +0000 Subject: [PATCH 076/195] Added translation using Weblate (Indonesian) --- priv/gettext/id/LC_MESSAGES/posix_errors.po | 163 ++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 priv/gettext/id/LC_MESSAGES/posix_errors.po diff --git a/priv/gettext/id/LC_MESSAGES/posix_errors.po b/priv/gettext/id/LC_MESSAGES/posix_errors.po new file mode 100644 index 000000000..f5ea93285 --- /dev/null +++ b/priv/gettext/id/LC_MESSAGES/posix_errors.po @@ -0,0 +1,163 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-11 18:40+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: id\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Translate Toolkit 3.7.2\n" + +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit them in PO (`.po`) files instead. +msgid "eperm" +msgstr "" + +msgid "eacces" +msgstr "" + +msgid "eagain" +msgstr "" + +msgid "ebadf" +msgstr "" + +msgid "ebadmsg" +msgstr "" + +msgid "ebusy" +msgstr "" + +msgid "edeadlk" +msgstr "" + +msgid "edeadlock" +msgstr "" + +msgid "edquot" +msgstr "" + +msgid "eexist" +msgstr "" + +msgid "efault" +msgstr "" + +msgid "efbig" +msgstr "" + +msgid "eftype" +msgstr "" + +msgid "eintr" +msgstr "" + +msgid "einval" +msgstr "" + +msgid "eio" +msgstr "" + +msgid "eisdir" +msgstr "" + +msgid "eloop" +msgstr "" + +msgid "emfile" +msgstr "" + +msgid "emlink" +msgstr "" + +msgid "emultihop" +msgstr "" + +msgid "enametoolong" +msgstr "" + +msgid "enfile" +msgstr "" + +msgid "enobufs" +msgstr "" + +msgid "enodev" +msgstr "" + +msgid "enolck" +msgstr "" + +msgid "enolink" +msgstr "" + +msgid "enoent" +msgstr "" + +msgid "enomem" +msgstr "" + +msgid "enospc" +msgstr "" + +msgid "enosr" +msgstr "" + +msgid "enostr" +msgstr "" + +msgid "enosys" +msgstr "" + +msgid "enotblk" +msgstr "" + +msgid "enotdir" +msgstr "" + +msgid "enotsup" +msgstr "" + +msgid "enxio" +msgstr "" + +msgid "eopnotsupp" +msgstr "" + +msgid "eoverflow" +msgstr "" + +msgid "epipe" +msgstr "" + +msgid "erange" +msgstr "" + +msgid "erofs" +msgstr "" + +msgid "espipe" +msgstr "" + +msgid "esrch" +msgstr "" + +msgid "estale" +msgstr "" + +msgid "etxtbsy" +msgstr "" + +msgid "exdev" +msgstr "" From 4fef91030308b0d73d085cb10b2e49790f322ca1 Mon Sep 17 00:00:00 2001 From: Neko Nekowazarashi Date: Mon, 11 Aug 2025 15:40:23 +0000 Subject: [PATCH 077/195] Added translation using Weblate (Indonesian) --- priv/gettext/id/LC_MESSAGES/static_pages.po | 574 ++++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 priv/gettext/id/LC_MESSAGES/static_pages.po diff --git a/priv/gettext/id/LC_MESSAGES/static_pages.po b/priv/gettext/id/LC_MESSAGES/static_pages.po new file mode 100644 index 000000000..e2565888a --- /dev/null +++ b/priv/gettext/id/LC_MESSAGES/static_pages.po @@ -0,0 +1,574 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-11 18:40+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: id\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Translate Toolkit 3.7.2\n" + +## This file is a PO Template file. +## +## "msgid"s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run "mix gettext.extract" to bring this file up to +## date. Leave "msgstr"s empty as changing them here as no +## effect: edit them in PO (.po) files instead. + +#: lib/pleroma/web/templates/twitter_api/remote_follow/follow.html.eex:9 +#, elixir-autogen, elixir-format +msgctxt "remote follow authorization button" +msgid "Authorize" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/remote_follow/follow.html.eex:2 +#, elixir-autogen, elixir-format +msgctxt "remote follow error" +msgid "Error fetching user" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/remote_follow/follow.html.eex:4 +#, elixir-autogen, elixir-format +msgctxt "remote follow header" +msgid "Remote follow" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex:8 +#, elixir-autogen, elixir-format +msgctxt "placeholder text for auth code entry" +msgid "Authentication code" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex:10 +#, elixir-autogen, elixir-format +msgctxt "placeholder text for password entry" +msgid "Password" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex:8 +#, elixir-autogen, elixir-format +msgctxt "placeholder text for username entry" +msgid "Username" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex:13 +#, elixir-autogen, elixir-format +msgctxt "remote follow authorization button for login" +msgid "Authorize" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex:12 +#, elixir-autogen, elixir-format +msgctxt "remote follow authorization button for mfa" +msgid "Authorize" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/remote_follow/followed.html.eex:2 +#, elixir-autogen, elixir-format +msgctxt "remote follow error" +msgid "Error following account" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex:4 +#, elixir-autogen, elixir-format +msgctxt "remote follow header, need login" +msgid "Log in to follow" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex:4 +#, elixir-autogen, elixir-format +msgctxt "remote follow mfa header" +msgid "Two-factor authentication" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/remote_follow/followed.html.eex:4 +#, elixir-autogen, elixir-format +msgctxt "remote follow success" +msgid "Account followed!" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/util/status_interact.html.eex:7 +#: lib/pleroma/web/templates/twitter_api/util/subscribe.html.eex:7 +#, elixir-autogen, elixir-format +msgctxt "placeholder text for account id" +msgid "Your account ID, e.g. lain@quitter.se" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/util/subscribe.html.eex:8 +#, elixir-autogen, elixir-format +msgctxt "remote follow authorization button for following with a remote account" +msgid "Follow" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/util/subscribe.html.eex:2 +#, elixir-autogen, elixir-format +msgctxt "remote follow error" +msgid "Error: %{error}" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/util/subscribe.html.eex:4 +#, elixir-autogen, elixir-format +msgctxt "remote follow header" +msgid "Remotely follow %{nickname}" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/password/reset.html.eex:12 +#, elixir-autogen, elixir-format +msgctxt "password reset button" +msgid "Reset" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/password/reset_failed.html.eex:4 +#, elixir-autogen, elixir-format +msgctxt "password reset failed homepage link" +msgid "Homepage" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/password/reset_failed.html.eex:1 +#, elixir-autogen, elixir-format +msgctxt "password reset failed message" +msgid "Password reset failed" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/password/reset.html.eex:8 +#, elixir-autogen, elixir-format +msgctxt "password reset form confirm password prompt" +msgid "Confirmation" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/password/reset.html.eex:4 +#, elixir-autogen, elixir-format +msgctxt "password reset form password prompt" +msgid "Password" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/password/invalid_token.html.eex:1 +#, elixir-autogen, elixir-format +msgctxt "password reset invalid token message" +msgid "Invalid Token" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/password/reset_success.html.eex:2 +#, elixir-autogen, elixir-format +msgctxt "password reset successful homepage link" +msgid "Homepage" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/password/reset_success.html.eex:1 +#, elixir-autogen, elixir-format +msgctxt "password reset successful message" +msgid "Password changed!" +msgstr "" + +#: lib/pleroma/web/templates/feed/feed/tag.atom.eex:12 +#: lib/pleroma/web/templates/feed/feed/tag.rss.eex:8 +#, elixir-autogen, elixir-format +msgctxt "tag feed description" +msgid "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse." +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/oob_token_exists.html.eex:1 +#, elixir-autogen, elixir-format +msgctxt "oauth authorization exists page title" +msgid "Authorization exists" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/show.html.eex:32 +#, elixir-autogen, elixir-format +msgctxt "oauth authorize approve button" +msgid "Approve" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/show.html.eex:30 +#, elixir-autogen, elixir-format +msgctxt "oauth authorize cancel button" +msgid "Cancel" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/show.html.eex:23 +#, elixir-autogen, elixir-format +msgctxt "oauth authorize message" +msgid "Application %{client_name} is requesting access to your account." +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/oob_authorization_created.html.eex:1 +#, elixir-autogen, elixir-format +msgctxt "oauth authorized page title" +msgid "Successfully authorized" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex:1 +#, elixir-autogen, elixir-format +msgctxt "oauth external provider page title" +msgid "Sign in with external provider" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex:13 +#, elixir-autogen, elixir-format +msgctxt "oauth external provider sign in button" +msgid "Sign in with %{strategy}" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/show.html.eex:54 +#, elixir-autogen, elixir-format +msgctxt "oauth login button" +msgid "Log In" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/show.html.eex:51 +#, elixir-autogen, elixir-format +msgctxt "oauth login password prompt" +msgid "Password" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/show.html.eex:47 +#, elixir-autogen, elixir-format +msgctxt "oauth login username prompt" +msgid "Username" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/show.html.eex:39 +#, elixir-autogen, elixir-format +msgctxt "oauth register nickname prompt" +msgid "Pleroma Handle" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/show.html.eex:37 +#, elixir-autogen, elixir-format +msgctxt "oauth register nickname unchangeable warning" +msgid "Choose carefully! You won't be able to change this later. You will be able to change your display name, though." +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/register.html.eex:18 +#, elixir-autogen, elixir-format +msgctxt "oauth register page email prompt" +msgid "Email" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/register.html.eex:10 +#, elixir-autogen, elixir-format +msgctxt "oauth register page fill form prompt" +msgid "If you'd like to register a new account, please provide the details below." +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/register.html.eex:35 +#, elixir-autogen, elixir-format +msgctxt "oauth register page login button" +msgid "Proceed as existing user" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/register.html.eex:31 +#, elixir-autogen, elixir-format +msgctxt "oauth register page login password prompt" +msgid "Password" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/register.html.eex:24 +#, elixir-autogen, elixir-format +msgctxt "oauth register page login prompt" +msgid "Alternatively, sign in to connect to existing account." +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/register.html.eex:27 +#, elixir-autogen, elixir-format +msgctxt "oauth register page login username prompt" +msgid "Name or email" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/register.html.eex:14 +#, elixir-autogen, elixir-format +msgctxt "oauth register page nickname prompt" +msgid "Nickname" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/register.html.eex:22 +#, elixir-autogen, elixir-format +msgctxt "oauth register page register button" +msgid "Proceed as new user" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/register.html.eex:8 +#, elixir-autogen, elixir-format +msgctxt "oauth register page title" +msgid "Registration Details" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/show.html.eex:36 +#, elixir-autogen, elixir-format +msgctxt "oauth register page title" +msgid "This is the first time you visit! Please enter your Pleroma handle." +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/_scopes.html.eex:2 +#, elixir-autogen, elixir-format +msgctxt "oauth scopes message" +msgid "The following permissions will be granted" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/o_auth/oob_authorization_created.html.eex:2 +#: lib/pleroma/web/templates/o_auth/o_auth/oob_token_exists.html.eex:2 +#, elixir-autogen, elixir-format +msgctxt "oauth token code message" +msgid "Token code is
%{token}" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/mfa/totp.html.eex:12 +#, elixir-autogen, elixir-format +msgctxt "mfa auth code prompt" +msgid "Authentication code" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/mfa/totp.html.eex:8 +#, elixir-autogen, elixir-format +msgctxt "mfa auth page title" +msgid "Two-factor authentication" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/mfa/totp.html.eex:23 +#, elixir-autogen, elixir-format +msgctxt "mfa auth page use recovery code link" +msgid "Enter a two-factor recovery code" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/mfa/totp.html.eex:20 +#, elixir-autogen, elixir-format +msgctxt "mfa auth verify code button" +msgid "Verify" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex:8 +#, elixir-autogen, elixir-format +msgctxt "mfa recover page title" +msgid "Two-factor recovery" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex:12 +#, elixir-autogen, elixir-format +msgctxt "mfa recover recovery code prompt" +msgid "Recovery code" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex:23 +#, elixir-autogen, elixir-format +msgctxt "mfa recover use 2fa code link" +msgid "Enter a two-factor code" +msgstr "" + +#: lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex:20 +#, elixir-autogen, elixir-format +msgctxt "mfa recover verify recovery code button" +msgid "Verify" +msgstr "" + +#: lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex:8 +#, elixir-autogen, elixir-format +msgctxt "static fe profile page remote follow button" +msgid "Remote follow" +msgstr "" + +#: lib/pleroma/web/templates/email/digest.html.eex:163 +#, elixir-autogen, elixir-format +msgctxt "digest email header line" +msgid "Hey %{nickname}, here is what you've missed!" +msgstr "" + +#: lib/pleroma/web/templates/email/digest.html.eex:544 +#, elixir-autogen, elixir-format +msgctxt "digest email receiver address" +msgid "The email address you are subscribed as is %{email}. " +msgstr "" + +#: lib/pleroma/web/templates/email/digest.html.eex:538 +#, elixir-autogen, elixir-format +msgctxt "digest email sending reason" +msgid "You have received this email because you have signed up to receive digest emails from %{instance} Pleroma instance." +msgstr "" + +#: lib/pleroma/web/templates/email/digest.html.eex:547 +#, elixir-autogen, elixir-format +msgctxt "digest email unsubscribe action" +msgid "To unsubscribe, please go %{here}." +msgstr "" + +#: lib/pleroma/web/templates/email/digest.html.eex:547 +#, elixir-autogen, elixir-format +msgctxt "digest email unsubscribe action link text" +msgid "here" +msgstr "" + +#: lib/pleroma/web/templates/mailer/subscription/unsubscribe_failure.html.eex:1 +#, elixir-autogen, elixir-format +msgctxt "mailer unsubscribe failed message" +msgid "UNSUBSCRIBE FAILURE" +msgstr "" + +#: lib/pleroma/web/templates/mailer/subscription/unsubscribe_success.html.eex:1 +#, elixir-autogen, elixir-format +msgctxt "mailer unsubscribe successful message" +msgid "UNSUBSCRIBE SUCCESSFUL" +msgstr "" + +#: lib/pleroma/web/templates/email/digest.html.eex:385 +#, elixir-format +msgctxt "new followers count header" +msgid "%{count} New Follower" +msgid_plural "%{count} New Followers" +msgstr[0] "" +msgstr[1] "" + +#: lib/pleroma/emails/user_email.ex:356 +#, elixir-autogen, elixir-format +msgctxt "account archive email body - self-requested" +msgid "

You requested a full backup of your Pleroma account. It's ready for download:

\n

%{download_url}

\n" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:384 +#, elixir-autogen, elixir-format +msgctxt "account archive email subject" +msgid "Your account archive is ready" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:188 +#, elixir-autogen, elixir-format +msgctxt "approval pending email body" +msgid "

Awaiting Approval

\n

Your account at %{instance_name} is being reviewed by staff. You will receive another email once your account is approved.

\n" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:202 +#, elixir-autogen, elixir-format +msgctxt "approval pending email subject" +msgid "Your account is awaiting approval" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:158 +#, elixir-autogen, elixir-format +msgctxt "confirmation email body" +msgid "

Thank you for registering on %{instance_name}

\n

Email confirmation is required to activate the account.

\n

Please click the following link to activate your account.

\n" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:174 +#, elixir-autogen, elixir-format +msgctxt "confirmation email subject" +msgid "%{instance_name} account confirmation" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:310 +#, elixir-autogen, elixir-format +msgctxt "digest email subject" +msgid "Your digest from %{instance_name}" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:81 +#, elixir-autogen, elixir-format +msgctxt "password reset email body" +msgid "

Reset your password at %{instance_name}

\n

Someone has requested password change for your account at %{instance_name}.

\n

If it was you, visit the following link to proceed: reset password.

\n

If it was someone else, nothing to worry about: your data is secure and your password has not been changed.

\n" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:98 +#, elixir-autogen, elixir-format +msgctxt "password reset email subject" +msgid "Password reset" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:215 +#, elixir-autogen, elixir-format +msgctxt "successful registration email body" +msgid "

Hello @%{nickname},

\n

Your account at %{instance_name} has been registered successfully.

\n

No further action is required to activate your account.

\n" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:231 +#, elixir-autogen, elixir-format +msgctxt "successful registration email subject" +msgid "Account registered on %{instance_name}" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:119 +#, elixir-autogen, elixir-format +msgctxt "user invitation email body" +msgid "

You are invited to %{instance_name}

\n

%{inviter_name} invites you to join %{instance_name}, an instance of Pleroma federated social networking platform.

\n

Click the following link to register: accept invitation.

\n" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:136 +#, elixir-autogen, elixir-format +msgctxt "user invitation email subject" +msgid "Invitation to %{instance_name}" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:53 +#, elixir-autogen, elixir-format +msgctxt "welcome email html body" +msgid "Welcome to %{instance_name}!" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:41 +#, elixir-autogen, elixir-format +msgctxt "welcome email subject" +msgid "Welcome to %{instance_name}!" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:65 +#, elixir-autogen, elixir-format +msgctxt "welcome email text body" +msgid "Welcome to %{instance_name}!" +msgstr "" + +#: lib/pleroma/emails/user_email.ex:368 +#, elixir-autogen, elixir-format +msgctxt "account archive email body - admin requested" +msgid "

Admin @%{admin_nickname} requested a full backup of your Pleroma account. It's ready for download:

\n

%{download_url}

\n" +msgstr "" + +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:123 +#, elixir-autogen, elixir-format +msgctxt "remote follow error message - unknown error" +msgid "Something went wrong." +msgstr "" + +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:67 +#, elixir-autogen, elixir-format +msgctxt "remote follow error message - user not found" +msgid "Could not find user" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/util/status_interact.html.eex:8 +#, elixir-autogen, elixir-format +msgctxt "status interact authorization button" +msgid "Interact" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/util/status_interact.html.eex:2 +#, elixir-autogen, elixir-format +msgctxt "status interact error" +msgid "Error: %{error}" +msgstr "" + +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:95 +#, elixir-autogen, elixir-format +msgctxt "status interact error message - status not found" +msgid "Could not find status" +msgstr "" + +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:144 +#, elixir-autogen, elixir-format +msgctxt "status interact error message - unknown error" +msgid "Something went wrong." +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/util/status_interact.html.eex:4 +#, elixir-autogen, elixir-format +msgctxt "status interact header" +msgid "Interacting with %{nickname}'s %{status_link}" +msgstr "" + +#: lib/pleroma/web/templates/twitter_api/util/status_interact.html.eex:4 +#, elixir-autogen, elixir-format +msgctxt "status interact header - status link text" +msgid "status" +msgstr "" From 086c15b5cd4d431bb54974d470de5b81321ebe38 Mon Sep 17 00:00:00 2001 From: Codimp Date: Fri, 26 Dec 2025 18:18:33 +0000 Subject: [PATCH 078/195] Translated using Weblate (French) Currently translated at 100.0% (80 of 80 strings) Translation: Pleroma/Pleroma Backend (domain errors) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-errors/fr/ --- priv/gettext/fr/LC_MESSAGES/errors.po | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/priv/gettext/fr/LC_MESSAGES/errors.po b/priv/gettext/fr/LC_MESSAGES/errors.po index 406f98de9..20636cee3 100644 --- a/priv/gettext/fr/LC_MESSAGES/errors.po +++ b/priv/gettext/fr/LC_MESSAGES/errors.po @@ -8,16 +8,15 @@ ## to merge POT files into PO files. msgid "" msgstr "" -"PO-Revision-Date: 2020-05-12 15:52+0000\n" -"Last-Translator: Haelwenn (lanodan) Monnier " -"\n" +"PO-Revision-Date: 2025-12-27 07:08+0000\n" +"Last-Translator: Codimp \n" "Language-Team: French \n" +"pleroma-backend-domain-errors/fr/>\n" "Language: fr\n" "Content-Type: text/plain; charset=UTF-8\n" -"Plural-Forms: nplurals=2; plural=n > 1;\n" -"X-Generator: Weblate 4.0.4\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.13.1\n" msgid "can't be blank" msgstr "ne peut être vide" @@ -357,7 +356,7 @@ msgstr "Ne peut poster dans la boite d'émission de %{nickname} en tant que %{as #: lib/pleroma/web/common_api/common_api.ex:335 #, elixir-format msgid "conversation is already muted" -msgstr "la conversation est déjà baillonée" +msgstr "la conversation est déjà silenciée" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:192 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:317 @@ -421,7 +420,7 @@ msgstr "Erreur interne" #: lib/pleroma/web/oauth/fallback_controller.ex:29 #, elixir-format msgid "Invalid Username/Password" -msgstr "Nom d'utilisateur/mot de passe invalide" +msgstr "Nom d'utilisateur·ice/mot de passe invalide" #: lib/pleroma/captcha/captcha.ex:107 #, elixir-format From d7a0d97c3687d3a8bac2597a223bb686693aaa8a Mon Sep 17 00:00:00 2001 From: Codimp Date: Fri, 26 Dec 2025 18:20:14 +0000 Subject: [PATCH 079/195] Translated using Weblate (French) Currently translated at 10.8% (106 of 974 strings) Translation: Pleroma/Pleroma Backend (domain config_descriptions) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-config_descriptions/fr/ --- priv/gettext/fr/LC_MESSAGES/config_descriptions.po | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/priv/gettext/fr/LC_MESSAGES/config_descriptions.po b/priv/gettext/fr/LC_MESSAGES/config_descriptions.po index 56ea1bdf3..2e12baa43 100644 --- a/priv/gettext/fr/LC_MESSAGES/config_descriptions.po +++ b/priv/gettext/fr/LC_MESSAGES/config_descriptions.po @@ -3,8 +3,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-07-22 02:09+0300\n" -"PO-Revision-Date: 2025-04-11 01:04+0000\n" -"Last-Translator: Irrlicht \n" +"PO-Revision-Date: 2025-12-27 07:08+0000\n" +"Last-Translator: Codimp \n" "Language-Team: French \n" "Language: fr\n" @@ -280,7 +280,7 @@ msgstr "" "Rejeter, Enlever de TWKN ou marquer comme contenu sensible les messages avec " "des mots-croisillons (sans mettre le # du début)\n" "\n" -"Note: cette politique MRF est toujours activée. Si vous voulez la " +"Note : cette politique MRF est toujours activée. Si vous voulez la " "désactiver, vous devez configurer des listes vides.\n" #: lib/pleroma/docs/translator.ex:5 @@ -468,10 +468,9 @@ msgctxt "config description at :pleroma-Pleroma.Captcha.Kocaptcha" msgid "Kocaptcha is a very simple captcha service with a single API endpoint, the source code is here: https://github.com/koto-bank/kocaptcha. The default endpoint (https://captcha.kotobank.ch) is hosted by the developer." msgstr "" "Kocaptcha est un service de captcha très simple avec une API n'utilisant " -"qu'une seule ressource, le code source est ici: https://github.com/koto-bank/" -"kocaptcha.\n" -"La ressource par défaut (https://captcha.kotobank.ch) est hébergée par son " -"développeur." +"qu'une seule ressource, le code source est ici : https://github.com/" +"koto-bank/kocaptcha. La ressource par défaut (https://captcha.kotobank.ch) " +"est hébergée par son développeur." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format From 784a319f2eb9b98e6e034c38b462dd1e11390359 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 14 May 2026 00:22:24 +0200 Subject: [PATCH 080/195] Woodpecker CI: Bump unit testing pipeline to Elixir 1.19 --- ...t-testing-elixir-1.18.yaml => unit-testing-elixir-1.19.yaml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .woodpecker/{unit-testing-elixir-1.18.yaml => unit-testing-elixir-1.19.yaml} (97%) diff --git a/.woodpecker/unit-testing-elixir-1.18.yaml b/.woodpecker/unit-testing-elixir-1.19.yaml similarity index 97% rename from .woodpecker/unit-testing-elixir-1.18.yaml rename to .woodpecker/unit-testing-elixir-1.19.yaml index 9ad9eebc9..462e2ee5d 100644 --- a/.woodpecker/unit-testing-elixir-1.18.yaml +++ b/.woodpecker/unit-testing-elixir-1.19.yaml @@ -19,7 +19,7 @@ variables: steps: unit-testing-elixir-1.18: - image: elixir:1.18-otp-27-alpine + image: elixir:1.19-otp-28-alpine entrypoint: *script_file_entrypoint environment: MIX_ENV: test From 7575b7abc2b1559dbb2a780ce55e572969d8184f Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 14 May 2026 12:20:27 +0200 Subject: [PATCH 081/195] Woodpecker CI: Do not trigger on Weblate MRs --- .woodpecker/changelog.yaml | 1 + .woodpecker/lint.yaml | 1 + .woodpecker/unit-testing-elixir-1.15.yaml | 1 + .woodpecker/unit-testing-elixir-1.18.yaml | 1 + changelog.d/weblate-ci.skip | 0 5 files changed, 4 insertions(+) create mode 100644 changelog.d/weblate-ci.skip diff --git a/.woodpecker/changelog.yaml b/.woodpecker/changelog.yaml index 64062f17e..a3732c240 100644 --- a/.woodpecker/changelog.yaml +++ b/.woodpecker/changelog.yaml @@ -1,5 +1,6 @@ when: - event: pull_request + evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate"' labels: platform: linux/amd64 diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml index 0ab7441a8..8a5718c5d 100644 --- a/.woodpecker/lint.yaml +++ b/.woodpecker/lint.yaml @@ -1,6 +1,7 @@ when: - event: pull_request path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] + evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate"' - event: push branch: ${CI_REPO_DEFAULT_BRANCH} path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] diff --git a/.woodpecker/unit-testing-elixir-1.15.yaml b/.woodpecker/unit-testing-elixir-1.15.yaml index a4a8fc266..a5bcc0ef3 100644 --- a/.woodpecker/unit-testing-elixir-1.15.yaml +++ b/.woodpecker/unit-testing-elixir-1.15.yaml @@ -1,6 +1,7 @@ when: - event: pull_request path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] + evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate"' - event: push branch: ${CI_REPO_DEFAULT_BRANCH} path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] diff --git a/.woodpecker/unit-testing-elixir-1.18.yaml b/.woodpecker/unit-testing-elixir-1.18.yaml index 9ad9eebc9..6e576fad9 100644 --- a/.woodpecker/unit-testing-elixir-1.18.yaml +++ b/.woodpecker/unit-testing-elixir-1.18.yaml @@ -1,6 +1,7 @@ when: - event: pull_request path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] + evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate"' - event: push branch: ${CI_REPO_DEFAULT_BRANCH} path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] diff --git a/changelog.d/weblate-ci.skip b/changelog.d/weblate-ci.skip new file mode 100644 index 000000000..e69de29bb From 1a13ec539baca66d23db899d92b42c181aaf3149 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Mon, 18 May 2026 21:05:46 +0300 Subject: [PATCH 082/195] reorganize mfm attributes in order they're listed in misskey repo, add missing ones --- priv/scrubbers/default.ex | 49 ++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index 342ef9944..d5c7340d6 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -83,48 +83,55 @@ defmodule Pleroma.HTML.Scrubber.Default do "quote-inline", "invisible", "ellipsis", - "mfm-center", - "mfm-flip", - "mfm-font", - "mfm-blur", - "mfm-rotate", - "mfm-x2", - "mfm-x3", - "mfm-x4", - "mfm-position", - "mfm-scale", - "mfm-fg", - "mfm-bg", + "mfm-tada", "mfm-jelly", "mfm-twitch", "mfm-shake", "mfm-spin", "mfm-jump", "mfm-bounce", + "mfm-flip", + "mfm-x2", + "mfm-x3", + "mfm-x4", + "mfm-scale", + "mfm-position", + "mfm-fg", + "mfm-bg", + "mfm-border", + "mfm-font", + "mfm-blur", "mfm-rainbow", - "mfm-tada", - "mfm-sparkle" + "mfm-sparkle", + "mfm-rotate", + "mfm-ruby", + "mfm-unixtime", ]) Meta.allow_tag_with_this_attribute_values(:p, "class", ["quote-inline"]) Meta.allow_tag_with_these_attributes(:span, [ "lang", - "data-mfm-h", - "data-mfm-v", + "data-mfm-speed", + "data-mfm-delay", + "data-mfm-left", + "data-mfm-alternate", "data-mfm-x", "data-mfm-y", - "data-mfm-alternate", - "data-mfm-speed", - "data-mfm-deg", - "data-mfm-left", + "data-mfm-h", + "data-mfm-v", + "data-mfm-color", + "data-mfm-width", + "data-mfm-style", + "data-mfm-radius", + "data-mfm-noclip", "data-mfm-serif", "data-mfm-monospace", "data-mfm-cursive", "data-mfm-fantasy", "data-mfm-emoji", "data-mfm-math", - "data-mfm-color" + "data-mfm-deg", ]) Meta.allow_tag_with_this_attribute_values(:code, "class", ["inline"]) From c428cf43e8683f5428341845629013504d0057bf Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Mon, 18 May 2026 21:07:12 +0300 Subject: [PATCH 083/195] "changelog" --- changelog.d/mfm-extend.skip | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 changelog.d/mfm-extend.skip diff --git a/changelog.d/mfm-extend.skip b/changelog.d/mfm-extend.skip new file mode 100644 index 000000000..e69de29bb From 46a28086900600d187348b796c486d11575f22da Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Mon, 18 May 2026 21:08:24 +0300 Subject: [PATCH 084/195] extraneous commas --- priv/scrubbers/default.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index d5c7340d6..8963d99e8 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -105,7 +105,7 @@ defmodule Pleroma.HTML.Scrubber.Default do "mfm-sparkle", "mfm-rotate", "mfm-ruby", - "mfm-unixtime", + "mfm-unixtime" ]) Meta.allow_tag_with_this_attribute_values(:p, "class", ["quote-inline"]) @@ -131,7 +131,7 @@ defmodule Pleroma.HTML.Scrubber.Default do "data-mfm-fantasy", "data-mfm-emoji", "data-mfm-math", - "data-mfm-deg", + "data-mfm-deg" ]) Meta.allow_tag_with_this_attribute_values(:code, "class", ["inline"]) From 9dd02ecd5074cc0c164cbae86253d8eea921c787 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 22 May 2026 16:30:14 +0400 Subject: [PATCH 085/195] Fix WebSocket protocol token handshakes --- changelog.d/mastodon-websocket-protocol.fix | 1 + lib/pleroma/web/endpoint.ex | 7 +- .../web/mastodon_api/websocket_handler.ex | 15 +-- .../web/mastodon_api/websocket_plug.ex | 105 ++++++++++++++++++ .../integration/mastodon_websocket_test.exs | 100 ++++++++++++++--- 5 files changed, 200 insertions(+), 28 deletions(-) create mode 100644 changelog.d/mastodon-websocket-protocol.fix create mode 100644 lib/pleroma/web/mastodon_api/websocket_plug.ex diff --git a/changelog.d/mastodon-websocket-protocol.fix b/changelog.d/mastodon-websocket-protocol.fix new file mode 100644 index 000000000..66dab16ed --- /dev/null +++ b/changelog.d/mastodon-websocket-protocol.fix @@ -0,0 +1 @@ +Echo Mastodon-style `Sec-WebSocket-Protocol` tokens in streaming WebSocket handshakes. diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 36e78e1e2..07b33f866 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -9,8 +9,8 @@ defmodule Pleroma.Web.Endpoint do alias Pleroma.Config - socket("/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, - longpoll: false, + plug(Pleroma.Web.MastodonAPI.WebsocketPlug, + path: "/api/v1/streaming", websocket: [ path: "/", compress: false, @@ -169,8 +169,7 @@ defmodule Pleroma.Web.Endpoint do else: "pleroma_key" extra = - Config.get([__MODULE__, :extra_cookie_attrs]) - |> Enum.join(";") + Enum.join(Config.get([__MODULE__, :extra_cookie_attrs]), ";") # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 2b698bd5d..3dc862a5a 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -67,9 +67,10 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do @impl Phoenix.Socket.Transport def handle_in({text, [opcode: :text]}, state) do - with {:ok, %{} = event} <- Jason.decode(text) do - handle_client_event(event, state) - else + case Jason.decode(text) do + {:ok, %{} = event} -> + handle_client_event(event, state) + _ -> Logger.error("#{__MODULE__} received non-JSON event: #{inspect(text)}") {:ok, state} @@ -85,11 +86,11 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do def handle_info({:render_with_user, view, template, item, topic}, state) do user = %User{} = User.get_cached_by_ap_id(state.user.ap_id) - unless Streamer.filtered_by_user?(user, item) do + if Streamer.filtered_by_user?(user, item) do + {:ok, state} + else message = view.render(template, item, user, topic) {:push, {:text, message}, %{state | user: user}} - else - {:ok, state} end end @@ -253,7 +254,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do defp find_sec_websocket_protocol(sec_headers) do Enum.find_value(sec_headers, fn - {"sec-websocket-protocol", token} -> token + {"sec-websocket-protocol", protocols} -> protocols |> Plug.Conn.Utils.list() |> List.first() _ -> nil end) end diff --git a/lib/pleroma/web/mastodon_api/websocket_plug.ex b/lib/pleroma/web/mastodon_api/websocket_plug.ex new file mode 100644 index 000000000..58ee913b4 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/websocket_plug.ex @@ -0,0 +1,105 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.WebsocketPlug do + @moduledoc """ + A Phoenix 1.8 compatible WebSocket transport for Mastodon streaming. + + It mirrors Phoenix.Transports.WebSocket, but echoes a successfully authenticated + Mastodon-style Sec-WebSocket-Protocol token so browser clients accept the handshake. + """ + + @behaviour Plug + + import Plug.Conn + + alias Phoenix.Socket.Transport + alias Pleroma.Web.Endpoint + alias Pleroma.Web.MastodonAPI.WebsocketHandler + + @connect_info_opts [:check_csrf] + + @impl Plug + def init(opts) do + path = String.split(Keyword.fetch!(opts, :path), "/", trim: true) + websocket = Keyword.fetch!(opts, :websocket) + config = Transport.load_config(websocket, Phoenix.Transports.WebSocket) + + {path, config} + end + + @impl Plug + def call(%{method: "GET", path_info: path} = conn, {path, opts}) do + conn + |> fetch_query_params() + |> Transport.code_reload(Endpoint, opts) + |> Transport.transport_log(opts[:transport_log]) + |> Transport.check_origin(WebsocketHandler, Endpoint, opts) + |> connect(opts) + end + + def call(%{path_info: path} = conn, {path, _opts}) do + conn + |> send_resp(400, "") + |> halt() + end + + def call(conn, _opts), do: conn + + defp connect(%{halted: true} = conn, _opts), do: conn + + defp connect(%{params: params} = conn, opts) do + keys = Keyword.get(opts, :connect_info, []) + + connect_info = + Transport.connect_info(conn, Endpoint, keys, Keyword.take(opts, @connect_info_opts)) + + config = %{ + endpoint: Endpoint, + transport: :websocket, + options: opts, + params: params, + connect_info: connect_info + } + + case WebsocketHandler.connect(config) do + {:ok, arg} -> + try do + conn + |> echo_sec_websocket_protocol() + |> WebSockAdapter.upgrade(WebsocketHandler, arg, opts) + |> halt() + rescue + e in WebSockAdapter.UpgradeError -> + conn + |> send_resp(400, e.message) + |> halt() + end + + :error -> + conn + |> send_resp(403, "") + |> halt() + + {:error, reason} -> + {m, f, args} = opts[:error_handler] + + halt(apply(m, f, [conn, reason | args])) + end + end + + defp echo_sec_websocket_protocol(conn) do + case get_req_header(conn, "sec-websocket-protocol") do + [protocols | _] -> + case Plug.Conn.Utils.list(protocols) do + [protocol | _] -> put_resp_header(conn, "sec-websocket-protocol", protocol) + nil -> conn + [] -> conn + end + + [] -> + conn + end + end +end diff --git a/test/pleroma/integration/mastodon_websocket_test.exs b/test/pleroma/integration/mastodon_websocket_test.exs index 078bb643c..de88e5002 100644 --- a/test/pleroma/integration/mastodon_websocket_test.exs +++ b/test/pleroma/integration/mastodon_websocket_test.exs @@ -11,6 +11,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do alias Pleroma.Integration.WebsocketClient alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.OAuth @moduletag needs_streamer: true, capture_log: true @@ -31,6 +32,48 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do WebsocketClient.start_link(self(), path, headers) end + defp raw_websocket_handshake(qs, headers) do + uri = URI.parse(@path <> qs) + port = uri.port || 80 + path = uri.path <> if(uri.query, do: "?" <> uri.query, else: "") + + default_headers = [ + {"host", "#{uri.host}:#{port}"}, + {"upgrade", "websocket"}, + {"connection", "Upgrade"}, + {"sec-websocket-key", Base.encode64(:crypto.strong_rand_bytes(16))}, + {"sec-websocket-version", "13"} + ] + + request = [ + "GET #{path} HTTP/1.1\r\n", + Enum.map(default_headers ++ headers, fn {name, value} -> "#{name}: #{value}\r\n" end), + "\r\n" + ] + + with {:ok, socket} <- + :gen_tcp.connect(String.to_charlist(uri.host), port, [:binary, active: false], 1_000), + :ok <- :gen_tcp.send(socket, request), + {:ok, response} <- :gen_tcp.recv(socket, 0, 1_000) do + :gen_tcp.close(socket) + {:ok, parse_http_response(response)} + end + end + + defp parse_http_response(response) do + [headers | _] = String.split(response, "\r\n\r\n", parts: 2) + [status_line | header_lines] = String.split(headers, "\r\n") + [_, status | _] = String.split(status_line, " ") + + headers = + Enum.map(header_lines, fn line -> + [name, value] = String.split(line, ":", parts: 2) + {String.downcase(name), String.trim(value)} + end) + + %{status: String.to_integer(status), headers: headers} + end + defp decode_json(json) do with {:ok, %{"event" => event, "payload" => payload_text}} <- Jason.decode(json), {:ok, payload} <- Jason.decode(payload_text) do @@ -85,9 +128,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do assert json["payload"] assert {:ok, json} = Jason.decode(json["payload"]) - view_json = - Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity, for: nil) - |> atom_key_to_string() + view_json = atom_key_to_string(StatusView.render("show.json", activity: activity, for: nil)) assert json == view_json end @@ -114,10 +155,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do assert json["payload"] assert {:ok, json} = Jason.decode(json["payload"]) - view_json = - Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity, for: nil) - |> Jason.encode!() - |> Jason.decode!() + view_json = atom_key_to_string(StatusView.render("show.json", activity: activity, for: nil)) assert json == view_json end @@ -279,6 +317,34 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do end) end + test "echoes the Sec-WebSocket-Protocol token in the handshake", %{token: token} do + assert {:ok, %{status: 101, headers: headers}} = + raw_websocket_handshake("?stream=user", [ + {"sec-websocket-protocol", token.token} + ]) + + assert {"sec-websocket-protocol", token.token} in headers + end + + test "echoes the selected Sec-WebSocket-Protocol token", %{token: token} do + assert {:ok, %{status: 101, headers: headers}} = + raw_websocket_handshake("?stream=user", [ + {"sec-websocket-protocol", "#{token.token}, phoenix"} + ]) + + assert {"sec-websocket-protocol", token.token} in headers + end + + test "does not echo an invalid Sec-WebSocket-Protocol token", %{token: token} do + assert {:ok, %{status: 401, headers: headers}} = + raw_websocket_handshake("?stream=user", [ + {"sec-websocket-protocol", "invalid"} + ]) + + refute {"sec-websocket-protocol", token.token} in headers + refute List.keymember?(headers, "sec-websocket-protocol", 0) + end + test "prefers sec-websocket-protocol token over query access_token", %{ token: token, user: user @@ -450,12 +516,12 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do assert {:ok, json} = Jason.decode(json["payload"]) view_json = - Pleroma.Web.MastodonAPI.StatusView.render("show.json", - activity: activity, - for: reading_user + atom_key_to_string( + StatusView.render("show.json", + activity: activity, + for: reading_user + ) ) - |> Jason.encode!() - |> Jason.decode!() assert json == view_json end @@ -478,12 +544,12 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do activity = Pleroma.Activity.normalize(activity) view_json = - Pleroma.Web.MastodonAPI.StatusView.render("show.json", - activity: activity, - for: reading_user + atom_key_to_string( + StatusView.render("show.json", + activity: activity, + for: reading_user + ) ) - |> Jason.encode!() - |> Jason.decode!() assert {:ok, %{"event" => "status.update", "payload" => ^view_json}} = decode_json(raw_json) end From 1e65c7a69e3cbd84009563a8d1974f772188a4b7 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 22 May 2026 20:36:52 +0400 Subject: [PATCH 086/195] Add grouped notifications API --- changelog.d/grouped-notifications-api.add | 1 + lib/pleroma/notification.ex | 84 +++++++++ .../operations/notification_operation.ex | 159 ++++++++++++++++ .../controllers/notification_controller.ex | 146 ++++++++++++++- lib/pleroma/web/mastodon_api/mastodon_api.ex | 177 ++++++++++++++++-- .../mastodon_api/views/notification_view.ex | 120 +++++++++++- lib/pleroma/web/router.ex | 10 + .../notification_controller_test.exs | 169 +++++++++++++++++ .../views/notification_view_test.exs | 8 +- 9 files changed, 850 insertions(+), 24 deletions(-) create mode 100644 changelog.d/grouped-notifications-api.add diff --git a/changelog.d/grouped-notifications-api.add b/changelog.d/grouped-notifications-api.add new file mode 100644 index 000000000..82ce8b178 --- /dev/null +++ b/changelog.d/grouped-notifications-api.add @@ -0,0 +1 @@ +Add Mastodon-compatible grouped notifications API endpoints under `/api/v2/notifications` diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index b8693c3a8..ec10854e3 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -62,6 +62,90 @@ defmodule Pleroma.Notification do |> Repo.aggregate(:count, :id) end + @groupable_notification_types ~w{favourite follow reblog} + @group_bucket_seconds 12 * 60 * 60 + + def groupable_notification_types, do: @groupable_notification_types + + def group_notifications(notifications, grouped_types \\ nil) do + grouped_types = normalize_grouped_types(grouped_types) + + {group_keys, grouped_notifications} = + Enum.reduce(notifications, {[], %{}}, fn notification, + {group_keys, grouped_notifications} -> + group_key = group_key(notification, grouped_types) + + if Map.has_key?(grouped_notifications, group_key) do + {group_keys, Map.update!(grouped_notifications, group_key, &[notification | &1])} + else + {[group_key | group_keys], Map.put(grouped_notifications, group_key, [notification])} + end + end) + + group_keys + |> Enum.reverse() + |> Enum.map(fn group_key -> + grouped_notifications + |> Map.fetch!(group_key) + |> Enum.reverse() + end) + end + + def group_key(notification, grouped_types \\ nil) + + def group_key(%Notification{} = notification, nil) do + group_key(notification, @groupable_notification_types) + end + + def group_key(%Notification{type: type} = notification, grouped_types) do + grouped_types = normalize_grouped_types(grouped_types) + + if type in @groupable_notification_types and type in grouped_types do + case group_target_id(notification) do + nil -> ungrouped_group_key(notification) + target_id -> "#{type}-#{target_id}-#{group_time_bucket(notification)}" + end + else + ungrouped_group_key(notification) + end + end + + def normalize_grouped_types(nil), do: @groupable_notification_types + def normalize_grouped_types(types) when is_list(types), do: types + def normalize_grouped_types(type), do: [type] + + defp ungrouped_group_key(%Notification{id: id}), do: "ungrouped-#{id}" + + defp group_time_bucket(%Notification{inserted_at: inserted_at}) do + inserted_at + |> NaiveDateTime.to_erl() + |> :calendar.datetime_to_gregorian_seconds() + |> div(@group_bucket_seconds) + end + + defp group_target_id(%Notification{type: type, activity: activity}) + when type in ["favourite", "reblog"] do + with object_id when is_binary(object_id) <- object_id_for(activity), + %Activity{id: id} <- Activity.get_create_by_object_ap_id(object_id) do + to_string(id) + else + _ -> nil + end + end + + defp group_target_id(%Notification{type: "follow", activity: %{data: %{"object" => ap_id}}}) do + case User.get_cached_by_ap_id(ap_id) do + %User{id: id} -> to_string(id) + _ -> nil + end + end + + defp group_target_id(_), do: nil + + defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id + defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id + defp object_id_for(_), do: nil + @notification_types ~w{ favourite follow diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index 94d1f6b82..ba996c13b 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -91,6 +91,79 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do } end + def grouped_index_operation do + %Operation{ + tags: ["Notifications"], + summary: "Retrieve grouped notifications", + description: "Notifications concerning the user, grouped by supported notification types.", + operationId: "NotificationController.grouped_index", + security: [%{"oAuth" => ["read:notifications"]}], + parameters: grouped_notification_params() ++ pagination_params(), + responses: %{ + 200 => Operation.response("Grouped notifications", "application/json", grouped_result()), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_group_operation do + %Operation{ + tags: ["Notifications"], + summary: "Retrieve a notification group", + operationId: "NotificationController.show_group", + security: [%{"oAuth" => ["read:notifications"]}], + parameters: [group_key_param()], + responses: %{ + 200 => Operation.response("Grouped notifications", "application/json", grouped_result()), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def group_accounts_operation do + %Operation{ + tags: ["Notifications"], + summary: "Retrieve notification group accounts", + operationId: "NotificationController.group_accounts", + security: [%{"oAuth" => ["write:notifications"]}], + parameters: [group_key_param()], + responses: %{ + 200 => + Operation.response("Accounts", "application/json", %Schema{ + type: :array, + items: Account + }) + } + } + end + + def unread_count_operation do + %Operation{ + tags: ["Notifications"], + summary: "Retrieve unread grouped notification count", + operationId: "NotificationController.unread_count", + security: [%{"oAuth" => ["read:notifications"]}], + parameters: + grouped_notification_params() ++ + [ + Operation.parameter( + :limit, + :query, + %Schema{type: :integer}, + "Maximum number of notifications to count" + ) + ], + responses: %{ + 200 => + Operation.response("Unread notification group count", "application/json", %Schema{ + type: :object, + properties: %{count: %Schema{type: :integer}} + }), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + def clear_operation do %Operation{ tags: ["Notifications"], @@ -114,6 +187,18 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do } end + def dismiss_group_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss a notification group", + description: "Clear all notifications in a notification group from the server.", + operationId: "NotificationController.dismiss_group", + parameters: [group_key_param()], + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + def dismiss_via_body_operation do %Operation{ tags: ["Notifications"], @@ -194,6 +279,73 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do } end + defp grouped_result do + %Schema{ + title: "GroupedNotificationsResults", + type: :object, + properties: %{ + accounts: %Schema{type: :array, items: Account}, + statuses: %Schema{type: :array, items: Status}, + notification_groups: %Schema{type: :array, items: notification_group()} + }, + required: [:accounts, :statuses, :notification_groups] + } + end + + defp notification_group do + %Schema{ + title: "NotificationGroup", + type: :object, + properties: %{ + group_key: %Schema{type: :string}, + notifications_count: %Schema{type: :integer}, + type: notification_type(), + most_recent_notification_id: %Schema{type: :string}, + page_min_id: %Schema{type: :string, nullable: true}, + page_max_id: %Schema{type: :string, nullable: true}, + latest_page_notification_at: %Schema{type: :string, format: :"date-time", nullable: true}, + sample_account_ids: %Schema{type: :array, items: %Schema{type: :string}}, + status_id: %Schema{type: :string, nullable: true} + }, + required: [ + :group_key, + :notifications_count, + :type, + :most_recent_notification_id, + :sample_account_ids + ] + } + end + + defp grouped_notification_params do + [ + Operation.parameter( + :exclude_types, + :query, + %Schema{type: :array, items: notification_type()}, + "Array of types to exclude" + ), + Operation.parameter( + :account_id, + :query, + %Schema{type: :string}, + "Return only notifications received from this account" + ), + Operation.parameter( + :types, + :query, + %Schema{type: :array, items: notification_type()}, + "Include the notifications for activities with the given types" + ), + Operation.parameter( + :grouped_types, + :query, + %Schema{type: :array, items: notification_type()}, + "Notification types that may be grouped" + ) + ] + end + defp notification_type do %Schema{ type: :string, @@ -239,4 +391,11 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do required: true ) end + + defp group_key_param do + Operation.parameter(:group_key, :path, :string, "Notification group key", + example: "favourite-123", + required: true + ) + end end diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index afd83b785..5cd8576c1 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -8,10 +8,12 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] alias Pleroma.Notification + alias Pleroma.User + alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.Plugs.OAuthScopesPlug - @oauth_read_actions [:show, :index] + @oauth_read_actions [:show, :index, :grouped_index, :show_group, :unread_count] plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) @@ -39,7 +41,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do # GET /api/v1/notifications def index(%{private: %{open_api_spex: %{params: %{account_id: account_id} = params}}} = conn, _) do - case Pleroma.User.get_cached_by_id(account_id) do + case User.get_cached_by_id(account_id) do %{ap_id: account_ap_id} -> params = params @@ -59,10 +61,110 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do do_get_notifications(conn, params) end + # GET /api/v2/notifications + def grouped_index( + %{private: %{open_api_spex: %{params: %{account_id: account_id} = params}}} = conn, + _ + ) do + case User.get_cached_by_id(account_id) do + %{ap_id: account_ap_id} -> + params = + params + |> Map.delete(:account_id) + |> Map.put(:account_ap_id, account_ap_id) + + do_get_grouped_notifications(conn, params) + + _ -> + conn + |> put_status(:not_found) + |> json(%{"error" => "Account is not found"}) + end + end + + def grouped_index(%{private: %{open_api_spex: %{params: params}}} = conn, _) do + do_get_grouped_notifications(conn, params) + end + + # GET /api/v2/notifications/:group_key + def show_group( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{group_key: group_key}}}} = + conn, + _ + ) do + notifications = MastodonAPI.get_notification_group(user, group_key, %{}) + + if Enum.empty?(notifications) do + conn + |> put_status(:not_found) + |> json(%{"error" => "Notification group is not found"}) + else + grouped_types = if String.starts_with?(group_key, "ungrouped-"), do: [], else: nil + + render(conn, "grouped_index.json", + notification_groups: [notifications], + for: user, + grouped_types: grouped_types + ) + end + end + + # GET /api/v2/notifications/:group_key/accounts + def group_accounts( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{group_key: group_key}}}} = + conn, + _ + ) do + users = + user + |> MastodonAPI.get_notification_group(group_key, %{}) + |> notification_actors() + + json(conn, AccountView.render("index.json", %{users: users, for: user})) + end + + # GET /api/v2/notifications/unread_count + def unread_count( + %{private: %{open_api_spex: %{params: %{account_id: account_id} = params}}} = conn, + _ + ) do + case User.get_cached_by_id(account_id) do + %{ap_id: account_ap_id} -> + params = + params + |> Map.delete(:account_id) + |> Map.put(:account_ap_id, account_ap_id) + + do_get_unread_group_count(conn, params) + + _ -> + conn + |> put_status(:not_found) + |> json(%{"error" => "Account is not found"}) + end + end + + def unread_count(%{private: %{open_api_spex: %{params: params}}} = conn, _) do + do_get_unread_group_count(conn, params) + end + + # POST /api/v2/notifications/:group_key/dismiss + def dismiss_group( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{group_key: group_key}}}} = + conn, + _ + ) do + ids = + user + |> MastodonAPI.get_notification_group(group_key, %{}) + |> Enum.map(& &1.id) + + Notification.destroy_multiple(user, ids) + json(conn, %{}) + end + defp do_get_notifications(%{assigns: %{user: user}} = conn, params) do - params = - Map.new(params, fn {k, v} -> {to_string(k), v} end) - |> Map.put_new("types", Map.get(params, :include_types, @default_notification_types)) + params = normalize_notification_params(params) notifications = MastodonAPI.get_notifications(user, params) @@ -74,6 +176,40 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do ) end + defp do_get_grouped_notifications(%{assigns: %{user: user}} = conn, params) do + params = normalize_notification_params(params) + + {notification_groups, page_notifications, notification_group_counts} = + MastodonAPI.get_grouped_notification_page(user, params) + + conn + |> add_link_headers(page_notifications) + |> render("grouped_index.json", + notification_groups: notification_groups, + notification_group_counts: notification_group_counts, + for: user, + grouped_types: params["grouped_types"] + ) + end + + defp do_get_unread_group_count(%{assigns: %{user: user}} = conn, params) do + params = normalize_notification_params(params) + json(conn, %{count: MastodonAPI.unread_notification_group_count(user, params)}) + end + + defp normalize_notification_params(params) do + params + |> Map.new(fn {k, v} -> {to_string(k), v} end) + |> Map.put_new("types", Map.get(params, :include_types, @default_notification_types)) + end + + defp notification_actors(notifications) do + notifications + |> Enum.map(&User.get_cached_by_ap_id(&1.activity.data["actor"])) + |> Enum.filter(& &1) + |> Enum.uniq_by(& &1.id) + end + # GET /api/v1/notifications/:id def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with {:ok, notification} <- Notification.get(user, id) do diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index c9e045d23..57cbfd6bf 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do alias Pleroma.Notification alias Pleroma.Pagination + alias Pleroma.Repo alias Pleroma.ScheduledActivity alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -61,29 +62,175 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do end def get_notifications(user, params \\ %{}) do - options = - cast_params(params) |> Map.update(:include_types, [], fn include_types -> include_types end) + user + |> notifications_query(params) + |> Pagination.fetch_paginated(params) + end - options = - if ("pleroma:report" not in options.include_types and - User.privileged?(user, :reports_manage_reports)) or - User.privileged?(user, :reports_manage_reports) do - options - else - options - |> Map.update(:exclude_types, ["pleroma:report"], fn current_exclude_types -> - current_exclude_types ++ ["pleroma:report"] - end) - end + def get_grouped_notification_page(user, params \\ %{}) do + grouped_types = Map.get(params, "grouped_types", Map.get(params, :grouped_types)) + {query, order} = group_pagination_query(notifications_query(user, params), params) + + notifications = + query + |> order_by([n], [{^order, n.id}]) + |> Repo.all() + + notification_group_counts = + Enum.frequencies_by(notifications, &Notification.group_key(&1, grouped_types)) + + page_notifications = + notifications + |> take_grouped_page_notifications(grouped_types, grouped_limit(params)) + + page_notifications = + if order == :asc, do: Enum.reverse(page_notifications), else: page_notifications + + { + Notification.group_notifications(page_notifications, grouped_types), + page_notifications, + notification_group_counts + } + end + + def get_grouped_notification_groups(user, params \\ %{}) do + {groups, _notifications, _notification_group_counts} = + get_grouped_notification_page(user, params) + + groups + end + + def get_notification_group(user, group_key, params \\ %{}) + + def get_notification_group(user, "ungrouped-" <> notification_id, _params) do + case Notification.get(user, notification_id) do + {:ok, notification} -> [notification] + _ -> [] + end + end + + def get_notification_group(user, group_key, params) do + grouped_types = Map.get(params, "grouped_types", Map.get(params, :grouped_types)) + + user + |> notifications_query(params) + |> order_by([n], desc: n.id) + |> Repo.all() + |> Enum.filter(&(Notification.group_key(&1, grouped_types) == group_key)) + end + + defp group_pagination_query(query, params) do + cond do + min_id = Map.get(params, "min_id", Map.get(params, :min_id)) -> + query = where(query, [n], n.id > ^min_id) + + query = + case Map.get(params, "max_id", Map.get(params, :max_id)) do + nil -> query + max_id -> where(query, [n], n.id < ^max_id) + end + + {query, :asc} + + since_id = Map.get(params, "since_id", Map.get(params, :since_id)) -> + {where(query, [n], n.id > ^since_id), :desc} + + max_id = Map.get(params, "max_id", Map.get(params, :max_id)) -> + {where(query, [n], n.id < ^max_id), :desc} + + true -> + {query, :desc} + end + end + + defp grouped_limit(params) do + params + |> Map.get("limit", Map.get(params, :limit, 40)) + |> parse_limit(40) + |> min(80) + end + + defp take_grouped_page_notifications(notifications, grouped_types, group_limit) do + {notifications, _group_keys} = + Enum.reduce_while(notifications, {[], MapSet.new()}, fn notification, + {notifications, group_keys} -> + group_key = Notification.group_key(notification, grouped_types) + + cond do + MapSet.member?(group_keys, group_key) -> + {:cont, {[notification | notifications], group_keys}} + + MapSet.size(group_keys) < group_limit -> + {:cont, {[notification | notifications], MapSet.put(group_keys, group_key)}} + + true -> + {:halt, {notifications, group_keys}} + end + end) + + Enum.reverse(notifications) + end + + def unread_notification_group_count(user, params \\ %{}) do + grouped_types = Map.get(params, "grouped_types", Map.get(params, :grouped_types)) + limit = unread_count_limit(params) + + user + |> notifications_query(params) + |> where([n], n.seen == false) + |> order_by([n], desc: n.id) + |> limit(^limit) + |> Repo.all() + |> Notification.group_notifications(grouped_types) + |> length() + end + + defp notifications_query(user, params) do + options = notification_options(user, params) user |> Notification.for_user_query(options) |> restrict(:types, options) |> restrict(:exclude_types, options) |> restrict(:account_ap_id, options) - |> Pagination.fetch_paginated(params) end + defp notification_options(user, params) do + options = + params + |> cast_params() + |> Map.update(:include_types, [], fn include_types -> include_types end) + + if ("pleroma:report" not in options.include_types and + User.privileged?(user, :reports_manage_reports)) or + User.privileged?(user, :reports_manage_reports) do + options + else + options + |> Map.update(:exclude_types, ["pleroma:report"], fn current_exclude_types -> + current_exclude_types ++ ["pleroma:report"] + end) + end + end + + defp unread_count_limit(params) do + params + |> Map.get("limit", Map.get(params, :limit, 100)) + |> parse_limit(100) + |> min(1000) + end + + defp parse_limit(limit, _default) when is_integer(limit) and limit > 0, do: limit + + defp parse_limit(limit, default) when is_binary(limit) do + case Integer.parse(limit) do + {limit, _} when limit > 0 -> limit + _ -> default + end + end + + defp parse_limit(_, default), do: default + def get_scheduled_activities(user, params \\ %{}) do user |> ScheduledActivity.for_user_query() @@ -95,6 +242,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do exclude_types: {:array, :string}, types: {:array, :string}, exclude_visibilities: {:array, :string}, + grouped_types: {:array, :string}, + limit: :integer, reblogs: :boolean, with_muted: :boolean, account_ap_id: :string, diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 27532c42a..466073853 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -72,6 +72,49 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do safe_render_many(notifications, NotificationView, "show.json", opts) end + def render("grouped_index.json", %{notifications: notifications} = opts) do + grouped_types = Notification.normalize_grouped_types(opts[:grouped_types]) + + opts + |> Map.delete(:notifications) + |> Map.put( + :notification_groups, + Notification.group_notifications(notifications, grouped_types) + ) + |> then(&render("grouped_index.json", &1)) + end + + def render( + "grouped_index.json", + %{notification_groups: notification_groups, for: reading_user} = opts + ) do + grouped_types = Notification.normalize_grouped_types(opts[:grouped_types]) + notification_group_counts = Map.get(opts, :notification_group_counts, %{}) + + statuses = + notification_groups + |> Enum.map(&List.first/1) + |> Enum.map(&render("show.json", %{notification: &1, for: reading_user})) + |> Enum.map(& &1[:status]) + |> Enum.filter(& &1) + |> Enum.uniq_by(& &1[:id]) + + actors = + notification_groups + |> List.flatten() + |> notification_actors() + + %{ + accounts: AccountView.render("index.json", %{users: actors, for: reading_user}), + statuses: statuses, + notification_groups: + Enum.map( + notification_groups, + &render_group(&1, reading_user, grouped_types, notification_group_counts) + ) + } + end + def render( "show.json", %{ @@ -95,7 +138,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do response = %{ id: to_string(notification.id), - group_key: "ungrouped-" <> to_string(notification.id), + group_key: Notification.group_key(notification), type: notification.type, created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), account: account, @@ -137,6 +180,81 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do Map.put(response, :report, report_render) end + defp render_group( + [%Notification{} = notification | _] = notifications, + _reading_user, + grouped_types, + notification_group_counts + ) do + latest_notification = List.first(notifications) + oldest_notification = List.last(notifications) + status_activity = status_activity_for_group(notifications, grouped_types) + group_key = Notification.group_key(notification, grouped_types) + + response = %{ + group_key: group_key, + notifications_count: Map.get(notification_group_counts, group_key, length(notifications)), + type: notification.type, + most_recent_notification_id: to_string(latest_notification.id), + page_min_id: to_string(oldest_notification.id), + page_max_id: to_string(latest_notification.id), + latest_page_notification_at: CommonAPI.Utils.to_masto_date(latest_notification.inserted_at), + sample_account_ids: + notifications + |> notification_actors() + |> Enum.map(&to_string(&1.id)) + } + + if status_activity do + Map.put(response, :status_id, to_string(status_activity.id)) + else + response + end + end + + defp status_activity_for_group([%Notification{} = notification | _], grouped_types) do + status_activity_for(notification, grouped_types) + end + + defp status_activity_for(%Notification{type: type, activity: activity}, _grouped_types) + when type in ["mention", "status", "poll"] do + activity + end + + defp status_activity_for(%Notification{type: type} = notification, grouped_types) + when type in ["favourite", "reblog"] do + group_key = Notification.group_key(notification, grouped_types) + + case String.split(group_key, "-", parts: 3) do + [^type, activity_id, _bucket] -> + case Activity.create_by_id_with_object(activity_id) do + %Activity{} = activity -> activity + _ -> parent_status_activity(notification.activity) + end + + _ -> + parent_status_activity(notification.activity) + end + end + + defp status_activity_for(%Notification{type: type, activity: activity}, _grouped_types) + when type in ["update", "pleroma:emoji_reaction"] do + parent_status_activity(activity) + end + + defp status_activity_for(_, _grouped_types), do: nil + + defp parent_status_activity(activity) do + Activity.get_create_by_object_ap_id(object_id_for(activity)) + end + + defp notification_actors(notifications) do + notifications + |> Enum.map(&User.get_cached_by_ap_id(&1.activity.data["actor"])) + |> Enum.filter(& &1) + |> Enum.uniq_by(& &1.id) + end + defp put_emoji(response, activity) do response |> Map.put(:emoji, activity.data["content"]) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 813c5c482..5657bcad7 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -844,6 +844,16 @@ defmodule Pleroma.Web.Router do get("/directory", DirectoryController, :index) end + scope "/api/v2", Pleroma.Web.MastodonAPI do + pipe_through(:authenticated_api) + + get("/notifications", NotificationController, :grouped_index) + get("/notifications/unread_count", NotificationController, :unread_count) + get("/notifications/:group_key/accounts", NotificationController, :group_accounts) + get("/notifications/:group_key", NotificationController, :show_group) + post("/notifications/:group_key/dismiss", NotificationController, :dismiss_group) + end + scope "/api/v2", Pleroma.Web.MastodonAPI do pipe_through(:api) diff --git a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs index dd4b7389e..1d3a9e966 100644 --- a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs @@ -577,6 +577,175 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result end + describe "GET /api/v2/notifications" do + test "groups favourite notifications for the same status" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user1 = insert(:user) + other_user2 = insert(:user) + + {:ok, status} = CommonAPI.post(user, %{status: "hello"}) + {:ok, _} = CommonAPI.favorite(status.id, other_user1) + {:ok, _} = CommonAPI.favorite(status.id, other_user2) + + notification_ids = + Notification + |> Repo.all() + |> Enum.map(&to_string(&1.id)) + + result = + conn + |> get("/api/v2/notifications") + |> json_response_and_validate_schema(200) + + assert [%{"id" => account_id1}, %{"id" => account_id2}] = result["accounts"] + assert account_id1 in [other_user1.id, other_user2.id] + assert account_id2 in [other_user1.id, other_user2.id] + assert [%{"id" => status_id}] = result["statuses"] + assert status_id == status.id + + assert [group] = result["notification_groups"] + assert group["type"] == "favourite" + assert group["notifications_count"] == 2 + assert group["status_id"] == status.id + assert group["most_recent_notification_id"] in notification_ids + assert group["page_min_id"] in notification_ids + assert group["page_max_id"] in notification_ids + assert group["latest_page_notification_at"] + assert Enum.sort(group["sample_account_ids"]) == Enum.sort([other_user1.id, other_user2.id]) + group_key = group["group_key"] + + assert [%{"group_key" => ^group_key}, %{"group_key" => ^group_key}] = + conn + |> get("/api/v1/notifications") + |> json_response_and_validate_schema(200) + + assert %{"notification_groups" => [shown_group]} = + conn + |> get("/api/v2/notifications/#{group_key}") + |> json_response_and_validate_schema(200) + + assert shown_group["group_key"] == group_key + assert shown_group["notifications_count"] == 2 + end + + test "round-trips ungrouped group keys when grouped_types excludes a type" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user = insert(:user) + + {:ok, status} = CommonAPI.post(user, %{status: "hello"}) + {:ok, _} = CommonAPI.favorite(status.id, other_user) + + %{"notification_groups" => [%{"group_key" => "ungrouped-" <> _ = group_key}]} = + conn + |> get("/api/v2/notifications?grouped_types[]=reblog") + |> json_response_and_validate_schema(200) + + assert %{ + "notification_groups" => [%{"group_key" => ^group_key, "notifications_count" => 1}] + } = + conn + |> get("/api/v2/notifications/#{group_key}") + |> json_response_and_validate_schema(200) + end + + test "paginates notification groups instead of raw notifications" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user1 = insert(:user) + other_user2 = insert(:user) + other_user3 = insert(:user) + + {:ok, older_status} = CommonAPI.post(user, %{status: "older"}) + {:ok, _} = CommonAPI.favorite(older_status.id, other_user3) + + {:ok, newer_status} = CommonAPI.post(user, %{status: "newer"}) + {:ok, _} = CommonAPI.favorite(newer_status.id, other_user1) + {:ok, _} = CommonAPI.favorite(newer_status.id, other_user2) + older_status_id = older_status.id + newer_status_id = newer_status.id + + %{"notification_groups" => groups} = + conn + |> get("/api/v2/notifications?limit=2") + |> json_response_and_validate_schema(200) + + assert [ + %{"status_id" => ^newer_status_id, "notifications_count" => 2}, + %{"status_id" => ^older_status_id, "notifications_count" => 1} + ] = groups + end + + test "returns total notification count for a partially represented group" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user1 = insert(:user) + other_user2 = insert(:user) + other_user3 = insert(:user) + + {:ok, grouped_status} = CommonAPI.post(user, %{status: "grouped"}) + {:ok, _} = CommonAPI.favorite(grouped_status.id, other_user1) + + {:ok, other_status} = CommonAPI.post(user, %{status: "other"}) + {:ok, _} = CommonAPI.favorite(other_status.id, other_user3) + {:ok, _} = CommonAPI.favorite(grouped_status.id, other_user2) + grouped_status_id = grouped_status.id + + assert %{ + "notification_groups" => [ + %{"status_id" => ^grouped_status_id, "notifications_count" => 2} + ] + } = + conn + |> get("/api/v2/notifications?limit=1") + |> json_response_and_validate_schema(200) + end + + test "counts unread notification groups" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user1 = insert(:user) + other_user2 = insert(:user) + mentioner = insert(:user) + + {:ok, status} = CommonAPI.post(user, %{status: "hello"}) + {:ok, _} = CommonAPI.favorite(status.id, other_user1) + {:ok, _} = CommonAPI.favorite(status.id, other_user2) + {:ok, _} = CommonAPI.post(mentioner, %{status: "hi @#{user.nickname}"}) + + assert %{"count" => 2} = + conn + |> get("/api/v2/notifications/unread_count") + |> json_response_and_validate_schema(200) + end + + test "lists accounts from and dismisses a notification group" do + %{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"]) + other_user1 = insert(:user) + other_user2 = insert(:user) + + {:ok, status} = CommonAPI.post(user, %{status: "hello"}) + {:ok, _} = CommonAPI.favorite(status.id, other_user1) + {:ok, _} = CommonAPI.favorite(status.id, other_user2) + + %{"notification_groups" => [%{"group_key" => group_key}]} = + conn + |> get("/api/v2/notifications") + |> json_response_and_validate_schema(200) + + account_ids = + conn + |> get("/api/v2/notifications/#{group_key}/accounts") + |> json_response_and_validate_schema(200) + |> Enum.map(& &1["id"]) + + assert Enum.sort(account_ids) == Enum.sort([other_user1.id, other_user2.id]) + + assert %{} = + conn + |> post("/api/v2/notifications/#{group_key}/dismiss") + |> json_response_and_validate_schema(200) + + assert [] = Notification.for_user(user) + end + end + test "doesn't see notifications after muting user with notifications" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) user2 = insert(:user) diff --git a/test/pleroma/web/mastodon_api/views/notification_view_test.exs b/test/pleroma/web/mastodon_api/views/notification_view_test.exs index c8287bb79..aa11ad682 100644 --- a/test/pleroma/web/mastodon_api/views/notification_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/notification_view_test.exs @@ -101,7 +101,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), - group_key: "ungrouped-#{to_string(notification.id)}", + group_key: Notification.group_key(notification), pleroma: %{is_seen: false, is_muted: false}, type: "favourite", account: AccountView.render("show.json", %{user: another_user, for: user}), @@ -122,7 +122,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), - group_key: "ungrouped-#{to_string(notification.id)}", + group_key: Notification.group_key(notification), pleroma: %{is_seen: false, is_muted: false}, type: "reblog", account: AccountView.render("show.json", %{user: another_user, for: user}), @@ -141,7 +141,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), - group_key: "ungrouped-#{to_string(notification.id)}", + group_key: Notification.group_key(notification), pleroma: %{is_seen: false, is_muted: false}, type: "follow", account: AccountView.render("show.json", %{user: follower, for: followed}), @@ -333,7 +333,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), - group_key: "ungrouped-#{to_string(notification.id)}", + group_key: Notification.group_key(notification), pleroma: %{is_seen: true, is_muted: true}, type: "favourite", account: AccountView.render("show.json", %{user: another_user, for: user}), From b054c2aa42748fd3489ee463f4bfd43b075ec638 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 22 May 2026 22:11:37 +0400 Subject: [PATCH 087/195] Fix paged featured collection fetches --- changelog.d/featured-collection-page.fix | 1 + lib/pleroma/web/activity_pub/activity_pub.ex | 36 ++++- .../web/activity_pub/activity_pub_test.exs | 133 ++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 changelog.d/featured-collection-page.fix diff --git a/changelog.d/featured-collection-page.fix b/changelog.d/featured-collection-page.fix new file mode 100644 index 000000000..b611a4137 --- /dev/null +++ b/changelog.d/featured-collection-page.fix @@ -0,0 +1 @@ +Support fetching featured collection items from the first collection page diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 0b513ee16..1e8bfe3dd 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1881,11 +1881,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end + @featured_collection_types ["OrderedCollection", "Collection"] + @featured_collection_page_types ["OrderedCollectionPage", "CollectionPage"] + @featured_collection_item_types @featured_collection_types ++ @featured_collection_page_types + def pin_data_from_featured_collection(%{ "type" => type, "orderedItems" => objects }) - when type in ["OrderedCollection", "Collection"] do + when type in @featured_collection_item_types do Map.new(objects, fn %{"id" => object_ap_id} -> {object_ap_id, NaiveDateTime.utc_now()} object_ap_id when is_binary(object_ap_id) -> {object_ap_id, NaiveDateTime.utc_now()} @@ -1903,7 +1907,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do def fetch_and_prepare_featured_from_ap_id(ap_id) do with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id) do - {:ok, pin_data_from_featured_collection(data)} + {:ok, prepare_featured_collection(data)} else e -> Logger.error("Could not decode featured collection at fetch #{ap_id}, #{inspect(e)}") @@ -1911,6 +1915,34 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end + defp prepare_featured_collection(%{"orderedItems" => objects} = data) when is_list(objects) do + pin_data_from_featured_collection(data) + end + + defp prepare_featured_collection(%{ + "type" => type, + "first" => %{"type" => page_type} = first + }) + when type in @featured_collection_types and page_type in @featured_collection_page_types do + pin_data_from_featured_collection(first) + end + + defp prepare_featured_collection(%{"type" => type, "first" => first}) + when type in @featured_collection_types and is_binary(first) do + case Fetcher.fetch_and_contain_remote_object_from_id(first) do + {:ok, data} -> + pin_data_from_featured_collection(data) + + e -> + Logger.error("Could not decode featured collection page at fetch #{first}, #{inspect(e)}") + %{} + end + end + + defp prepare_featured_collection(data) do + pin_data_from_featured_collection(data) + end + def enqueue_pin_fetches(%{pinned_objects: pins}) do # enqueue a task to fetch all pinned objects Enum.each(pins, fn {ap_id, _} -> diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 9aafc41a5..dd7a82a55 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -415,6 +415,105 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do assert %{data: %{"id" => ^object_url}} = Object.get_by_ap_id(object_url) end + test "fetches user featured collection from the first collection page" do + ap_id = "https://example.com/users/lain" + featured_url = "https://example.com/users/lain/collections/featured" + first_page_url = "#{featured_url}?page=true" + + user_data = + "test/fixtures/users_mock/user.json" + |> File.read!() + |> String.replace("{{nickname}}", "lain") + |> Jason.decode!() + |> Map.put("featured", featured_url) + |> Jason.encode!() + + object_id = Ecto.UUID.generate() + object_url = "https://example.com/objects/#{object_id}" + + featured_data = + Jason.encode!(%{ + "id" => featured_url, + "type" => "OrderedCollection", + "first" => first_page_url + }) + + first_page_data = + Jason.encode!(%{ + "id" => first_page_url, + "type" => "OrderedCollectionPage", + "partOf" => featured_url, + "orderedItems" => [object_url] + }) + + object_data = + "test/fixtures/statuses/note.json" + |> File.read!() + |> String.replace("{{object_id}}", object_id) + |> String.replace("{{nickname}}", "lain") + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^ap_id + } -> + %Tesla.Env{ + status: 200, + body: user_data, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^featured_url + } -> + %Tesla.Env{ + status: 200, + body: featured_data, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^first_page_url + } -> + %Tesla.Env{ + status: 200, + body: first_page_data, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^object_url + } -> + %Tesla.Env{ + status: 200, + body: object_data, + headers: [{"content-type", "application/activity+json"}] + } + end) + + refute capture_log(fn -> + {:ok, user} = ActivityPub.make_user_from_ap_id(ap_id) + + assert_enqueued( + worker: Pleroma.Workers.RemoteFetcherWorker, + args: %{ + "op" => "fetch_remote", + "id" => object_url, + "depth" => 1 + } + ) + + Pleroma.Tests.ObanHelpers.perform_all() + + assert user.featured_address == featured_url + assert Map.has_key?(user.pinned_objects, object_url) + assert %{data: %{"id" => ^object_url}} = Object.get_by_ap_id(object_url) + end) =~ "Could not parse featured collection" + end + test "fetches user birthday information from misskey" do user_id = "https://misskey.io/@mkljczk" @@ -2818,4 +2917,38 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do "first" => "https://social.example/users/alice/collections/featured?page=true" }) end + + test "fetch_and_prepare_featured_from_ap_id handles embedded first collection pages" do + featured_url = "https://social.example/users/alice/collections/featured" + first_page_url = "#{featured_url}?page=true" + object_url = "https://social.example/objects/1" + + featured_data = + Jason.encode!(%{ + "id" => featured_url, + "type" => "OrderedCollection", + "first" => %{ + "id" => first_page_url, + "type" => "OrderedCollectionPage", + "partOf" => featured_url, + "orderedItems" => [object_url] + } + }) + + Tesla.Mock.mock(fn + %{method: :get, url: ^featured_url} -> + %Tesla.Env{ + status: 200, + body: featured_data, + headers: [{"content-type", "application/activity+json"}] + } + end) + + refute capture_log(fn -> + assert {:ok, pinned_objects} = + ActivityPub.fetch_and_prepare_featured_from_ap_id(featured_url) + + assert Map.has_key?(pinned_objects, object_url) + end) =~ "Could not parse featured collection" + end end From cda6738309117a8ea95f8367175dbed3587e697b Mon Sep 17 00:00:00 2001 From: hj Date: Fri, 22 May 2026 20:47:52 +0000 Subject: [PATCH 088/195] Update priv/scrubbers/default.ex --- priv/scrubbers/default.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index 8963d99e8..cc5eba027 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -105,7 +105,9 @@ defmodule Pleroma.HTML.Scrubber.Default do "mfm-sparkle", "mfm-rotate", "mfm-ruby", - "mfm-unixtime" + "mfm-unixtime", + # Exists in Akkoma but not Misskey? + "mfm-center" ]) Meta.allow_tag_with_this_attribute_values(:p, "class", ["quote-inline"]) From add3844c6d8f6e540d139f55e44047d946d9559a Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sat, 23 May 2026 11:38:33 +0400 Subject: [PATCH 089/195] Persist notification group keys --- changelog.d/grouped-notifications-api.add | 2 +- lib/pleroma/notification.ex | 39 ++- .../operations/notification_operation.ex | 5 +- lib/pleroma/web/controller_helper.ex | 12 +- .../controllers/notification_controller.ex | 37 +-- lib/pleroma/web/mastodon_api/mastodon_api.ex | 307 ++++++++++++++---- .../mastodon_api/views/notification_view.ex | 44 ++- ...3070000_add_group_key_to_notifications.exs | 15 + .../notification_controller_test.exs | 157 ++++++++- .../views/notification_view_test.exs | 3 + 10 files changed, 505 insertions(+), 116 deletions(-) create mode 100644 priv/repo/migrations/20260523070000_add_group_key_to_notifications.exs diff --git a/changelog.d/grouped-notifications-api.add b/changelog.d/grouped-notifications-api.add index 82ce8b178..8c2cc5397 100644 --- a/changelog.d/grouped-notifications-api.add +++ b/changelog.d/grouped-notifications-api.add @@ -1 +1 @@ -Add Mastodon-compatible grouped notifications API endpoints under `/api/v2/notifications` +Add Mastodon-compatible grouped notifications API endpoints under `/api/v2/notifications` for newly-created notifications diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index ec10854e3..afd750823 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -35,6 +35,7 @@ defmodule Pleroma.Notification do # remember to add a migration to add it to the `notifications_type` enum # as well. field(:type, :string) + field(:group_key, :string) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) @@ -49,7 +50,7 @@ defmodule Pleroma.Notification do |> type_from_activity() notification - |> changeset(%{type: type}) + |> changeset(%{type: type, group_key: grouped_notification_key(type, activity)}) |> Repo.update() end end @@ -97,14 +98,11 @@ defmodule Pleroma.Notification do group_key(notification, @groupable_notification_types) end - def group_key(%Notification{type: type} = notification, grouped_types) do + def group_key(%Notification{type: type, group_key: group_key} = notification, grouped_types) do grouped_types = normalize_grouped_types(grouped_types) - if type in @groupable_notification_types and type in grouped_types do - case group_target_id(notification) do - nil -> ungrouped_group_key(notification) - target_id -> "#{type}-#{target_id}-#{group_time_bucket(notification)}" - end + if type in @groupable_notification_types and type in grouped_types and is_binary(group_key) do + group_key else ungrouped_group_key(notification) end @@ -116,15 +114,27 @@ defmodule Pleroma.Notification do defp ungrouped_group_key(%Notification{id: id}), do: "ungrouped-#{id}" - defp group_time_bucket(%Notification{inserted_at: inserted_at}) do + defp grouped_notification_key(type, activity) when type in @groupable_notification_types do + with target_id when is_binary(target_id) <- group_target_id(type, activity) do + # Mastodon uses Redis to reuse a recent bucket for rolling 12h windows. Pleroma keeps keys + # deterministic and self-contained; clients must treat group_key as opaque either way. + "#{type}-#{target_id}-#{group_time_bucket(activity)}" + end + end + + defp grouped_notification_key(_type, _activity), do: nil + + defp group_time_bucket(%Activity{inserted_at: inserted_at}) when not is_nil(inserted_at) do inserted_at |> NaiveDateTime.to_erl() |> :calendar.datetime_to_gregorian_seconds() |> div(@group_bucket_seconds) end - defp group_target_id(%Notification{type: type, activity: activity}) - when type in ["favourite", "reblog"] do + defp group_time_bucket(_), + do: group_time_bucket(%Activity{inserted_at: NaiveDateTime.utc_now()}) + + defp group_target_id(type, activity) when type in ["favourite", "reblog"] do with object_id when is_binary(object_id) <- object_id_for(activity), %Activity{id: id} <- Activity.get_create_by_object_ap_id(object_id) do to_string(id) @@ -133,14 +143,14 @@ defmodule Pleroma.Notification do end end - defp group_target_id(%Notification{type: "follow", activity: %{data: %{"object" => ap_id}}}) do + defp group_target_id("follow", %{data: %{"object" => ap_id}}) do case User.get_cached_by_ap_id(ap_id) do %User{id: id} -> to_string(id) _ -> nil end end - defp group_target_id(_), do: nil + defp group_target_id(_, _), do: nil defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id @@ -163,7 +173,7 @@ defmodule Pleroma.Notification do def changeset(%Notification{} = notification, attrs) do notification - |> cast(attrs, [:seen, :type]) + |> cast(attrs, [:seen, :type, :group_key]) |> validate_inclusion(:type, @notification_types) end @@ -542,7 +552,8 @@ defmodule Pleroma.Notification do user_id: user.id, activity: activity, seen: mark_as_read?(activity, user), - type: type + type: type, + group_key: grouped_notification_key(type, activity) }) |> Marker.multi_set_last_read_id(user, "notifications") |> Repo.transaction() diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index ba996c13b..6c8f7d5e9 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ApiSpec.NotificationOperation do - alias OpenApiSpex.Operation alias OpenApiSpex.Operation alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.Account @@ -125,7 +124,7 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do tags: ["Notifications"], summary: "Retrieve notification group accounts", operationId: "NotificationController.group_accounts", - security: [%{"oAuth" => ["write:notifications"]}], + security: [%{"oAuth" => ["read:notifications"]}], parameters: [group_key_param()], responses: %{ 200 => @@ -269,7 +268,7 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do }, example: %{ "id" => "34975861", - "group-key" => "ungrouped-34975861", + "group_key" => "ungrouped-34975861", "type" => "mention", "created_at" => "2019-11-23T07:49:02.064Z", "account" => Account.schema().example, diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index b15b0ea81..eecd1774f 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -55,16 +55,24 @@ defmodule Pleroma.Web.ControllerHelper do # TODO: Only fetch the params from open_api_spex when everything is converted @id_keys Pagination.page_keys() -- ["limit", "order"] + @id_key_atoms Enum.map(@id_keys, &String.to_atom/1) defp build_pagination_fields(conn, min_id, max_id, extra_params, order) do + path_param_keys = + conn.path_params + |> Map.keys() + |> Enum.flat_map(&[&1, String.to_existing_atom(&1)]) + params = if Map.has_key?(conn.private, :open_api_spex) do get_in(conn, [Access.key(:private), Access.key(:open_api_spex), Access.key(:params)]) else conn.params end - |> Map.drop(Map.keys(conn.path_params) |> Enum.map(&String.to_existing_atom/1)) + |> Map.drop(path_param_keys) |> Map.merge(extra_params) - |> Map.drop(@id_keys) + # OpenApiSpex casts params to atoms while uncast conn params are string-keyed. Drop both so + # generated links replace pagination cursors instead of preserving stale request cursors. + |> Map.drop(@id_keys ++ @id_key_atoms) {{next_id, nid}, {prev_id, pid}} = if order == :desc, diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index 5cd8576c1..2cc31db0c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -13,7 +13,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.Plugs.OAuthScopesPlug - @oauth_read_actions [:show, :index, :grouped_index, :show_group, :unread_count] + # Mastodon's docs currently list write:notifications for group accounts, but the endpoint is + # read-only and Mastodon's implementation accepts read:notifications. Prefer least privilege. + @oauth_read_actions [:show, :index, :grouped_index, :show_group, :group_accounts, :unread_count] plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) @@ -92,7 +94,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do conn, _ ) do - notifications = MastodonAPI.get_notification_group(user, group_key, %{}) + {notifications, notification_group_counts, notification_group_bounds} = + MastodonAPI.get_notification_group_result(user, group_key, %{}) if Enum.empty?(notifications) do conn @@ -103,8 +106,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do render(conn, "grouped_index.json", notification_groups: [notifications], + notification_group_counts: notification_group_counts, + notification_group_bounds: notification_group_bounds, for: user, - grouped_types: grouped_types + grouped_types: grouped_types, + include_page_metadata: false ) end end @@ -115,10 +121,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do conn, _ ) do - users = - user - |> MastodonAPI.get_notification_group(group_key, %{}) - |> notification_actors() + # Mastodon paginates this endpoint in code, but the public docs say it returns accounts of all + # notifications in the group and do not document cursor params here. Follow the documented API. + users = MastodonAPI.get_notification_group_accounts(user, group_key) json(conn, AccountView.render("index.json", %{users: users, for: user})) end @@ -154,12 +159,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do conn, _ ) do - ids = - user - |> MastodonAPI.get_notification_group(group_key, %{}) - |> Enum.map(& &1.id) - - Notification.destroy_multiple(user, ids) + MastodonAPI.dismiss_notification_group(user, group_key) json(conn, %{}) end @@ -179,7 +179,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do defp do_get_grouped_notifications(%{assigns: %{user: user}} = conn, params) do params = normalize_notification_params(params) - {notification_groups, page_notifications, notification_group_counts} = + {notification_groups, page_notifications, notification_group_counts, + notification_group_bounds} = MastodonAPI.get_grouped_notification_page(user, params) conn @@ -187,6 +188,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do |> render("grouped_index.json", notification_groups: notification_groups, notification_group_counts: notification_group_counts, + notification_group_bounds: notification_group_bounds, for: user, grouped_types: params["grouped_types"] ) @@ -203,13 +205,6 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do |> Map.put_new("types", Map.get(params, :include_types, @default_notification_types)) end - defp notification_actors(notifications) do - notifications - |> Enum.map(&User.get_cached_by_ap_id(&1.activity.data["actor"])) - |> Enum.filter(& &1) - |> Enum.uniq_by(& &1.id) - end - # GET /api/v1/notifications/:id def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with {:ok, notification} <- Notification.get(user, id) do diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index 57cbfd6bf..cb11a3f4a 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do import Ecto.Query import Ecto.Changeset + alias Pleroma.Marker alias Pleroma.Notification alias Pleroma.Pagination alias Pleroma.Repo @@ -13,6 +14,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do alias Pleroma.User alias Pleroma.Web.CommonAPI + @notification_group_sample_limit 8 + @spec follow(User.t(), User.t(), map) :: {:ok, User.t()} | {:error, String.t()} def follow(follower, followed, params \\ %{}) do result = @@ -68,38 +71,55 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do end def get_grouped_notification_page(user, params \\ %{}) do - grouped_types = Map.get(params, "grouped_types", Map.get(params, :grouped_types)) - {query, order} = group_pagination_query(notifications_query(user, params), params) + grouped_types = + params + |> Map.get("grouped_types", Map.get(params, :grouped_types)) + |> Notification.normalize_grouped_types() - notifications = + query = notifications_query(user, params) + {order, cursor_filters} = group_pagination(params) + + group_rows = query - |> order_by([n], [{^order, n.id}]) - |> Repo.all() + |> notification_group_rows(grouped_types, grouped_limit(params), order, cursor_filters) - notification_group_counts = - Enum.frequencies_by(notifications, &Notification.group_key(&1, grouped_types)) + group_rows = if order == :asc, do: Enum.reverse(group_rows), else: group_rows - page_notifications = - notifications - |> take_grouped_page_notifications(grouped_types, grouped_limit(params)) + page_notifications = representative_notifications(query, group_rows) - page_notifications = - if order == :asc, do: Enum.reverse(page_notifications), else: page_notifications + notification_groups = + Enum.map(group_rows, ¬ification_group_sample(user, params, &1.group_key)) { - Notification.group_notifications(page_notifications, grouped_types), + notification_groups, page_notifications, - notification_group_counts + notification_group_counts(group_rows), + notification_group_bounds(group_rows) } end def get_grouped_notification_groups(user, params \\ %{}) do - {groups, _notifications, _notification_group_counts} = + {groups, _notifications, _notification_group_counts, _notification_group_bounds} = get_grouped_notification_page(user, params) groups end + def get_notification_group_result(user, group_key, params \\ %{}) do + notifications = notification_group_sample(user, params, group_key) + metadata = notification_group_metadata(user, group_key, params) + + if Enum.empty?(notifications) or is_nil(metadata) do + {[], %{}, %{}} + else + { + notifications, + %{group_key => metadata.notifications_count}, + %{group_key => Map.drop(metadata, [:notifications_count])} + } + end + end + def get_notification_group(user, group_key, params \\ %{}) def get_notification_group(user, "ungrouped-" <> notification_id, _params) do @@ -110,36 +130,58 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do end def get_notification_group(user, group_key, params) do - grouped_types = Map.get(params, "grouped_types", Map.get(params, :grouped_types)) - - user - |> notifications_query(params) - |> order_by([n], desc: n.id) - |> Repo.all() - |> Enum.filter(&(Notification.group_key(&1, grouped_types) == group_key)) + notification_group_sample(user, params, group_key) end - defp group_pagination_query(query, params) do + def get_notification_group_accounts(user, "ungrouped-" <> notification_id) do + with {:ok, notification} <- Notification.get(user, notification_id), + %User{} = actor <- User.get_cached_by_ap_id(notification.activity.data["actor"]) do + [actor] + else + _ -> [] + end + end + + def get_notification_group_accounts(user, group_key) do + user + |> notification_group_query(group_key, %{}) + |> exclude(:preload) + |> distinct(true) + |> select([user_actor: user_actor], user_actor) + |> Repo.all() + end + + def dismiss_notification_group(user, "ungrouped-" <> notification_id) do + Notification.destroy_multiple(user, [notification_id]) + end + + def dismiss_notification_group(%User{id: user_id}, group_key) do + Notification + |> where([n], n.user_id == ^user_id and n.group_key == ^group_key) + |> Repo.delete_all() + end + + defp group_pagination(params) do cond do min_id = Map.get(params, "min_id", Map.get(params, :min_id)) -> - query = where(query, [n], n.id > ^min_id) + cursor_filters = [{:gt, min_id}] - query = + cursor_filters = case Map.get(params, "max_id", Map.get(params, :max_id)) do - nil -> query - max_id -> where(query, [n], n.id < ^max_id) + nil -> cursor_filters + max_id -> [{:lt, max_id} | cursor_filters] end - {query, :asc} + {:asc, cursor_filters} since_id = Map.get(params, "since_id", Map.get(params, :since_id)) -> - {where(query, [n], n.id > ^since_id), :desc} + {:desc, [{:gt, since_id}]} max_id = Map.get(params, "max_id", Map.get(params, :max_id)) -> - {where(query, [n], n.id < ^max_id), :desc} + {:desc, [{:lt, max_id}]} true -> - {query, :desc} + {:desc, []} end end @@ -150,41 +192,190 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do |> min(80) end - defp take_grouped_page_notifications(notifications, grouped_types, group_limit) do - {notifications, _group_keys} = - Enum.reduce_while(notifications, {[], MapSet.new()}, fn notification, - {notifications, group_keys} -> - group_key = Notification.group_key(notification, grouped_types) - - cond do - MapSet.member?(group_keys, group_key) -> - {:cont, {[notification | notifications], group_keys}} - - MapSet.size(group_keys) < group_limit -> - {:cont, {[notification | notifications], MapSet.put(group_keys, group_key)}} - - true -> - {:halt, {notifications, group_keys}} - end - end) - - Enum.reverse(notifications) - end - def unread_notification_group_count(user, params \\ %{}) do - grouped_types = Map.get(params, "grouped_types", Map.get(params, :grouped_types)) + grouped_types = + params + |> Map.get("grouped_types", Map.get(params, :grouped_types)) + |> Notification.normalize_grouped_types() + limit = unread_count_limit(params) user |> notifications_query(params) - |> where([n], n.seen == false) - |> order_by([n], desc: n.id) - |> limit(^limit) - |> Repo.all() - |> Notification.group_notifications(grouped_types) + # The grouped API docs define unread by the notifications marker, not by Pleroma's per-row + # seen flag used by the v1 unread count. Keep this marker-based for Mastodon clients. + |> restrict_after_marker(notification_marker_last_read_id(user)) + |> notification_group_rows(grouped_types, limit, :desc, []) |> length() end + defp notification_group_rows(query, grouped_types, group_limit, :desc, cursor_filters) do + query + |> notification_group_keyed_query(grouped_types) + |> group_by([n], n.group_key) + |> apply_group_cursor_filters(cursor_filters) + |> select([n], %{ + group_key: n.group_key, + representative_id: max(n.id), + notifications_count: count(n.id), + page_min_id: min(n.id), + page_max_id: max(n.id), + latest_page_notification_at: max(n.inserted_at) + }) + |> order_by([n], desc: max(n.id)) + |> limit(^group_limit) + |> Repo.all() + end + + defp notification_group_rows(query, grouped_types, group_limit, :asc, cursor_filters) do + query + |> notification_group_keyed_query(grouped_types) + |> group_by([n], n.group_key) + |> apply_group_cursor_filters(cursor_filters) + |> select([n], %{ + group_key: n.group_key, + representative_id: max(n.id), + notifications_count: count(n.id), + page_min_id: min(n.id), + page_max_id: max(n.id), + latest_page_notification_at: max(n.inserted_at) + }) + |> order_by([n], asc: max(n.id)) + |> limit(^group_limit) + |> Repo.all() + end + + defp apply_group_cursor_filters(query, []), do: query + + defp apply_group_cursor_filters(query, [{:gt, id} | rest]) do + query + |> having([n], max(n.id) > ^id) + |> apply_group_cursor_filters(rest) + end + + defp apply_group_cursor_filters(query, [{:lt, id} | rest]) do + query + |> having([n], max(n.id) < ^id) + |> apply_group_cursor_filters(rest) + end + + defp notification_group_keyed_query(query, grouped_types) do + query + |> exclude(:preload) + |> select([n], %{ + id: n.id, + inserted_at: n.inserted_at, + group_key: + fragment( + "CASE WHEN ? IS NOT NULL AND ?::text = ANY(?) THEN ? ELSE 'ungrouped-' || ?::text END", + n.group_key, + n.type, + type(^grouped_types, {:array, :string}), + n.group_key, + n.id + ) + }) + |> subquery() + end + + defp representative_notifications(_query, []), do: [] + + defp representative_notifications(query, group_rows) do + representative_ids = Enum.map(group_rows, & &1.representative_id) + + notifications_by_id = + query + |> where([n], n.id in ^representative_ids) + |> Repo.all() + |> Map.new(&{to_string(&1.id), &1}) + + group_rows + |> Enum.map(&Map.get(notifications_by_id, to_string(&1.representative_id))) + |> Enum.filter(& &1) + end + + defp notification_group_counts(group_rows) do + Map.new(group_rows, &{&1.group_key, &1.notifications_count}) + end + + defp notification_group_bounds(group_rows) do + Map.new(group_rows, fn row -> + {row.group_key, + %{ + page_min_id: row.page_min_id, + page_max_id: row.page_max_id, + latest_page_notification_at: row.latest_page_notification_at + }} + end) + end + + defp notification_group_sample(user, _params, "ungrouped-" <> notification_id) do + case Notification.get(user, notification_id) do + {:ok, notification} -> [notification] + _ -> [] + end + end + + defp notification_group_sample(user, params, group_key) do + user + |> notification_group_query(group_key, params) + |> order_by([n], desc: n.id) + |> limit(^@notification_group_sample_limit) + |> Repo.all() + end + + defp notification_group_metadata(user, "ungrouped-" <> notification_id, _params) do + case Notification.get(user, notification_id) do + {:ok, notification} -> + %{ + notifications_count: 1, + page_min_id: notification.id, + page_max_id: notification.id, + latest_page_notification_at: notification.inserted_at + } + + _ -> + nil + end + end + + defp notification_group_metadata(user, group_key, params) do + user + |> notification_group_query(group_key, params) + |> exclude(:preload) + |> select([n], %{ + notifications_count: count(n.id), + page_min_id: min(n.id), + page_max_id: max(n.id), + latest_page_notification_at: max(n.inserted_at) + }) + |> Repo.one() + |> case do + %{notifications_count: 0} -> nil + metadata -> metadata + end + end + + defp notification_group_query(user, group_key, params) do + user + |> notifications_query(params) + |> where([n], n.group_key == ^group_key) + end + + defp notification_marker_last_read_id(user) do + Marker + |> where([m], m.user_id == ^user.id and m.timeline == "notifications") + |> select([m], m.last_read_id) + |> Repo.one() + end + + defp restrict_after_marker(query, last_read_id) + when is_binary(last_read_id) and last_read_id != "" do + where(query, [n], n.id > ^last_read_id) + end + + defp restrict_after_marker(query, _last_read_id), do: query + defp notifications_query(user, params) do options = notification_options(user, params) diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 466073853..33d4b2924 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -90,6 +90,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do ) do grouped_types = Notification.normalize_grouped_types(opts[:grouped_types]) notification_group_counts = Map.get(opts, :notification_group_counts, %{}) + notification_group_bounds = Map.get(opts, :notification_group_bounds, %{}) + include_page_metadata = Map.get(opts, :include_page_metadata, true) statuses = notification_groups @@ -110,7 +112,14 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do notification_groups: Enum.map( notification_groups, - &render_group(&1, reading_user, grouped_types, notification_group_counts) + &render_group( + &1, + reading_user, + grouped_types, + notification_group_counts, + notification_group_bounds, + include_page_metadata + ) ) } end @@ -184,27 +193,50 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do [%Notification{} = notification | _] = notifications, _reading_user, grouped_types, - notification_group_counts + notification_group_counts, + notification_group_bounds, + include_page_metadata ) do latest_notification = List.first(notifications) oldest_notification = List.last(notifications) status_activity = status_activity_for_group(notifications, grouped_types) group_key = Notification.group_key(notification, grouped_types) + bounds = Map.get(notification_group_bounds, group_key, %{}) response = %{ group_key: group_key, notifications_count: Map.get(notification_group_counts, group_key, length(notifications)), type: notification.type, - most_recent_notification_id: to_string(latest_notification.id), - page_min_id: to_string(oldest_notification.id), - page_max_id: to_string(latest_notification.id), - latest_page_notification_at: CommonAPI.Utils.to_masto_date(latest_notification.inserted_at), + most_recent_notification_id: + bounds + |> Map.get(:page_max_id, latest_notification.id) + |> to_string(), sample_account_ids: notifications |> notification_actors() |> Enum.map(&to_string(&1.id)) } + response = + if include_page_metadata do + Map.merge(response, %{ + page_min_id: + bounds + |> Map.get(:page_min_id, oldest_notification.id) + |> to_string(), + page_max_id: + bounds + |> Map.get(:page_max_id, latest_notification.id) + |> to_string(), + latest_page_notification_at: + bounds + |> Map.get(:latest_page_notification_at, latest_notification.inserted_at) + |> CommonAPI.Utils.to_masto_date() + }) + else + response + end + if status_activity do Map.put(response, :status_id, to_string(status_activity.id)) else diff --git a/priv/repo/migrations/20260523070000_add_group_key_to_notifications.exs b/priv/repo/migrations/20260523070000_add_group_key_to_notifications.exs new file mode 100644 index 000000000..7c7d529e0 --- /dev/null +++ b/priv/repo/migrations/20260523070000_add_group_key_to_notifications.exs @@ -0,0 +1,15 @@ +defmodule Pleroma.Repo.Migrations.AddGroupKeyToNotifications do + use Ecto.Migration + + def change do + alter table(:notifications) do + add(:group_key, :string) + end + + create_if_not_exists( + index(:notifications, [:user_id, :group_key, "id desc nulls last"], + where: "group_key IS NOT NULL" + ) + ) + end +end diff --git a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs index 1d3a9e966..e9c521d6b 100644 --- a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs @@ -587,10 +587,10 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do {:ok, _} = CommonAPI.favorite(status.id, other_user1) {:ok, _} = CommonAPI.favorite(status.id, other_user2) - notification_ids = - Notification - |> Repo.all() - |> Enum.map(&to_string(&1.id)) + notifications = Repo.all(Notification) + notification_ids = Enum.map(notifications, &to_string(&1.id)) + assert [persisted_group_key] = notifications |> Enum.map(& &1.group_key) |> Enum.uniq() + assert is_binary(persisted_group_key) result = conn @@ -613,6 +613,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do assert group["latest_page_notification_at"] assert Enum.sort(group["sample_account_ids"]) == Enum.sort([other_user1.id, other_user2.id]) group_key = group["group_key"] + assert group_key == persisted_group_key assert [%{"group_key" => ^group_key}, %{"group_key" => ^group_key}] = conn @@ -626,6 +627,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do assert shown_group["group_key"] == group_key assert shown_group["notifications_count"] == 2 + refute Map.has_key?(shown_group, "page_min_id") + refute Map.has_key?(shown_group, "page_max_id") + refute Map.has_key?(shown_group, "latest_page_notification_at") end test "round-trips ungrouped group keys when grouped_types excludes a type" do @@ -648,6 +652,48 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do |> json_response_and_validate_schema(200) end + test "keeps legacy notifications without persisted group keys ungrouped" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user1 = insert(:user) + other_user2 = insert(:user) + + {:ok, status} = CommonAPI.post(user, %{status: "hello"}) + {:ok, favorite1} = CommonAPI.favorite(status.id, other_user1) + {:ok, favorite2} = CommonAPI.favorite(status.id, other_user2) + + notification_ids = + [favorite1, favorite2] + |> Enum.map(&get_notification_id_by_activity/1) + + Repo.update_all(Notification, set: [group_key: nil]) + + expected_group_keys = Enum.map(notification_ids, &"ungrouped-#{&1}") + + assert [%{"group_key" => group_key1}, %{"group_key" => group_key2}] = + conn + |> get("/api/v1/notifications") + |> json_response_and_validate_schema(200) + + assert Enum.sort([group_key1, group_key2]) == Enum.sort(expected_group_keys) + + %{"notification_groups" => groups} = + conn + |> get("/api/v2/notifications") + |> json_response_and_validate_schema(200) + + assert Enum.sort(Enum.map(groups, & &1["group_key"])) == Enum.sort(expected_group_keys) + assert Enum.all?(groups, &(&1["notifications_count"] == 1)) + + group_key = List.first(expected_group_keys) + + assert %{ + "notification_groups" => [%{"group_key" => ^group_key, "notifications_count" => 1}] + } = + conn + |> get("/api/v2/notifications/#{group_key}") + |> json_response_and_validate_schema(200) + end + test "paginates notification groups instead of raw notifications" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user1 = insert(:user) @@ -674,6 +720,44 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do ] = groups end + test "uses group-level cursors when paginating notification groups" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user1 = insert(:user) + other_user2 = insert(:user) + other_user3 = insert(:user) + + {:ok, older_status} = CommonAPI.post(user, %{status: "older"}) + {:ok, _} = CommonAPI.favorite(older_status.id, other_user3) + + {:ok, newer_status} = CommonAPI.post(user, %{status: "newer"}) + {:ok, _} = CommonAPI.favorite(newer_status.id, other_user1) + {:ok, _} = CommonAPI.favorite(newer_status.id, other_user2) + older_status_id = older_status.id + newer_status_id = newer_status.id + + assert %{ + "notification_groups" => [ + %{ + "status_id" => ^newer_status_id, + "notifications_count" => 2, + "most_recent_notification_id" => cursor + } + ] + } = + conn + |> get("/api/v2/notifications?limit=1") + |> json_response_and_validate_schema(200) + + assert %{ + "notification_groups" => [ + %{"status_id" => ^older_status_id, "notifications_count" => 1} + ] + } = + conn + |> get("/api/v2/notifications?limit=1&max_id=#{cursor}") + |> json_response_and_validate_schema(200) + end + test "returns total notification count for a partially represented group" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user1 = insert(:user) @@ -715,27 +799,78 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do |> json_response_and_validate_schema(200) end + test "counts unread notification groups newer than the notification marker" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user = insert(:user) + mentioner = insert(:user) + + {:ok, status} = CommonAPI.post(user, %{status: "older"}) + {:ok, favorite_activity} = CommonAPI.favorite(status.id, other_user) + marker_id = get_notification_id_by_activity(favorite_activity) + + {:ok, _} = CommonAPI.post(mentioner, %{status: "newer @#{user.nickname}"}) + + {:ok, _} = + Pleroma.Marker.upsert(user, %{"notifications" => %{"last_read_id" => marker_id}}) + + assert %{"count" => 1} = + conn + |> get("/api/v2/notifications/unread_count") + |> json_response_and_validate_schema(200) + end + + test "does not preserve stale cursor params in grouped notification link headers" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user = insert(:user) + + {:ok, activity1} = CommonAPI.post(other_user, %{status: "one @#{user.nickname}"}) + {:ok, _activity2} = CommonAPI.post(other_user, %{status: "two @#{user.nickname}"}) + {:ok, activity3} = CommonAPI.post(other_user, %{status: "three @#{user.nickname}"}) + + notification1_id = get_notification_id_by_activity(activity1) + notification3_id = get_notification_id_by_activity(activity3) + + conn = get(conn, "/api/v2/notifications?since_id=#{notification1_id}&limit=1") + + assert [link_header] = get_resp_header(conn, "link") + assert link_header =~ ~r/max_id=#{notification3_id}/ + refute link_header =~ "since_id=" + end + test "lists accounts from and dismisses a notification group" do %{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"]) - other_user1 = insert(:user) - other_user2 = insert(:user) + other_users = insert_list(9, :user) {:ok, status} = CommonAPI.post(user, %{status: "hello"}) - {:ok, _} = CommonAPI.favorite(status.id, other_user1) - {:ok, _} = CommonAPI.favorite(status.id, other_user2) - %{"notification_groups" => [%{"group_key" => group_key}]} = + Enum.each(other_users, fn other_user -> + {:ok, _} = CommonAPI.favorite(status.id, other_user) + end) + + %{ + "notification_groups" => [ + %{ + "group_key" => group_key, + "notifications_count" => 9, + "sample_account_ids" => sample_account_ids + } + ] + } = conn |> get("/api/v2/notifications") |> json_response_and_validate_schema(200) + assert length(sample_account_ids) == 8 + + %{conn: read_conn} = oauth_access(["read:notifications"], user: user) + account_ids = - conn + read_conn |> get("/api/v2/notifications/#{group_key}/accounts") |> json_response_and_validate_schema(200) |> Enum.map(& &1["id"]) - assert Enum.sort(account_ids) == Enum.sort([other_user1.id, other_user2.id]) + assert Enum.sort(account_ids) == Enum.sort(Enum.map(other_users, & &1.id)) assert %{} = conn diff --git a/test/pleroma/web/mastodon_api/views/notification_view_test.exs b/test/pleroma/web/mastodon_api/views/notification_view_test.exs index aa11ad682..2d0801589 100644 --- a/test/pleroma/web/mastodon_api/views/notification_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/notification_view_test.exs @@ -98,6 +98,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do {:ok, favorite_activity} = CommonAPI.favorite(create_activity.id, another_user) {:ok, [notification]} = Notification.create_notifications(favorite_activity) create_activity = Activity.get_by_id(create_activity.id) + assert is_binary(notification.group_key) expected = %{ id: to_string(notification.id), @@ -119,6 +120,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do {:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, another_user) {:ok, [notification]} = Notification.create_notifications(reblog_activity) reblog_activity = Activity.get_by_id(create_activity.id) + assert is_binary(notification.group_key) expected = %{ id: to_string(notification.id), @@ -138,6 +140,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do followed = insert(:user) {:ok, followed, follower, _activity} = CommonAPI.follow(followed, follower) notification = Notification |> Repo.one() |> Repo.preload(:activity) + assert is_binary(notification.group_key) expected = %{ id: to_string(notification.id), From 1db2b99e4873a5196cbbb393c294419e10045a15 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sat, 23 May 2026 14:00:17 +0400 Subject: [PATCH 090/195] Run group key migration before notification backfill --- ...ions.exs => 20200602125217_add_group_key_to_notifications.exs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename priv/repo/migrations/{20260523070000_add_group_key_to_notifications.exs => 20200602125217_add_group_key_to_notifications.exs} (100%) diff --git a/priv/repo/migrations/20260523070000_add_group_key_to_notifications.exs b/priv/repo/migrations/20200602125217_add_group_key_to_notifications.exs similarity index 100% rename from priv/repo/migrations/20260523070000_add_group_key_to_notifications.exs rename to priv/repo/migrations/20200602125217_add_group_key_to_notifications.exs From a2a23b327b9c32420b5a6aed4198073b4f363864 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sat, 23 May 2026 14:27:11 +0400 Subject: [PATCH 091/195] Keep cursor cleanup scoped to grouped notifications --- lib/pleroma/web/controller_helper.ex | 10 +++++++--- .../controllers/notification_controller.ex | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index eecd1774f..0c85e79f9 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -56,7 +56,11 @@ defmodule Pleroma.Web.ControllerHelper do # TODO: Only fetch the params from open_api_spex when everything is converted @id_keys Pagination.page_keys() -- ["limit", "order"] @id_key_atoms Enum.map(@id_keys, &String.to_atom/1) + @drop_id_params_key :drop_id_params defp build_pagination_fields(conn, min_id, max_id, extra_params, order) do + drop_id_params? = Map.get(extra_params, @drop_id_params_key, false) + extra_params = Map.delete(extra_params, @drop_id_params_key) + path_param_keys = conn.path_params |> Map.keys() @@ -70,9 +74,9 @@ defmodule Pleroma.Web.ControllerHelper do end |> Map.drop(path_param_keys) |> Map.merge(extra_params) - # OpenApiSpex casts params to atoms while uncast conn params are string-keyed. Drop both so - # generated links replace pagination cursors instead of preserving stale request cursors. - |> Map.drop(@id_keys ++ @id_key_atoms) + # Some OpenApiSpex routes cast cursor params to atoms. Keep the historical default for + # existing endpoints, but allow grouped notifications to opt out of stale request cursors. + |> Map.drop(@id_keys ++ if(drop_id_params?, do: @id_key_atoms, else: [])) {{next_id, nid}, {prev_id, pid}} = if order == :desc, diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index 2cc31db0c..8a5f8e7b6 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, add_link_headers: 3] alias Pleroma.Notification alias Pleroma.User @@ -184,7 +184,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do MastodonAPI.get_grouped_notification_page(user, params) conn - |> add_link_headers(page_notifications) + |> add_link_headers(page_notifications, %{drop_id_params: true}) |> render("grouped_index.json", notification_groups: notification_groups, notification_group_counts: notification_group_counts, From 94a28d128605bc4e3328293a01585471c193f82b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 16 May 2026 00:31:02 +0200 Subject: [PATCH 092/195] RichMedia Backfill: Add cachex positive test --- test/pleroma/web/rich_media/backfill_test.exs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/pleroma/web/rich_media/backfill_test.exs b/test/pleroma/web/rich_media/backfill_test.exs index 6d221fcf5..8b4834bf7 100644 --- a/test/pleroma/web/rich_media/backfill_test.exs +++ b/test/pleroma/web/rich_media/backfill_test.exs @@ -23,4 +23,26 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do Backfill.run(%{"url" => url}) end + + test "sets a warm_cache entry" do + url = "https://good.example.com/" + url_hash = Card.url_to_hash(url) + + Tesla.Mock.mock(fn %{url: ^url} -> + {:ok, + %Tesla.Env{ + status: 200, + body: "" + }} + end) + + Pleroma.CachexMock + |> expect(:put, fn :rich_media_cache, + ^url_hash, + %Pleroma.Web.RichMedia.Card{url_hash: ^url_hash} -> + {:ok, true} + end) + + Backfill.run(%{"url" => url}) + end end From 678fe8a0642771e24d7b4d9b1df78578024351fe Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 20 May 2026 20:16:19 +0200 Subject: [PATCH 093/195] RichMedia: Add support for disabling wss streaming out on backfill --- .../web/mastodon_api/views/status_view.ex | 9 +-- lib/pleroma/web/rich_media/backfill.ex | 14 +++-- lib/pleroma/web/rich_media/card.ex | 16 ++++-- test/pleroma/web/rich_media/backfill_test.exs | 56 ++++++++++++++++++- 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 09af60865..80d53adff 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -28,9 +28,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do # This is a naive way to do this, just spawning a process per activity # to fetch the preview. However it should be fine considering # pagination is restricted to 40 activities at a time - defp fetch_rich_media_for_activities(activities) do + defp fetch_rich_media_for_activities(activities, opts) do Enum.each(activities, fn activity -> - Card.get_by_activity(activity) + Card.get_by_activity(activity, opts) end) end @@ -113,7 +113,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do activities = Enum.filter(opts.activities, & &1) # Start prefetching rich media before doing anything else - fetch_rich_media_for_activities(activities) + fetch_rich_media_for_activities(activities, opts) + replied_to_activities = get_replied_to_activities(activities) quoted_activities = get_quoted_activities(activities) @@ -362,7 +363,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do summary = object.data["summary"] || "" card = - case Card.get_by_activity(activity) do + case Card.get_by_activity(activity, opts) do %Card{} = result -> render("card.json", result) _ -> nil end diff --git a/lib/pleroma/web/rich_media/backfill.ex b/lib/pleroma/web/rich_media/backfill.ex index 1cd90629f..607c5238a 100644 --- a/lib/pleroma/web/rich_media/backfill.ex +++ b/lib/pleroma/web/rich_media/backfill.ex @@ -11,6 +11,8 @@ defmodule Pleroma.Web.RichMedia.Backfill do require Logger + @callback run(map()) :: :ok | Parser.parse_errors() | Helpers.get_errors() + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) @stream_out_impl Pleroma.Config.get( [__MODULE__, :stream_out], @@ -26,11 +28,7 @@ defmodule Pleroma.Web.RichMedia.Backfill do {:ok, card} = Card.create(url, fields) maybe_schedule_expiration(url, fields) - - with %{"activity_id" => activity_id} <- args, - false <- is_nil(activity_id) do - stream_update(args) - end + maybe_update_stream(args) warm_cache(url_hash, card) :ok @@ -55,12 +53,16 @@ defmodule Pleroma.Web.RichMedia.Backfill do end end - defp stream_update(%{"activity_id" => activity_id}) do + defp maybe_update_stream(%{"activity_id" => activity_id, "stream" => true}) when is_binary(activity_id) do Pleroma.Activity.get_by_id(activity_id) |> Pleroma.Activity.normalize() |> @stream_out_impl.stream_out() end + # Streamer.stream_out returns noop when unsupported activity type is requested to be streamed. + # Do the same here for unwanted streaming + defp maybe_update_stream(_), do: :noop + defp warm_cache(key, val), do: @cachex.put(:rich_media_cache, key, val) defp negative_cache(key, ttl \\ :timer.minutes(15)), diff --git a/lib/pleroma/web/rich_media/card.ex b/lib/pleroma/web/rich_media/card.ex index 6b4bb9555..86a4f577a 100644 --- a/lib/pleroma/web/rich_media/card.ex +++ b/lib/pleroma/web/rich_media/card.ex @@ -90,8 +90,12 @@ defmodule Pleroma.Web.RichMedia.Card do nil -> activity_id = Keyword.get(opts, :activity_id, nil) + # Nested opts, first layer comes from get_by_activity/2 as Keyword, second from API views/Federation as Map. + # Provide default Map when called directly. + opts = Keyword.get(opts, :opts, %{}) + stream = Map.get(opts, :stream, true) - RichMediaWorker.new(%{"op" => "backfill", "url" => url, "activity_id" => activity_id}) + RichMediaWorker.new(%{"op" => "backfill", "url" => url, "activity_id" => activity_id, "stream" => stream}) |> Oban.insert() nil @@ -112,9 +116,11 @@ defmodule Pleroma.Web.RichMedia.Card do end end - @spec get_by_activity(Activity.t()) :: t() | nil | :error + @spec get_by_activity(Activity.t(), %{}) :: t() | nil | :error + def get_by_activity(activity, opts \\ %{}) + # Fake/Draft activity - def get_by_activity(%Activity{id: "pleroma:fakeid"} = activity) do + def get_by_activity(%Activity{id: "pleroma:fakeid"} = activity, _opts) do with {_, true} <- {:config, @config_impl.get([:rich_media, :enabled])}, %Object{} = object <- Object.normalize(activity, fetch: false), url when not is_nil(url) <- HTML.extract_first_external_url_from_object(object) do @@ -138,13 +144,13 @@ defmodule Pleroma.Web.RichMedia.Card do end end - def get_by_activity(activity) do + def get_by_activity(activity, opts) do with %Object{} = object <- Object.normalize(activity, fetch: false), {_, nil} <- {:cached, get_cached_url(object, activity.id)} do nil else {:cached, url} -> - get_or_backfill_by_url(url, activity_id: activity.id) + get_or_backfill_by_url(url, activity_id: activity.id, opts: opts) _ -> :error diff --git a/test/pleroma/web/rich_media/backfill_test.exs b/test/pleroma/web/rich_media/backfill_test.exs index 8b4834bf7..f7e74c02a 100644 --- a/test/pleroma/web/rich_media/backfill_test.exs +++ b/test/pleroma/web/rich_media/backfill_test.exs @@ -5,12 +5,20 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do use Pleroma.DataCase + alias Pleroma.Web.CommonAPI alias Pleroma.Web.RichMedia.Backfill alias Pleroma.Web.RichMedia.Card import Mox + import Pleroma.Factory - setup_all do: clear_config([:rich_media, :enabled], true) + setup do + clear_config([:rich_media, :enabled], true) + + Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache) + + :ok + end test "sets a negative cache entry for an error" do url = "https://bad.example.com/" @@ -45,4 +53,50 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do Backfill.run(%{"url" => url}) end + + test "streams out update when stream == true" do + url = "https://example.com" + user = insert(:user) + + Tesla.Mock.mock(fn %{url: ^url} -> + {:ok, + %Tesla.Env{ + status: 200, + body: "" + }} + end) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"}) + + Pleroma.CachexMock + |> expect(:put, fn :rich_media_cache, _, _ -> {:ok, true} end) + + Pleroma.Web.ActivityPub.ActivityPubMock + |> expect(:stream_out, fn _ -> :ok end) + + Backfill.run(%{"url" => url, "activity_id" => "#{activity.data["id"]}", "stream" => true}) + end + + test "does not stream out update when stream == false" do + url = "https://example.com" + user = insert(:user) + + Tesla.Mock.mock(fn %{url: ^url} -> + {:ok, + %Tesla.Env{ + status: 200, + body: "" + }} + end) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"}) + + Pleroma.CachexMock + |> expect(:put, fn :rich_media_cache, _, _ -> {:ok, true} end) + + Pleroma.Web.ActivityPub.ActivityPubMock + |> deny(:stream_out, 1) + + Backfill.run(%{"url" => url, "activity_id" => "#{activity.data["id"]}", "stream" => false}) + end end From 5f55c9653c64ced6e1461224a942a303e82627d8 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 20 May 2026 20:18:47 +0200 Subject: [PATCH 094/195] RichMedia: Disable websockets backfill streaming in StatusView --- lib/pleroma/web/mastodon_api/views/status_view.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 80d53adff..cc807f603 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -28,7 +28,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do # This is a naive way to do this, just spawning a process per activity # to fetch the preview. However it should be fine considering # pagination is restricted to 40 activities at a time + # Force disable Websockets streaming for backfill jobs, + # otherwise old posts can show up on timelines. defp fetch_rich_media_for_activities(activities, opts) do + opts = Map.put(opts, :stream, false) Enum.each(activities, fn activity -> Card.get_by_activity(activity, opts) end) @@ -362,8 +365,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do summary = object.data["summary"] || "" + # Force disable Websockets streaming for backfill jobs which the below call will create, + # otherwise old posts can show up on timelines. card = - case Card.get_by_activity(activity, opts) do + case Card.get_by_activity(activity, Map.put(opts, :stream, false)) do %Card{} = result -> render("card.json", result) _ -> nil end From ff7927e219f84ada9235353457f2c2497d336964 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 22 May 2026 14:53:28 +0200 Subject: [PATCH 095/195] changelog --- changelog.d/wss-necroposts.fix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/wss-necroposts.fix diff --git a/changelog.d/wss-necroposts.fix b/changelog.d/wss-necroposts.fix new file mode 100644 index 000000000..743f9087c --- /dev/null +++ b/changelog.d/wss-necroposts.fix @@ -0,0 +1 @@ +RichMedia: Fix backfill causing old posts to show up on timelines by disabling it in MastoAPI StatusView From 6ee40cb2ebe7a6666bedea3773710a3b555791ad Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 23 May 2026 19:43:44 +0200 Subject: [PATCH 096/195] RichMedia: Add StatusView backfill streaming tests --- test/pleroma/web/rich_media/backfill_test.exs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/test/pleroma/web/rich_media/backfill_test.exs b/test/pleroma/web/rich_media/backfill_test.exs index f7e74c02a..a4b2d34fd 100644 --- a/test/pleroma/web/rich_media/backfill_test.exs +++ b/test/pleroma/web/rich_media/backfill_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do alias Pleroma.Web.CommonAPI alias Pleroma.Web.RichMedia.Backfill alias Pleroma.Web.RichMedia.Card + alias Pleroma.Tests.ObanHelpers import Mox import Pleroma.Factory @@ -15,6 +16,7 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do setup do clear_config([:rich_media, :enabled], true) + Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig) Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache) :ok @@ -99,4 +101,64 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do Backfill.run(%{"url" => url, "activity_id" => "#{activity.data["id"]}", "stream" => false}) end + + # NOTE: Below two MastoAPI tests cover almost the same code paths. + # index.json will always prefetch rich media, while show.json will try to get the card and + # fetch it when it isn't cached (both use Card.get_by_activity in the end). + # So if index.json doesn't fetch the rich media, show.json will when it renders the post, + # hence why index.json test will only call ActivityPub.stream_out twice, + # if streaming is re-enabled for in both. + test "does not stream out in MastoAPI StatusView index" do + url = "https://example.com" + user = insert(:user) + + Tesla.Mock.mock(fn %{url: ^url} -> + {:ok, + %Tesla.Env{ + status: 200, + body: "" + }} + end) + + # CommonAPI federation processing will stream out once as a new post + Pleroma.Web.ActivityPub.ActivityPubMock + |> expect(:stream_out, 1, fn _ -> :ok end) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"}) + ObanHelpers.perform_all() + + # Clear cache to force backfill below + Pleroma.Activity.HTML.invalidate_cache_for(activity.id) + Pleroma.Web.RichMedia.Card.delete(url) + + Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{activities: [activity], as: :activity}) + ObanHelpers.perform_all() + end + + test "does not stream out in MastoAPI StatusView show" do + url = "https://example.com" + user = insert(:user) + + Tesla.Mock.mock(fn %{url: ^url} -> + {:ok, + %Tesla.Env{ + status: 200, + body: "" + }} + end) + + # CommonAPI federation processing will stream out once as a new post + Pleroma.Web.ActivityPub.ActivityPubMock + |> expect(:stream_out, 1, fn _ -> :ok end) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"}) + ObanHelpers.perform_all() + + # Clear cache to force backfill below + Pleroma.Activity.HTML.invalidate_cache_for(activity.id) + Pleroma.Web.RichMedia.Card.delete(url) + + Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity) + ObanHelpers.perform_all() + end end From 0c0d6873306244ad98273a6ae661a4414cee875b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 23 May 2026 19:49:22 +0200 Subject: [PATCH 097/195] credo, lint --- lib/pleroma/web/mastodon_api/views/status_view.ex | 1 + lib/pleroma/web/rich_media/backfill.ex | 3 ++- lib/pleroma/web/rich_media/card.ex | 11 +++++++++-- test/pleroma/web/rich_media/backfill_test.exs | 10 +++++++--- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index cc807f603..6949c5f2d 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -32,6 +32,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do # otherwise old posts can show up on timelines. defp fetch_rich_media_for_activities(activities, opts) do opts = Map.put(opts, :stream, false) + Enum.each(activities, fn activity -> Card.get_by_activity(activity, opts) end) diff --git a/lib/pleroma/web/rich_media/backfill.ex b/lib/pleroma/web/rich_media/backfill.ex index 607c5238a..a100b39ab 100644 --- a/lib/pleroma/web/rich_media/backfill.ex +++ b/lib/pleroma/web/rich_media/backfill.ex @@ -53,7 +53,8 @@ defmodule Pleroma.Web.RichMedia.Backfill do end end - defp maybe_update_stream(%{"activity_id" => activity_id, "stream" => true}) when is_binary(activity_id) do + defp maybe_update_stream(%{"activity_id" => activity_id, "stream" => true}) + when is_binary(activity_id) do Pleroma.Activity.get_by_id(activity_id) |> Pleroma.Activity.normalize() |> @stream_out_impl.stream_out() diff --git a/lib/pleroma/web/rich_media/card.ex b/lib/pleroma/web/rich_media/card.ex index 86a4f577a..1e9e66ec1 100644 --- a/lib/pleroma/web/rich_media/card.ex +++ b/lib/pleroma/web/rich_media/card.ex @@ -90,12 +90,19 @@ defmodule Pleroma.Web.RichMedia.Card do nil -> activity_id = Keyword.get(opts, :activity_id, nil) - # Nested opts, first layer comes from get_by_activity/2 as Keyword, second from API views/Federation as Map. + + # Nested opts, first layer comes from get_by_activity/2 as Keyword, + # second from API views/Federation as Map. # Provide default Map when called directly. opts = Keyword.get(opts, :opts, %{}) stream = Map.get(opts, :stream, true) - RichMediaWorker.new(%{"op" => "backfill", "url" => url, "activity_id" => activity_id, "stream" => stream}) + RichMediaWorker.new(%{ + "op" => "backfill", + "url" => url, + "activity_id" => activity_id, + "stream" => stream + }) |> Oban.insert() nil diff --git a/test/pleroma/web/rich_media/backfill_test.exs b/test/pleroma/web/rich_media/backfill_test.exs index a4b2d34fd..a1949533a 100644 --- a/test/pleroma/web/rich_media/backfill_test.exs +++ b/test/pleroma/web/rich_media/backfill_test.exs @@ -5,15 +5,15 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do use Pleroma.DataCase + alias Pleroma.Tests.ObanHelpers alias Pleroma.Web.CommonAPI alias Pleroma.Web.RichMedia.Backfill alias Pleroma.Web.RichMedia.Card - alias Pleroma.Tests.ObanHelpers import Mox import Pleroma.Factory - setup do + setup do clear_config([:rich_media, :enabled], true) Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig) @@ -131,7 +131,11 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do Pleroma.Activity.HTML.invalidate_cache_for(activity.id) Pleroma.Web.RichMedia.Card.delete(url) - Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{activities: [activity], as: :activity}) + Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ + activities: [activity], + as: :activity + }) + ObanHelpers.perform_all() end From 4c9fc6287e547d908ee0723996fc978653d4652d Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 25 May 2026 09:26:07 +0400 Subject: [PATCH 098/195] Remove unreachable WebSocket protocol match --- lib/pleroma/web/mastodon_api/websocket_plug.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/websocket_plug.ex b/lib/pleroma/web/mastodon_api/websocket_plug.ex index 58ee913b4..c5fb5b748 100644 --- a/lib/pleroma/web/mastodon_api/websocket_plug.ex +++ b/lib/pleroma/web/mastodon_api/websocket_plug.ex @@ -94,7 +94,6 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketPlug do [protocols | _] -> case Plug.Conn.Utils.list(protocols) do [protocol | _] -> put_resp_header(conn, "sec-websocket-protocol", protocol) - nil -> conn [] -> conn end From cdb0f103a8f7f141185e2bff6d686b047f368fa2 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 25 May 2026 14:08:07 +0400 Subject: [PATCH 099/195] Tighten rich media backfill stream test --- test/pleroma/web/rich_media/backfill_test.exs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/pleroma/web/rich_media/backfill_test.exs b/test/pleroma/web/rich_media/backfill_test.exs index a1949533a..071f9b48a 100644 --- a/test/pleroma/web/rich_media/backfill_test.exs +++ b/test/pleroma/web/rich_media/backfill_test.exs @@ -74,9 +74,12 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do |> expect(:put, fn :rich_media_cache, _, _ -> {:ok, true} end) Pleroma.Web.ActivityPub.ActivityPubMock - |> expect(:stream_out, fn _ -> :ok end) + |> expect(:stream_out, fn %Pleroma.Activity{id: id} -> + assert id == activity.id + :ok + end) - Backfill.run(%{"url" => url, "activity_id" => "#{activity.data["id"]}", "stream" => true}) + Backfill.run(%{"url" => url, "activity_id" => activity.id, "stream" => true}) end test "does not stream out update when stream == false" do From 8a410bf49f1818ebf631020c55ba05012efcb8f7 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 25 May 2026 16:04:53 +0400 Subject: [PATCH 100/195] Quote E2E entrypoint variables --- docker-entrypoint-e2e.sh | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docker-entrypoint-e2e.sh b/docker-entrypoint-e2e.sh index c83c44a80..528a09619 100755 --- a/docker-entrypoint-e2e.sh +++ b/docker-entrypoint-e2e.sh @@ -5,12 +5,12 @@ SEED_SENTINEL_PATH=/var/lib/pleroma/.e2e_seeded CONFIG_OVERRIDE_PATH=/var/lib/pleroma/config.exs echo '-- Waiting for database...' -while ! pg_isready -U ${DB_USER:-pleroma} -d postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma} -t 1; do +while ! pg_isready -U "${DB_USER:-pleroma}" -d "postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma}" -t 1; do sleep 1s done echo '-- Writing E2E config overrides...' -cat > $CONFIG_OVERRIDE_PATH < "$CONFIG_OVERRIDE_PATH" </dev/null; then - kill -TERM $PLEROMA_PID - wait $PLEROMA_PID || true + if kill -0 "$PLEROMA_PID" 2>/dev/null; then + kill -TERM "$PLEROMA_PID" + wait "$PLEROMA_PID" || true fi } @@ -48,23 +48,23 @@ for _i in $(seq 1 120); do sleep 1s done -if [ $api_ok != true ]; then +if [ "$api_ok" != true ]; then echo 'Timed out waiting for Pleroma API to become available' exit 1 fi -if [ ! -f $SEED_SENTINEL_PATH ]; then - if [ -n ${E2E_ADMIN_USERNAME:-} ] && [ -n ${E2E_ADMIN_PASSWORD:-} ] && [ -n ${E2E_ADMIN_EMAIL:-} ]; then - echo '-- Seeding admin user' $E2E_ADMIN_USERNAME '...' - if ! /opt/pleroma/bin/pleroma_ctl user new $E2E_ADMIN_USERNAME $E2E_ADMIN_EMAIL --admin --password $E2E_ADMIN_PASSWORD -y; then +if [ ! -f "$SEED_SENTINEL_PATH" ]; then + if [ -n "${E2E_ADMIN_USERNAME:-}" ] && [ -n "${E2E_ADMIN_PASSWORD:-}" ] && [ -n "${E2E_ADMIN_EMAIL:-}" ]; then + echo '-- Seeding admin user' "$E2E_ADMIN_USERNAME" '...' + if ! /opt/pleroma/bin/pleroma_ctl user new "$E2E_ADMIN_USERNAME" "$E2E_ADMIN_EMAIL" --admin --password "$E2E_ADMIN_PASSWORD" -y; then echo '-- User already exists or creation failed, ensuring admin + confirmed...' - /opt/pleroma/bin/pleroma_ctl user set $E2E_ADMIN_USERNAME --admin --confirmed + /opt/pleroma/bin/pleroma_ctl user set "$E2E_ADMIN_USERNAME" --admin --confirmed fi else echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)' fi - touch $SEED_SENTINEL_PATH + touch "$SEED_SENTINEL_PATH" fi -wait $PLEROMA_PID +wait "$PLEROMA_PID" From 54369c93d78d4431f10a44af9c8f28a416262f8e Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 25 May 2026 16:05:21 +0400 Subject: [PATCH 101/195] Use E2E approval config key --- docker-entrypoint-e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-entrypoint-e2e.sh b/docker-entrypoint-e2e.sh index 528a09619..772ac7cec 100755 --- a/docker-entrypoint-e2e.sh +++ b/docker-entrypoint-e2e.sh @@ -19,7 +19,7 @@ config :pleroma, Pleroma.Captcha, config :pleroma, :instance, registrations_open: true, account_activation_required: false, - approval_required: false + account_approval_required: false EOF echo '-- Running migrations...' From e023ed7a5a450d909565cfd5451b4fb4ab7df4fe Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 25 May 2026 16:06:21 +0400 Subject: [PATCH 102/195] Seed E2E admin before API readiness --- docker-entrypoint-e2e.sh | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docker-entrypoint-e2e.sh b/docker-entrypoint-e2e.sh index 772ac7cec..d7aa332f7 100755 --- a/docker-entrypoint-e2e.sh +++ b/docker-entrypoint-e2e.sh @@ -25,6 +25,20 @@ EOF echo '-- Running migrations...' /opt/pleroma/bin/pleroma_ctl migrate +if [ ! -f "$SEED_SENTINEL_PATH" ]; then + if [ -n "${E2E_ADMIN_USERNAME:-}" ] && [ -n "${E2E_ADMIN_PASSWORD:-}" ] && [ -n "${E2E_ADMIN_EMAIL:-}" ]; then + echo '-- Seeding admin user' "$E2E_ADMIN_USERNAME" '...' + if ! PLEROMA_CTL_RPC_DISABLED=true /opt/pleroma/bin/pleroma_ctl user new "$E2E_ADMIN_USERNAME" "$E2E_ADMIN_EMAIL" --admin --password "$E2E_ADMIN_PASSWORD" -y; then + echo '-- User already exists or creation failed, ensuring admin + confirmed...' + PLEROMA_CTL_RPC_DISABLED=true /opt/pleroma/bin/pleroma_ctl user set "$E2E_ADMIN_USERNAME" --admin --confirmed + fi + else + echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)' + fi + + touch "$SEED_SENTINEL_PATH" +fi + echo '-- Starting!' /opt/pleroma/bin/pleroma start & PLEROMA_PID=$! @@ -53,18 +67,4 @@ if [ "$api_ok" != true ]; then exit 1 fi -if [ ! -f "$SEED_SENTINEL_PATH" ]; then - if [ -n "${E2E_ADMIN_USERNAME:-}" ] && [ -n "${E2E_ADMIN_PASSWORD:-}" ] && [ -n "${E2E_ADMIN_EMAIL:-}" ]; then - echo '-- Seeding admin user' "$E2E_ADMIN_USERNAME" '...' - if ! /opt/pleroma/bin/pleroma_ctl user new "$E2E_ADMIN_USERNAME" "$E2E_ADMIN_EMAIL" --admin --password "$E2E_ADMIN_PASSWORD" -y; then - echo '-- User already exists or creation failed, ensuring admin + confirmed...' - /opt/pleroma/bin/pleroma_ctl user set "$E2E_ADMIN_USERNAME" --admin --confirmed - fi - else - echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)' - fi - - touch "$SEED_SENTINEL_PATH" -fi - wait "$PLEROMA_PID" From a84d16831f788d82ec77eceb51debf359cf397d2 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 25 May 2026 18:09:18 +0400 Subject: [PATCH 103/195] Fix account favourites ordering --- changelog.d/account-favourites-order.fix | 1 + lib/pleroma/web/activity_pub/activity_pub.ex | 17 +++- .../controllers/account_controller_test.exs | 84 +++++++++++++++++-- 3 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 changelog.d/account-favourites-order.fix diff --git a/changelog.d/account-favourites-order.fix b/changelog.d/account-favourites-order.fix new file mode 100644 index 000000000..94e27e499 --- /dev/null +++ b/changelog.d/account-favourites-order.fix @@ -0,0 +1 @@ +Fixed `/api/v1/pleroma/accounts/:id/favourites` ordering and pagination to use the favourite date. diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1e8bfe3dd..be7b63725 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -537,9 +537,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do # and extra sorting on "activities.id DESC NULLS LAST" would worse the query plan opts = Map.put(opts, :skip_extra_order, true) - Pagination.fetch_paginated(query, opts, pagination) + Pagination.fetch_paginated(query, opts, pagination, pagination_binding(opts)) end + defp pagination_binding(%{favorited_by: _}), do: :favorited_activity + defp pagination_binding(_), do: nil + def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do list_memberships = Pleroma.List.memberships(opts[:user]) @@ -786,6 +789,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_since(query, %{since_id: ""}), do: query + defp restrict_since(query, %{favorited_by: _, since_id: _}), do: query + defp restrict_since(query, %{since_id: since_id}) do from(activity in query, where: activity.id > ^since_id) end @@ -1013,8 +1018,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_favorited_by(query, %{favorited_by: ap_id}) do from( - [_activity, object] in query, - where: fragment("(?)->'likes' \\? (?)", object.data, ^ap_id) + [activity, object: object] in query, + join: favorited_activity in Activity, + as: :favorited_activity, + on: + favorited_activity.actor == ^ap_id and + fragment("?->>'type' = ?", favorited_activity.data, "Like") and + fragment("associated_object_id(?) = (?)->>'id'", favorited_activity.data, object.data), + select: %Activity{activity | pagination_id: favorited_activity.id} ) end diff --git a/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs index 38a928f30..e93b277cc 100644 --- a/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs @@ -105,6 +105,28 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do assert length(response) == 1 end + test "orders statuses by favorite date", %{ + conn: conn, + user: user + } do + other_user = insert(:user) + + {:ok, first_post} = CommonAPI.post(other_user, %{status: "first post"}) + {:ok, second_post} = CommonAPI.post(other_user, %{status: "second post"}) + {:ok, third_post} = CommonAPI.post(other_user, %{status: "third post"}) + + {:ok, _} = CommonAPI.favorite(third_post.id, user) + {:ok, _} = CommonAPI.favorite(first_post.id, user) + {:ok, _} = CommonAPI.favorite(second_post.id, user) + + response = + conn + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + |> json_response_and_validate_schema(:ok) + + assert Enum.map(response, & &1["id"]) == [second_post.id, first_post.id, third_post.id] + end + test "returns favorited DM only when user is logged in and he is one of recipients", %{ current_user: current_user, user: user @@ -164,23 +186,67 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do } do activities = insert_list(10, :note_activity) - Enum.each(activities, fn activity -> - CommonAPI.favorite(activity.id, user) - end) + favorite_pairs = + Enum.map(activities, fn activity -> + {:ok, favorite} = CommonAPI.favorite(activity.id, user) + {favorite, activity} + end) + |> Enum.sort_by(fn {favorite, _activity} -> FlakeId.from_string(favorite.id) end) - third_activity = Enum.at(activities, 2) - seventh_activity = Enum.at(activities, 6) + {third_favorite, _} = Enum.at(favorite_pairs, 2) + {seventh_favorite, _} = Enum.at(favorite_pairs, 6) + + expected_ids = + favorite_pairs + |> Enum.slice(3, 3) + |> Enum.reverse() + |> Enum.map(fn {_favorite, activity} -> activity.id end) response = conn |> get( - "/api/v1/pleroma/accounts/#{user.id}/favourites?since_id=#{third_activity.id}&max_id=#{seventh_activity.id}" + "/api/v1/pleroma/accounts/#{user.id}/favourites?since_id=#{third_favorite.id}&max_id=#{seventh_favorite.id}" ) |> json_response_and_validate_schema(:ok) - assert length(response) == 3 - refute third_activity in response - refute seventh_activity in response + assert Enum.map(response, & &1["id"]) == expected_ids + end + + test "paginates favorites using min_id and limit", %{ + conn: conn, + user: user + } do + activities = insert_list(10, :note_activity) + + favorite_pairs = + Enum.map(activities, fn activity -> + {:ok, favorite} = CommonAPI.favorite(activity.id, user) + {favorite, activity} + end) + |> Enum.sort_by(fn {favorite, _activity} -> FlakeId.from_string(favorite.id) end) + + {third_favorite, _} = Enum.at(favorite_pairs, 2) + {fourth_favorite, _} = Enum.at(favorite_pairs, 3) + {fifth_favorite, _} = Enum.at(favorite_pairs, 4) + + expected_ids = + favorite_pairs + |> Enum.slice(3, 2) + |> Enum.reverse() + |> Enum.map(fn {_favorite, activity} -> activity.id end) + + conn = + get( + conn, + "/api/v1/pleroma/accounts/#{user.id}/favourites?min_id=#{third_favorite.id}&limit=2" + ) + + response = json_response_and_validate_schema(conn, :ok) + + assert Enum.map(response, & &1["id"]) == expected_ids + assert [link_header] = get_resp_header(conn, "link") + assert link_header =~ "max_id=#{fourth_favorite.id}" + assert link_header =~ "min_id=#{fifth_favorite.id}" end test "limits favorites using limit parameter", %{ From fc9e6793ce3aaf1c9f4b305d2b9692ed59d3c058 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 29 May 2026 12:30:33 +0400 Subject: [PATCH 104/195] Address grouped notification review cleanup --- lib/pleroma/web/mastodon_api/mastodon_api.ex | 23 +++----- .../notification_controller_test.exs | 59 +++++++++++-------- 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index cb11a3f4a..d6f2dd3a3 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -73,7 +73,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do def get_grouped_notification_page(user, params \\ %{}) do grouped_types = params - |> Map.get("grouped_types", Map.get(params, :grouped_types)) + |> Map.get("grouped_types") |> Notification.normalize_grouped_types() query = notifications_query(user, params) @@ -98,13 +98,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do } end - def get_grouped_notification_groups(user, params \\ %{}) do - {groups, _notifications, _notification_group_counts, _notification_group_bounds} = - get_grouped_notification_page(user, params) - - groups - end - def get_notification_group_result(user, group_key, params \\ %{}) do notifications = notification_group_sample(user, params, group_key) metadata = notification_group_metadata(user, group_key, params) @@ -163,21 +156,21 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do defp group_pagination(params) do cond do - min_id = Map.get(params, "min_id", Map.get(params, :min_id)) -> + min_id = Map.get(params, "min_id") -> cursor_filters = [{:gt, min_id}] cursor_filters = - case Map.get(params, "max_id", Map.get(params, :max_id)) do + case Map.get(params, "max_id") do nil -> cursor_filters max_id -> [{:lt, max_id} | cursor_filters] end {:asc, cursor_filters} - since_id = Map.get(params, "since_id", Map.get(params, :since_id)) -> + since_id = Map.get(params, "since_id") -> {:desc, [{:gt, since_id}]} - max_id = Map.get(params, "max_id", Map.get(params, :max_id)) -> + max_id = Map.get(params, "max_id") -> {:desc, [{:lt, max_id}]} true -> @@ -187,7 +180,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do defp grouped_limit(params) do params - |> Map.get("limit", Map.get(params, :limit, 40)) + |> Map.get("limit", 40) |> parse_limit(40) |> min(80) end @@ -195,7 +188,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do def unread_notification_group_count(user, params \\ %{}) do grouped_types = params - |> Map.get("grouped_types", Map.get(params, :grouped_types)) + |> Map.get("grouped_types") |> Notification.normalize_grouped_types() limit = unread_count_limit(params) @@ -406,7 +399,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do defp unread_count_limit(params) do params - |> Map.get("limit", Map.get(params, :limit, 100)) + |> Map.get("limit", 100) |> parse_limit(100) |> min(1000) end diff --git a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs index e9c521d6b..55df41ec6 100644 --- a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs @@ -837,40 +837,28 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do refute link_header =~ "since_id=" end - test "lists accounts from and dismisses a notification group" do - %{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"]) - other_users = insert_list(9, :user) + test "lists accounts from a notification group" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) - {:ok, status} = CommonAPI.post(user, %{status: "hello"}) - - Enum.each(other_users, fn other_user -> - {:ok, _} = CommonAPI.favorite(status.id, other_user) - end) - - %{ - "notification_groups" => [ - %{ - "group_key" => group_key, - "notifications_count" => 9, - "sample_account_ids" => sample_account_ids - } - ] - } = - conn - |> get("/api/v2/notifications") - |> json_response_and_validate_schema(200) + {other_users, %{"group_key" => group_key, "sample_account_ids" => sample_account_ids}} = + create_favourite_notification_group(user, conn) assert length(sample_account_ids) == 8 - %{conn: read_conn} = oauth_access(["read:notifications"], user: user) - account_ids = - read_conn + conn |> get("/api/v2/notifications/#{group_key}/accounts") |> json_response_and_validate_schema(200) |> Enum.map(& &1["id"]) assert Enum.sort(account_ids) == Enum.sort(Enum.map(other_users, & &1.id)) + end + + test "dismisses a notification group" do + %{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"]) + + {_other_users, %{"group_key" => group_key}} = + create_favourite_notification_group(user, conn) assert %{} = conn @@ -1012,6 +1000,29 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do |> to_string() end + defp create_favourite_notification_group(user, conn) do + other_users = insert_list(9, :user) + + {:ok, status} = CommonAPI.post(user, %{status: "hello"}) + + Enum.each(other_users, fn other_user -> + {:ok, _} = CommonAPI.favorite(status.id, other_user) + end) + + %{ + "notification_groups" => [ + %{ + "notifications_count" => 9 + } = group + ] + } = + conn + |> get("/api/v2/notifications") + |> json_response_and_validate_schema(200) + + {other_users, group} + end + defp params_to_query(%{} = params) do Enum.map_join(params, "&", fn {k, v} when is_list(v) -> Enum.map_join(v, "&", &"#{k}[]=#{&1}") From 86dd9663fc3e1162d5747fc140774374dcc65896 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Mon, 1 Jun 2026 22:06:12 +0300 Subject: [PATCH 105/195] allow
(used by mfm) --- priv/scrubbers/default.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index cc5eba027..8e2d16650 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -46,6 +46,7 @@ defmodule Pleroma.HTML.Scrubber.Default do Meta.allow_tag_with_these_attributes(:b, ["lang"]) Meta.allow_tag_with_these_attributes(:blockquote, ["lang"]) Meta.allow_tag_with_these_attributes(:br, ["lang"]) + Meta.allow_tag_with_these_attributes(:center, []) Meta.allow_tag_with_these_attributes(:cite, ["lang"]) Meta.allow_tag_with_these_attributes(:code, ["lang"]) Meta.allow_tag_with_these_attributes(:del, ["lang"]) From 07c65adb0c5ad94610a998730cd81439bf498f8b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 10 Jun 2026 22:26:16 +0200 Subject: [PATCH 106/195] Grouped Notifications: Add feature flag to instance view and nodeinfo --- changelog.d/grouped_notifications_flag.skip | 0 lib/pleroma/web/mastodon_api/views/instance_view.ex | 1 + test/pleroma/web/node_info_test.exs | 1 + 3 files changed, 2 insertions(+) create mode 100644 changelog.d/grouped_notifications_flag.skip diff --git a/changelog.d/grouped_notifications_flag.skip b/changelog.d/grouped_notifications_flag.skip new file mode 100644 index 000000000..e69de29bb diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index d64ce4fb5..4fccdd01e 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -166,6 +166,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do [ "pleroma_api", "mastodon_api", + "mastodon_api_grouped_notifications", "mastodon_api_streaming", "polls", "v2_suggestions", diff --git a/test/pleroma/web/node_info_test.exs b/test/pleroma/web/node_info_test.exs index afe4ebb36..e2bf3400a 100644 --- a/test/pleroma/web/node_info_test.exs +++ b/test/pleroma/web/node_info_test.exs @@ -160,6 +160,7 @@ defmodule Pleroma.Web.NodeInfoTest do default_features = [ "pleroma_api", "mastodon_api", + "mastodon_api_grouped_notifications", "mastodon_api_streaming", "polls", "pleroma_explicit_addressing", From 9782bcf979c12b06b7aefde14ed3708e74ff0657 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 16 Jun 2026 18:22:23 +0200 Subject: [PATCH 107/195] Elixir 1.19: Fix most MRFs being absent from AdminAPI suggestions. Elixir 1.19 started lazily loading modules, so modules only get loaded when referenced. Thus :code.all_loaded would no longer include all MRF policies which is used on startup to generate sugestions for AdminAPI. --- lib/pleroma/docs/generator.ex | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/docs/generator.ex b/lib/pleroma/docs/generator.ex index 93b19484f..70413837a 100644 --- a/lib/pleroma/docs/generator.ex +++ b/lib/pleroma/docs/generator.ex @@ -12,13 +12,27 @@ defmodule Pleroma.Docs.Generator do @spec list_behaviour_implementations(behaviour :: module()) :: [module()] def list_behaviour_implementations(behaviour) do - :code.all_loaded() - |> Enum.filter(fn {module, _} -> + # :code.all_loaded() no longer works on Elixir 1.19 and newer since modules are lazily loaded. + # We can get a complete list of modules by interating over Application.loaded_applications/0, + # but that includes every module which would get loaded below, which is very slow. + # Hardcode the Pleroma application instead, which is good enough for now. + application_modules = + case :application.get_key(:pleroma, :modules) do + {:ok, modules} -> modules + :undefined -> [] + end + + # At compile time or in tests some modules will be outside of the Pleroma application. + loaded_modules = Enum.map(:code.all_loaded(), fn {module, _} -> module end) + + (application_modules ++ loaded_modules) + |> Enum.uniq() + |> Enum.filter(fn module -> + Code.ensure_loaded(module) + # This shouldn't be needed as all modules are expected to have module_info/1, # but in test environments some transient modules `:elixir_compiler_XX` # are loaded for some reason (where XX is a random integer). - Code.ensure_loaded(module) - if function_exported?(module, :module_info, 1) do module.module_info(:attributes) |> Keyword.get_values(:behaviour) @@ -26,7 +40,6 @@ defmodule Pleroma.Docs.Generator do |> Enum.member?(behaviour) end end) - |> Enum.map(fn {module, _} -> module end) end @doc """ From c6d1cead8323ac8ce7ebd13395f84d90fea43518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 23 Jun 2026 16:08:52 +0200 Subject: [PATCH 108/195] Allow attaching emojis to user lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/list-emoji.add | 1 + lib/pleroma/list.ex | 42 ++++++++++++++++++- .../web/api_spec/operations/list_operation.ex | 9 ++-- lib/pleroma/web/api_spec/schemas/list.ex | 17 +++++++- .../web/mastodon_api/views/list_view.ex | 23 +++++++++- .../20260623000000_add_emoji_to_lists.exs | 9 ++++ test/pleroma/list_test.exs | 23 ++++++++++ .../controllers/list_controller_test.exs | 33 +++++++++++++++ .../web/mastodon_api/views/list_view_test.exs | 18 +++++++- 9 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 changelog.d/list-emoji.add create mode 100644 priv/repo/migrations/20260623000000_add_emoji_to_lists.exs diff --git a/changelog.d/list-emoji.add b/changelog.d/list-emoji.add new file mode 100644 index 000000000..dce4874d3 --- /dev/null +++ b/changelog.d/list-emoji.add @@ -0,0 +1 @@ +Allow attaching emojis to user lists diff --git a/lib/pleroma/list.ex b/lib/pleroma/list.ex index cd23bd0a9..3e8a83342 100644 --- a/lib/pleroma/list.ex +++ b/lib/pleroma/list.ex @@ -9,6 +9,7 @@ defmodule Pleroma.List do import Ecto.Changeset alias Pleroma.Activity + alias Pleroma.Emoji alias Pleroma.Repo alias Pleroma.User @@ -18,14 +19,53 @@ defmodule Pleroma.List do field(:following, {:array, :string}, default: []) field(:ap_id, :string) field(:exclusive, :boolean, default: false) + field(:emoji, :string) timestamps() end def update_changeset(list, attrs \\ %{}) do list - |> cast(attrs, [:title, :exclusive]) + |> cast(attrs, [:title, :exclusive, :emoji]) |> validate_required([:title]) + |> fix_emoji() + |> validate_emoji() + end + + defp fix_emoji(changeset) do + with {:emoji_field, emoji} when is_binary(emoji) <- + {:emoji_field, get_field(changeset, :emoji)}, + {:fixed_emoji, emoji} <- + {:fixed_emoji, + emoji + |> Emoji.fully_qualify_emoji() + |> Emoji.maybe_quote()} do + put_change(changeset, :emoji, emoji) + else + {:emoji_field, _} -> changeset + end + end + + defp validate_emoji(changeset) do + validate_change(changeset, :emoji, fn + :emoji, nil -> + [] + + :emoji, emoji -> + if Emoji.unicode?(emoji) or valid_local_custom_emoji?(emoji) do + [] + else + [emoji: "Invalid emoji"] + end + end) + end + + defp valid_local_custom_emoji?(emoji) do + with %{file: _path} <- Emoji.get(emoji) do + true + else + _ -> false + end end def follow_changeset(list, attrs \\ %{}) do diff --git a/lib/pleroma/web/api_spec/operations/list_operation.ex b/lib/pleroma/web/api_spec/operations/list_operation.ex index 87189edc2..8aec1c352 100644 --- a/lib/pleroma/web/api_spec/operations/list_operation.ex +++ b/lib/pleroma/web/api_spec/operations/list_operation.ex @@ -41,7 +41,8 @@ defmodule Pleroma.Web.ApiSpec.ListOperation do responses: %{ 200 => Operation.response("List", "application/json", List), 400 => Operation.response("Error", "application/json", ApiError), - 404 => Operation.response("Error", "application/json", ApiError) + 404 => Operation.response("Error", "application/json", ApiError), + 422 => Operation.response("Error", "application/json", ApiError) } } end @@ -175,7 +176,8 @@ defmodule Pleroma.Web.ApiSpec.ListOperation do exclusive: %Schema{ type: :boolean, description: "Whether members of the list should be removed from the “Home” feed" - } + }, + emoji: %Schema{type: :string, description: "List emoji", nullable: true} }, required: [:title] }, @@ -194,7 +196,8 @@ defmodule Pleroma.Web.ApiSpec.ListOperation do exclusive: %Schema{ type: :boolean, description: "Whether members of the list should be removed from the “Home” feed" - } + }, + emoji: %Schema{type: :string, description: "List emoji", nullable: true} } }, required: true diff --git a/lib/pleroma/web/api_spec/schemas/list.ex b/lib/pleroma/web/api_spec/schemas/list.ex index 5df674894..ff52b1def 100644 --- a/lib/pleroma/web/api_spec/schemas/list.ex +++ b/lib/pleroma/web/api_spec/schemas/list.ex @@ -17,11 +17,26 @@ defmodule Pleroma.Web.ApiSpec.Schemas.List do exclusive: %Schema{ type: :boolean, description: "Whether members of the list should be removed from the “Home” feed" + }, + pleroma: %Schema{ + type: :object, + properties: %{ + emoji: %Schema{type: :string, description: "List emoji", nullable: true}, + emoji_url: %Schema{ + type: :string, + description: "URL of the list emoji if it's a custom emoji, null otherwise", + nullable: true + } + } } }, example: %{ "id" => "12249", - "title" => "Friends" + "title" => "Friends", + "pleroma" => %{ + "emoji" => nil, + "emoji_url" => nil + } } }) end diff --git a/lib/pleroma/web/mastodon_api/views/list_view.ex b/lib/pleroma/web/mastodon_api/views/list_view.ex index 740f458d4..508169fc1 100644 --- a/lib/pleroma/web/mastodon_api/views/list_view.ex +++ b/lib/pleroma/web/mastodon_api/views/list_view.ex @@ -4,6 +4,8 @@ defmodule Pleroma.Web.MastodonAPI.ListView do use Pleroma.Web, :view + + alias Pleroma.Emoji alias Pleroma.Web.MastodonAPI.ListView def render("index.json", %{lists: lists} = opts) do @@ -11,10 +13,29 @@ defmodule Pleroma.Web.MastodonAPI.ListView do end def render("show.json", %{list: list}) do + {emoji, emoji_url} = get_emoji(list.emoji) + %{ id: to_string(list.id), title: list.title, - exclusive: list.exclusive + exclusive: list.exclusive, + pleroma: %{ + emoji: emoji, + emoji_url: emoji_url + } } end + + defp get_emoji(nil), do: {nil, nil} + + defp get_emoji(emoji) do + if Emoji.unicode?(emoji) do + {emoji, nil} + else + case Emoji.get(emoji) do + nil -> {nil, nil} + emoji_data -> {emoji, Emoji.local_url(emoji_data.file)} + end + end + end end diff --git a/priv/repo/migrations/20260623000000_add_emoji_to_lists.exs b/priv/repo/migrations/20260623000000_add_emoji_to_lists.exs new file mode 100644 index 000000000..b72ff0c13 --- /dev/null +++ b/priv/repo/migrations/20260623000000_add_emoji_to_lists.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddEmojiToLists do + use Ecto.Migration + + def change do + alter table(:lists) do + add(:emoji, :string) + end + end +end diff --git a/test/pleroma/list_test.exs b/test/pleroma/list_test.exs index d44310689..32121b8fd 100644 --- a/test/pleroma/list_test.exs +++ b/test/pleroma/list_test.exs @@ -23,6 +23,29 @@ defmodule Pleroma.ListTest do assert changeset.errors == [title: {"can't be blank", [validation: :required]}] end + test "creating a list with an emoji" do + user = insert(:user) + {:ok, %Pleroma.List{} = list} = Pleroma.List.create(%{title: "title", emoji: "🕓"}, user) + %Pleroma.List{emoji: emoji} = Pleroma.List.get(list.id, user) + assert emoji == "🕓" + end + + test "validates emoji" do + user = insert(:user) + + assert {:error, changeset} = + Pleroma.List.create(%{title: "title", emoji: "not an emoji"}, user) + + assert changeset.errors == [emoji: {"Invalid emoji", []}] + end + + test "updating a list's emoji" do + user = insert(:user) + {:ok, list} = Pleroma.List.create(%{title: "title"}, user) + {:ok, %{emoji: emoji}} = Pleroma.List.update(list, %{emoji: "🕓"}) + assert "🕓" == emoji + end + test "getting a list not belonging to the user" do user = insert(:user) other_user = insert(:user) diff --git a/test/pleroma/web/mastodon_api/controllers/list_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/list_controller_test.exs index bfda48be4..2845f67ed 100644 --- a/test/pleroma/web/mastodon_api/controllers/list_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/list_controller_test.exs @@ -19,6 +19,16 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do |> json_response_and_validate_schema(:ok) end + test "creating a list with an emoji" do + %{conn: conn} = oauth_access(["write:lists"]) + + assert %{"title" => "cuties", "pleroma" => %{"emoji" => "🕓", "emoji_url" => nil}} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/lists", %{"title" => "cuties", "emoji" => "🕓"}) + |> json_response_and_validate_schema(:ok) + end + test "renders error for invalid params" do %{conn: conn} = oauth_access(["write:lists"]) @@ -31,6 +41,18 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do json_response_and_validate_schema(conn, 400) end + test "renders error for an invalid emoji" do + %{conn: conn} = oauth_access(["write:lists"]) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/lists", %{"title" => "cuties", "emoji" => "not an emoji"}) + + assert %{"error" => "Invalid emoji"} = + json_response_and_validate_schema(conn, :unprocessable_entity) + end + test "listing a user's lists" do %{conn: conn} = oauth_access(["read:lists", "write:lists"]) @@ -159,6 +181,17 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do |> json_response_and_validate_schema(:ok) end + test "updating a list's emoji" do + %{user: user, conn: conn} = oauth_access(["write:lists"]) + {:ok, list} = Pleroma.List.create(%{title: "name"}, user) + + assert %{"pleroma" => %{"emoji" => "🕓", "emoji_url" => nil}} = + conn + |> put_req_header("content-type", "application/json") + |> put("/api/v1/lists/#{list.id}", %{"title" => "name", "emoji" => "🕓"}) + |> json_response_and_validate_schema(:ok) + end + test "validates title when renaming a list" do %{user: user, conn: conn} = oauth_access(["write:lists"]) {:ok, list} = Pleroma.List.create(%{title: "name"}, user) diff --git a/test/pleroma/web/mastodon_api/views/list_view_test.exs b/test/pleroma/web/mastodon_api/views/list_view_test.exs index ae0593b6b..86f461ded 100644 --- a/test/pleroma/web/mastodon_api/views/list_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/list_view_test.exs @@ -15,12 +15,28 @@ defmodule Pleroma.Web.MastodonAPI.ListViewTest do expected = %{ id: to_string(list.id), title: title, - exclusive: false + exclusive: false, + pleroma: %{ + emoji: nil, + emoji_url: nil + } } assert expected == ListView.render("show.json", %{list: list}) end + test "show with a unicode emoji" do + user = insert(:user) + {:ok, list} = Pleroma.List.create(%{title: "mortal enemies", emoji: "🕓"}, user) + + assert %{ + pleroma: %{ + emoji: "🕓", + emoji_url: nil + } + } = ListView.render("show.json", %{list: list}) + end + test "index" do user = insert(:user) From 19a84baf82da5b8e8204bff9e305eeac5bdb4189 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 10 Jun 2026 22:54:22 +0200 Subject: [PATCH 109/195] InstanceView: Add feature flag tests --- .../mastodon_api/views/instance_view_test.exs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 test/pleroma/web/mastodon_api/views/instance_view_test.exs diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs new file mode 100644 index 000000000..46bb80ba4 --- /dev/null +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -0,0 +1,98 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Language.LanguageDetectorMock + alias Pleroma.StaticStubbedConfigMock + alias Pleroma.Web.MastodonAPI.InstanceView + + import Mox + + @features [ + "pleroma_api", + "mastodon_api", + "mastodon_api_grouped_notifications", + "mastodon_api_streaming", + "polls", + "v2_suggestions", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + "editing", + "quote_posting", + "pleroma_emoji_reactions", + "pleroma_custom_emoji_reactions", + "pleroma_chat_messages", + "pleroma:pin_chats", + "pleroma:get:main/ostatus", + "pleroma:group_actors", + "pleroma:bookmark_folders", + "pleroma:block_expiration" + ] + + @configureable_features [ + "blockers_visible", + "media_proxy", + "gopher", + "chat", + "shout", + "relay", + "safe_dm_mentions", + "exposable_reactions", + "profile_directory", + "pleroma:language_detection" + ] + @configurable_features_flags [ + [:activitypub, :blockers_visible], + [:media_proxy, :enabled], + [:gopher, :enabled], + [:shout, :enabled], + [:instance, :allow_relay], + [:instance, :safe_dm_mentions], + [:instance, :show_reactions], + [:instance, :profile_directory] + ] + + # When this fails, a new feature flag was probably added. Add it to the macros above. + describe "uncofigurable features" do + setup do + Enum.each(@configurable_features_flags, &clear_config(&1, false)) + end + + test "returns always enabled features" do + features = @features + + assert Enum.sort(InstanceView.features()) == Enum.sort(features) + end + end + + describe "all features" do + setup do + Enum.each(@configurable_features_flags, &clear_config(&1, true)) + + # Stub the StaticStubbedConfigMock to return our mock for the provider + StaticStubbedConfigMock + |> stub(:get, fn + [Pleroma.Language.LanguageDetector, :provider] -> LanguageDetectorMock + _other -> nil + end) + + # Stub the LanguageDetectorMock with default implementations + LanguageDetectorMock + |> stub(:missing_dependencies, fn -> [] end) + |> stub(:configured?, fn -> true end) + + :ok + end + + test "returns all features including configurable ones" do + features = @features ++ @configureable_features + + assert Enum.sort(InstanceView.features()) == Enum.sort(features) + end + end +end From 7e9b8a766340248578a675df772410b1960ee22c Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 23 Jun 2026 18:20:16 +0200 Subject: [PATCH 110/195] InstanceView: Add federation/0 func test --- .../mastodon_api/views/instance_view_test.exs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index 46bb80ba4..d34aad25b 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -95,4 +95,72 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do assert Enum.sort(InstanceView.features()) == Enum.sort(features) end end + + describe "federation/0" do + setup do: clear_config([:mrf, :transparency], true) + + test "quarantined instances" do + clear_config([:instance, :quarantined_instances], [{"quarantine.example.com", "crimes"}]) + + output = InstanceView.federation() + assert %{quarantined_instances: ["quarantine.example.com"]} = output + assert %{quarantined_instances_info: %{"quarantined_instances" => %{"quarantine.example.com" => %{"reason" => "crimes"}}}} = output + end + + test "rejected instances" do + clear_config([:instance, :rejected_instances], [{"rejected.example.com", "not enough #cofe posting"}]) + + output = InstanceView.federation() + assert %{rejected_instances: %{"rejected.example.com" => %{"reason" => "not enough #cofe posting"}}} = output + end + + test "federating" do + clear_config([:instance, :federating], true) + + output = InstanceView.federation() + assert %{enabled: true} = output + end + + test "transparency" do + clear_config([:instance, :quarantined_instances], [{"quarantine.example.com", "crimes"}]) + clear_config([:instance, :rejected_instances], [{"rejected.example.com", "not enough #cofe posting"}]) + clear_config([:instance, :federating], true) + clear_config([:mrf, :transparency], false) + + output = InstanceView.federation() + assert %{enabled: true} == output + end + + test "MRFs" do + clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + clear_config([:mrf, :transparency_exclusions], [{"media1.example.com", "the Fediverse doesn't need to know"}]) + clear_config([:mrf_simple, :media_removal], [{"media1.example.com", "NSFW"}, {"media2.example.com", "usual suspects"}]) + clear_config([:mrf_simple, :reject], [{"rejected.example.com", "not enough #cofe posting"}]) + + output = InstanceView.federation() + + expected = %{ + mrf_simple: %{ + reject: ["rejected.example.com"], + media_removal: ["media2.example.com"], + }, + mrf_hashtag: %{ + sensitive: ["nsfw"], + }, + exclusions: true, + mrf_policies: ["SimplePolicy", "HashtagPolicy"], + mrf_simple_info: %{ + reject: %{ + "rejected.example.com" => %{"reason" => "not enough #cofe posting"} + }, + media_removal: %{"media2.example.com" => %{"reason" => "usual suspects"}} + } + } + + assert expected.mrf_simple.reject == output.mrf_simple.reject + assert expected.mrf_simple.media_removal == output.mrf_simple.media_removal + assert expected.mrf_simple_info == output.mrf_simple_info + assert expected.exclusions == output.exclusions + end + end end From 86725b5a000a6992cad522be06d0aa99dd829bcb Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 23 Jun 2026 22:22:49 +0200 Subject: [PATCH 111/195] InstanceView: add rule(s), translation render tests --- .../mastodon_api/views/instance_view_test.exs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index d34aad25b..d3e850766 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -163,4 +163,49 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do assert expected.exclusions == output.exclusions end end + + describe "render rule.json" do + setup do + rule = Pleroma.Rule.create(%{text: "hambaga"}) + + %{rule: rule} + end + + test "renders properly", %{rule: rule} do + output = InstanceView.render("rule.json", %{rule: rule}) + + assert %{text: "hambaga"} = output + end + end + + describe "render rules.json" do + setup do + Pleroma.Rule.create(%{text: "rule 1"}) + Pleroma.Rule.create(%{text: "rule 2"}) + Pleroma.Rule.create(%{text: "rule 3"}) + Pleroma.Rule.create(%{text: "rule 4"}) + + :ok + end + + test "renders properly" do + output = InstanceView.render("rules.json", %{}) + keys = Enum.map(output, fn %{text: text} -> text end) + + assert length(keys) == 4 + assert Enum.all?(output, fn %{text: "rule " <> num} -> String.to_integer(num) end) + end + end + + describe "render translation_languages.json" do + setup do + clear_config([Pleroma.Language.Translation, :provider], TranslationMock) + end + + test "returns language matrix" do + output = InstanceView.render("translation_languages.json", %{}) + + assert %{"en" => ["pl"], "pl" => ["en"]} == output + end + end end From df67f219748418b18063d9e788a1945d9dbf14a8 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 23 Jun 2026 22:24:44 +0200 Subject: [PATCH 112/195] InstanceView: use shorter setup syntax when applicable --- .../pleroma/web/mastodon_api/views/instance_view_test.exs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index d3e850766..f1852cadd 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -59,9 +59,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do # When this fails, a new feature flag was probably added. Add it to the macros above. describe "uncofigurable features" do - setup do - Enum.each(@configurable_features_flags, &clear_config(&1, false)) - end + setup do: Enum.each(@configurable_features_flags, &clear_config(&1, false)) test "returns always enabled features" do features = @features @@ -198,9 +196,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do end describe "render translation_languages.json" do - setup do - clear_config([Pleroma.Language.Translation, :provider], TranslationMock) - end + setup do: clear_config([Pleroma.Language.Translation, :provider], TranslationMock) test "returns language matrix" do output = InstanceView.render("translation_languages.json", %{}) From e45dad21046435a3f5e93412a183955c34df16fb Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 23 Jun 2026 22:42:15 +0200 Subject: [PATCH 113/195] InstanceView: Add domain_blocks.json render test --- .../mastodon_api/views/instance_view_test.exs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index f1852cadd..9701fab85 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -195,6 +195,46 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do end end + describe "render domain_blocks.json" do + setup do + clear_config([:mrf, :transparency], true) + clear_config([:mrf, :transparency_exclusions], [{"removed1.example.com", "the Fediverse doesn't need to know"}]) + clear_config([:mrf_simple, :media_removal], [{"media1.example.com", "NSFW"}]) + clear_config([:mrf_simple, :reject], [{"rejected.example.com", "not enough #cofe posting"}, {"rejected-without-reason.example.com", ""}]) + clear_config([:mrf_simple, :federated_timeline_removal], [{"removed1.example.com", "spam"}, {"removed2.example.com", "more spam"}]) + + :ok + end + + test "renders properly" do + output = InstanceView.render("domain_blocks.json", %{}) + + # Media removal is not in @block_severities in the view. + # NOTE: Order of these two matters! + expected = [ + %{ + domain: "removed2.example.com", + comment: "more spam", + severity: "silence", + digest: "5f6649cfc780fac5172dbbcc8a552953f6621a14368d82728b6e8faf51295dc0" + }, + %{ + domain: "rejected.example.com", + comment: "not enough #cofe posting", + severity: "suspend", + digest: "c15082d347fb7eccd251e199a98dcdce94a8dfc1d33d6ece80889eed374b6f92" + }, + %{ + domain: "rejected-without-reason.example.com", + severity: "suspend", + digest: "48297155018c626c8867ca0bba4a2621eccdf51c6f7ac91b7099ed8b2ee9863f" + } + ] + + assert expected == output + end + end + describe "render translation_languages.json" do setup do: clear_config([Pleroma.Language.Translation, :provider], TranslationMock) From c290f022de1b262d8e4d84be84ad7095a89043b5 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 24 Jun 2026 00:28:44 +0200 Subject: [PATCH 114/195] InstanceView: Add show.json render test --- .../web/mastodon_api/views/instance_view.ex | 2 + .../mastodon_api/views/instance_view_test.exs | 194 ++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 4fccdd01e..1a0f63ffa 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -17,6 +17,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do reject: "suspend" } + def mastodon_api_level, do: @mastodon_api_level + def render("show.json", _) do instance = Config.get(:instance) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index 9701fab85..3b24e8e5f 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -5,12 +5,63 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do use Pleroma.DataCase, async: true + import Pleroma.Factory + alias Pleroma.Language.LanguageDetectorMock alias Pleroma.StaticStubbedConfigMock alias Pleroma.Web.MastodonAPI.InstanceView import Mox + @atom_common_information_keys [ + :languages, + :rules, + :title, + :version + ] + + @common_information_keys Enum.map(@atom_common_information_keys, &to_string/1) + + @atom_configuration_keys [ + :accounts, + :statuses, + :media_attachments, + :polls + ] + + @atom_pleroma_configuration_keys [ + :metadata, + :stats, + :vapid_public_key + ] + + @show_keys @common_information_keys ++ [ + "uri", + "description", + "short_description", + "email", + "urls", + "stats", + "thumbnail", + "registrations", + "approval_required", + "contact_account", + "configuration", + # Extra (not present in Mastodon)" + "max_toot_chars", + "max_media_attachments", + "poll_limits", + "upload_limit", + "avatar_upload_limit", + "background_upload_limit", + "banner_upload_limit", + "background_image", + "shout_limit", + "description_limit", + "chat_limit", + "pleroma" + ] + @features [ "pleroma_api", "mastodon_api", @@ -57,6 +108,96 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do [:instance, :profile_directory] ] + defp check_common_information(info) do + filtered_info = Map.reject(info, fn {key, _value} -> key not in @atom_common_information_keys end) + + expected = %{ + languages: ["en", "cs"], + rules: InstanceView.render("rules.json", %{}), + title: "test title", + version: InstanceView.mastodon_api_level() <> " (compatible; #{Pleroma.Application.named_version()})" + } + + Map.equal?(expected, filtered_info) + end + + defp check_contact_name(info, user) do + contact = info[:contact_account] + view = Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user, for: nil}) + + contact == view + end + + defp check_configuration(info) do + configuration = info[:configuration] + filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @atom_configuration_keys end) + + expected = %{ + accounts: %{ + max_featured_tags: 0 + }, + statuses: %{ + max_characters: 4096, + max_media_attachments: 1 + }, + media_attachments: %{ + image_size_limit: 1024, + video_size_limit: 1024, + supported_mime_types: ["application/octet-stream"] + }, + polls: %{ + max_options: 4, + max_characters_per_option: 120, + min_expiration: 1, + max_expiration: 2 + } + } + + Map.equal?(expected, filtered_configuration) + end + + defp check_pleroma_configuration(info) do + configuration = info[:pleroma] + filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @atom_pleroma_configuration_keys end) + metadata = Map.fetch!(filtered_configuration, :metadata) + # Tested elsewhere already + filtered_metadata = Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) + filtered_configuration = %{filtered_configuration | metadata: filtered_metadata} + + expected = %{ + metadata: %{ + account_activation_required: true, + fields_limits: %{ + max_fields: 1, + max_remote_fields: 1, + name_length: 1, + value_length: 1 + }, + post_formats: ["text/plain"], + birthday_required: true, + birthday_min_age: 1, + translation: + %{ + source_languages: ["en", "pl"], + target_languages: ["en", "pl"] + }, + base_urls: %{ + media_proxy: "https://mediaproxy.example.com", + upload: "https://upload.example.com" + }, + markup: %{ + allow_inline_images: true, + allow_headings: true, + allow_tables: true + } + }, + stats: %{mau: Pleroma.User.active_user_count()}, + vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) + } + + Map.equal?(expected, filtered_configuration) + end + # When this fails, a new feature flag was probably added. Add it to the macros above. describe "uncofigurable features" do setup do: Enum.each(@configurable_features_flags, &clear_config(&1, false)) @@ -162,6 +303,59 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do end end + describe "render show.json" do + setup do + Pleroma.Rule.create(%{text: "rule 1"}) + Pleroma.Rule.create(%{text: "rule 2"}) + + user = insert(:user) + + # Wall of configs for testing whether they are rendered properly + clear_config([Pleroma.Language.Translation, :provider], TranslationMock) + + clear_config([:instance, :languages], ["en", "cs"]) + clear_config([:instance, :name], "test title") + clear_config([:instance, :contact_username], user.nickname) + clear_config([:instance, :limit], 4096) + clear_config([:instance, :max_media_attachments], 1) + clear_config([:instance, :upload_limit], 1024) + clear_config([:instance, :poll_limits, :max_options], 4) + clear_config([:instance, :poll_limits, :max_option_chars], 120) + clear_config([:instance, :poll_limits, :min_expiration], 1) + clear_config([:instance, :poll_limits, :max_expiration], 2) + clear_config([:instance, :account_activation_required], true) + clear_config([:instance, :max_account_fields], 1) + clear_config([:instance, :max_remote_account_fields], 1) + clear_config([:instance, :account_field_name_length], 1) + clear_config([:instance, :account_field_value_length], 1) + clear_config([:instance, :allowed_post_formats], ["text/plain"]) + clear_config([:instance, :birthday_required], true) + clear_config([:instance, :birthday_min_age], 1) + + clear_config([:media_proxy, :enabled], true) + clear_config([:media_proxy, :base_url], "https://mediaproxy.example.com") + + clear_config([Pleroma.Upload, :base_url], "https://upload.example.com") + + clear_config([:markup, :allow_inline_images], true) + clear_config([:markup, :allow_headings], true) + clear_config([:markup, :allow_tables], true) + + %{user: user} + end + + test "renders properly", %{user: user} do + output = InstanceView.render("show.json", %{}) + expected_keys = Enum.sort(@show_keys) + + assert check_common_information(output) + assert check_contact_name(output, user) + assert check_configuration(output) + assert check_pleroma_configuration(output) + assert expected_keys == Enum.sort(Enum.map(Map.keys(output), &to_string/1)) + end + end + describe "render rule.json" do setup do rule = Pleroma.Rule.create(%{text: "hambaga"}) From 0337bd0ae4ff1adf26dda28af5405a092c4782e1 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 25 Jun 2026 00:19:15 +0200 Subject: [PATCH 115/195] InstanceView: Add show2.json render test --- .../mastodon_api/views/instance_view_test.exs | 194 +++++++++++++++++- 1 file changed, 192 insertions(+), 2 deletions(-) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index 3b24e8e5f..b4622191b 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -29,12 +29,20 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do :polls ] + @atom_configuration2_keys @atom_configuration_keys ++ [ + :urls, + :vapid, + :translation, + :timelines_access + ] + @atom_pleroma_configuration_keys [ :metadata, :stats, :vapid_public_key ] + # TODO: Simplify this, so ideally only atoms are macroed @show_keys @common_information_keys ++ [ "uri", "description", @@ -62,6 +70,19 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do "pleroma" ] + @show2_keys @common_information_keys ++ [ + "domain", + "source_url", + "description", + "usage", + "thumbnail", + "configuration", + "registrations", + "contact", + # Extra (not present in Mastodon): + "pleroma" + ] + @features [ "pleroma_api", "mastodon_api", @@ -121,6 +142,27 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do Map.equal?(expected, filtered_info) end + defp check_thumbnail(info) do + thumbnail = info[:thumbnail] + + expected = %{ + url: URI.merge(Pleroma.Web.Endpoint.url(), "https://example.com/thumbnail.png") |> to_string + } + + expected == thumbnail + end + + defp check_contact(info, user) do + contact = info[:contact] + + expected = %{ + email: "nonexist@example.com", + account: Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user, for: nil}) + } + + Map.equal?(expected, contact) + end + defp check_contact_name(info, user) do contact = info[:contact_account] view = Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user, for: nil}) @@ -156,6 +198,70 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do Map.equal?(expected, filtered_configuration) end + defp check_configuration2(info) do + configuration = info[:configuration] + filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @atom_configuration2_keys end) + + # configuration2 also includes parts from configuration + expected = %{ + accounts: %{ + max_featured_tags: 0, + max_pinned_statuses: 1, + max_profile_fields: 1, + profile_field_name_limit: 1, + profile_field_value_limit: 1 + }, + statuses: %{ + max_characters: 4096, + max_media_attachments: 1, + characters_reserved_per_url: 0 + }, + media_attachments: %{ + image_size_limit: 1024, + video_size_limit: 1024, + supported_mime_types: ["application/octet-stream"] + }, + polls: %{ + max_options: 4, + max_characters_per_option: 120, + min_expiration: 1, + max_expiration: 2 + }, + urls: %{ + streaming: Pleroma.Web.Endpoint.websocket_url(), + status: "https://status.example.com" + }, + vapid: %{ + public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) + }, + translation: %{enabled: true}, + timelines_access: %{ + live_feeds: %{local: "authenticated", remote: "authenticated"}, + hashtag_feeds: %{local: "authenticated", remote: "authenticated"}, + # not implemented in Pleroma + trending_link_feeds: %{ + local: "disabled", + remote: "disabled" + } + } + } + + Map.equal?(expected, filtered_configuration) + end + + defp check_registrations(info) do + registrations = info[:registrations] + + expected = %{ + enabled: false, + approval_required: true, + message: nil, + url: nil + } + + Map.equal?(expected, registrations) + end + defp check_pleroma_configuration(info) do configuration = info[:pleroma] filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @atom_pleroma_configuration_keys end) @@ -198,6 +304,56 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do Map.equal?(expected, filtered_configuration) end + defp check_pleroma_configuration2(info) do + configuration = info[:pleroma] + filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @atom_pleroma_configuration_keys end) + metadata = Map.fetch!(filtered_configuration, :metadata) + # Tested elsewhere already + filtered_metadata = Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) + filtered_configuration = %{filtered_configuration | metadata: filtered_metadata} + + # pleroma_configuration2 also includes parts from pleroma_configuration + expected = %{ + metadata: %{ + account_activation_required: true, + fields_limits: %{ + max_fields: 1, + max_remote_fields: 1, + name_length: 1, + value_length: 1 + }, + post_formats: ["text/plain"], + birthday_required: true, + birthday_min_age: 1, + translation: + %{ + source_languages: ["en", "pl"], + target_languages: ["en", "pl"] + }, + base_urls: %{ + media_proxy: "https://mediaproxy.example.com", + upload: "https://upload.example.com" + }, + markup: %{ + allow_inline_images: true, + allow_headings: true, + allow_tables: true + }, + avatar_upload_limit: 1024, + background_upload_limit: 1024, + banner_upload_limit: 1024, + background_image: Pleroma.Web.Endpoint.url() <> "/media/background.png", + chat_limit: 120, + description_limit: 120, + shout_limit: 120 + }, + stats: %{mau: Pleroma.User.active_user_count()}, + vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) + } + + Map.equal?(expected, filtered_configuration) + end + # When this fails, a new feature flag was probably added. Add it to the macros above. describe "uncofigurable features" do setup do: Enum.each(@configurable_features_flags, &clear_config(&1, false)) @@ -303,7 +459,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do end end - describe "render show.json" do + describe "render show.json/show2.json" do setup do Pleroma.Rule.create(%{text: "rule 1"}) Pleroma.Rule.create(%{text: "rule 2"}) @@ -332,6 +488,23 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do clear_config([:instance, :birthday_required], true) clear_config([:instance, :birthday_min_age], 1) + clear_config([:instance, :short_description], "Corndog Emporium!") + clear_config([:instance, :instance_thumbnail], "https://example.com/thumbnail.png") + clear_config([:instance, :registrations_open], false) + clear_config([:instance, :account_approval_required], true) + + clear_config([:instance, :max_pinned_statuses], 1) + clear_config([:instance, :status_page], "https://status.example.com") + + clear_config([:instance, :email], "nonexist@example.com") + + clear_config([:instance, :avatar_upload_limit], 1024) + clear_config([:instance, :background_upload_limit], 1024) + clear_config([:instance, :banner_upload_limit], 1024) + clear_config([:instance, :background_image], "/media/background.png") + clear_config([:instance, :chat_limit], 120) + clear_config([:instance, :description_limit], 120) + clear_config([:media_proxy, :enabled], true) clear_config([:media_proxy, :base_url], "https://mediaproxy.example.com") @@ -341,10 +514,14 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do clear_config([:markup, :allow_headings], true) clear_config([:markup, :allow_tables], true) + clear_config([:restrict_unauthenticated, :timelines], %{local: true, federated: true}) + + clear_config([:shout, :limit], 120) + %{user: user} end - test "renders properly", %{user: user} do + test "renders show.json properly", %{user: user} do output = InstanceView.render("show.json", %{}) expected_keys = Enum.sort(@show_keys) @@ -354,6 +531,19 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do assert check_pleroma_configuration(output) assert expected_keys == Enum.sort(Enum.map(Map.keys(output), &to_string/1)) end + + test "renders show2.json properly", %{user: user} do + output = InstanceView.render("show2.json", %{}) + expected_keys = Enum.sort(@show2_keys) + + assert check_common_information(output) + assert check_thumbnail(output) + assert check_configuration2(output) + assert check_registrations(output) + assert check_contact(output, user) + assert check_pleroma_configuration2(output) + assert expected_keys == Enum.sort(Enum.map(Map.keys(output), &to_string/1)) + end end describe "render rule.json" do From 6393fe0a9e62051393c44acc1d66bd196616ea54 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 25 Jun 2026 23:21:09 +0200 Subject: [PATCH 116/195] InstanceView: removing unneeded doubling of attributes in tests --- .../mastodon_api/views/instance_view_test.exs | 97 +++++++++---------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index b4622191b..93b5c60c9 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -13,74 +13,71 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do import Mox - @atom_common_information_keys [ + @common_information_keys [ :languages, :rules, :title, :version ] - @common_information_keys Enum.map(@atom_common_information_keys, &to_string/1) - - @atom_configuration_keys [ + @configuration_keys [ :accounts, :statuses, :media_attachments, :polls ] - @atom_configuration2_keys @atom_configuration_keys ++ [ + @configuration2_keys @configuration_keys ++ [ :urls, :vapid, :translation, :timelines_access ] - @atom_pleroma_configuration_keys [ + @pleroma_configuration_keys [ :metadata, :stats, :vapid_public_key ] - # TODO: Simplify this, so ideally only atoms are macroed @show_keys @common_information_keys ++ [ - "uri", - "description", - "short_description", - "email", - "urls", - "stats", - "thumbnail", - "registrations", - "approval_required", - "contact_account", - "configuration", - # Extra (not present in Mastodon)" - "max_toot_chars", - "max_media_attachments", - "poll_limits", - "upload_limit", - "avatar_upload_limit", - "background_upload_limit", - "banner_upload_limit", - "background_image", - "shout_limit", - "description_limit", - "chat_limit", - "pleroma" + :uri, + :description, + :short_description, + :email, + :urls, + :stats, + :thumbnail, + :registrations, + :approval_required, + :contact_account, + :configuration, + # Extra (not present in Mastodon) + :max_toot_chars, + :max_media_attachments, + :poll_limits, + :upload_limit, + :avatar_upload_limit, + :background_upload_limit, + :banner_upload_limit, + :background_image, + :shout_limit, + :description_limit, + :chat_limit, + :pleroma ] @show2_keys @common_information_keys ++ [ - "domain", - "source_url", - "description", - "usage", - "thumbnail", - "configuration", - "registrations", - "contact", - # Extra (not present in Mastodon): - "pleroma" + :domain, + :source_url, + :description, + :usage, + :thumbnail, + :configuration, + :registrations, + :contact, + # Extra (not present in Mastodon) + :pleroma ] @features [ @@ -106,7 +103,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do "pleroma:block_expiration" ] - @configureable_features [ + @configurable_features [ "blockers_visible", "media_proxy", "gopher", @@ -130,7 +127,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do ] defp check_common_information(info) do - filtered_info = Map.reject(info, fn {key, _value} -> key not in @atom_common_information_keys end) + filtered_info = Map.reject(info, fn {key, _value} -> key not in @common_information_keys end) expected = %{ languages: ["en", "cs"], @@ -172,7 +169,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_configuration(info) do configuration = info[:configuration] - filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @atom_configuration_keys end) + filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @configuration_keys end) expected = %{ accounts: %{ @@ -200,7 +197,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_configuration2(info) do configuration = info[:configuration] - filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @atom_configuration2_keys end) + filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @configuration2_keys end) # configuration2 also includes parts from configuration expected = %{ @@ -264,7 +261,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_pleroma_configuration(info) do configuration = info[:pleroma] - filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @atom_pleroma_configuration_keys end) + filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @pleroma_configuration_keys end) metadata = Map.fetch!(filtered_configuration, :metadata) # Tested elsewhere already filtered_metadata = Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) @@ -306,7 +303,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_pleroma_configuration2(info) do configuration = info[:pleroma] - filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @atom_pleroma_configuration_keys end) + filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @pleroma_configuration_keys end) metadata = Map.fetch!(filtered_configuration, :metadata) # Tested elsewhere already filtered_metadata = Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) @@ -385,7 +382,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do end test "returns all features including configurable ones" do - features = @features ++ @configureable_features + features = @features ++ @configurable_features assert Enum.sort(InstanceView.features()) == Enum.sort(features) end @@ -529,7 +526,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do assert check_contact_name(output, user) assert check_configuration(output) assert check_pleroma_configuration(output) - assert expected_keys == Enum.sort(Enum.map(Map.keys(output), &to_string/1)) + assert expected_keys == Enum.sort(Map.keys(output)) end test "renders show2.json properly", %{user: user} do @@ -542,7 +539,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do assert check_registrations(output) assert check_contact(output, user) assert check_pleroma_configuration2(output) - assert expected_keys == Enum.sort(Enum.map(Map.keys(output), &to_string/1)) + assert expected_keys == Enum.sort(Map.keys(output)) end end From 93fe0f5ab5ed258d6b6e3b42e007ca37336218fb Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 26 Jun 2026 01:41:32 +0200 Subject: [PATCH 117/195] InstanceView: Filter out *configuration keys from *configuration2 --- .../mastodon_api/views/instance_view_test.exs | 88 ++++++++----------- 1 file changed, 39 insertions(+), 49 deletions(-) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index 93b5c60c9..e6c8185c1 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -34,12 +34,26 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do :timelines_access ] + # Used for filtering out values from configuration.json render in configuration2.json render + @configuration2_filter [ + {:accounts, [:max_pinned_statuses, :max_profile_fields, :profile_field_name_limit, :profile_field_value_limit]}, + {:statuses, [:characters_reserved_per_url]}, + {:urls, [:streaming, :status]}, + {:vapid, [:public_key]}, + {:translation, [:enabled]}, + {:timelines_access, [:live_feeds, :hashtag_feeds, :trending_link_feeds]} + ] + @pleroma_configuration_keys [ :metadata, :stats, :vapid_public_key ] + @pleroma_configuration2_filter [ + {:metadata, [:avatar_upload_limit, :background_upload_limit, :banner_upload_limit, :background_image, :chat_limit, :description_limit, :shout_limit]} + ] + @show_keys @common_information_keys ++ [ :uri, :description, @@ -126,6 +140,17 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do [:instance, :profile_directory] ] + # Filters out values from one layer deep nested Maps. + defp filter_render(%{}, [], acc), do: acc + + defp filter_render(input, filter, acc) do + {{filter_key, filters}, filter_rest} = List.pop_at(filter, 0) + {config_key_values, input_rest} = Map.pop!(input, filter_key) + filtered = Map.reject(config_key_values, fn {key, _} -> key not in filters end) + + filter_render(input_rest, filter_rest, Map.put(acc, filter_key, filtered)) + end + defp check_common_information(info) do filtered_info = Map.reject(info, fn {key, _value} -> key not in @common_information_keys end) @@ -197,33 +222,21 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_configuration2(info) do configuration = info[:configuration] - filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @configuration2_keys end) + filtered_configuration = + configuration + |> Map.reject(fn {key, _value} -> key not in @configuration2_keys end) + |> filter_render(@configuration2_filter, %{}) - # configuration2 also includes parts from configuration expected = %{ accounts: %{ - max_featured_tags: 0, max_pinned_statuses: 1, max_profile_fields: 1, profile_field_name_limit: 1, profile_field_value_limit: 1 }, statuses: %{ - max_characters: 4096, - max_media_attachments: 1, characters_reserved_per_url: 0 }, - media_attachments: %{ - image_size_limit: 1024, - video_size_limit: 1024, - supported_mime_types: ["application/octet-stream"] - }, - polls: %{ - max_options: 4, - max_characters_per_option: 120, - min_expiration: 1, - max_expiration: 2 - }, urls: %{ streaming: Pleroma.Web.Endpoint.websocket_url(), status: "https://status.example.com" @@ -262,8 +275,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_pleroma_configuration(info) do configuration = info[:pleroma] filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @pleroma_configuration_keys end) - metadata = Map.fetch!(filtered_configuration, :metadata) + # Tested elsewhere already + metadata = Map.fetch!(filtered_configuration, :metadata) filtered_metadata = Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) filtered_configuration = %{filtered_configuration | metadata: filtered_metadata} @@ -304,38 +318,16 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_pleroma_configuration2(info) do configuration = info[:pleroma] filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @pleroma_configuration_keys end) - metadata = Map.fetch!(filtered_configuration, :metadata) - # Tested elsewhere already - filtered_metadata = Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) - filtered_configuration = %{filtered_configuration | metadata: filtered_metadata} - # pleroma_configuration2 also includes parts from pleroma_configuration + # Tested elsewhere already + metadata = Map.fetch!(filtered_configuration, :metadata) + filtered_metadata = Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) + filtered_configuration = + %{filtered_configuration | metadata: filtered_metadata} + |> filter_render(@pleroma_configuration2_filter, %{}) + expected = %{ metadata: %{ - account_activation_required: true, - fields_limits: %{ - max_fields: 1, - max_remote_fields: 1, - name_length: 1, - value_length: 1 - }, - post_formats: ["text/plain"], - birthday_required: true, - birthday_min_age: 1, - translation: - %{ - source_languages: ["en", "pl"], - target_languages: ["en", "pl"] - }, - base_urls: %{ - media_proxy: "https://mediaproxy.example.com", - upload: "https://upload.example.com" - }, - markup: %{ - allow_inline_images: true, - allow_headings: true, - allow_tables: true - }, avatar_upload_limit: 1024, background_upload_limit: 1024, banner_upload_limit: 1024, @@ -343,9 +335,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do chat_limit: 120, description_limit: 120, shout_limit: 120 - }, - stats: %{mau: Pleroma.User.active_user_count()}, - vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) + } } Map.equal?(expected, filtered_configuration) From 9912b2d16af01d322a1e3a4eb78efbd6f42efdf5 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 26 Jun 2026 01:57:32 +0200 Subject: [PATCH 118/195] InstanceView: lint tests --- .../mastodon_api/views/instance_view_test.exs | 207 ++++++++++++------ 1 file changed, 136 insertions(+), 71 deletions(-) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index e6c8185c1..cae704b90 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -27,16 +27,23 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do :polls ] - @configuration2_keys @configuration_keys ++ [ - :urls, - :vapid, - :translation, - :timelines_access - ] + @configuration2_keys @configuration_keys ++ + [ + :urls, + :vapid, + :translation, + :timelines_access + ] # Used for filtering out values from configuration.json render in configuration2.json render @configuration2_filter [ - {:accounts, [:max_pinned_statuses, :max_profile_fields, :profile_field_name_limit, :profile_field_value_limit]}, + {:accounts, + [ + :max_pinned_statuses, + :max_profile_fields, + :profile_field_name_limit, + :profile_field_value_limit + ]}, {:statuses, [:characters_reserved_per_url]}, {:urls, [:streaming, :status]}, {:vapid, [:public_key]}, @@ -51,48 +58,59 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do ] @pleroma_configuration2_filter [ - {:metadata, [:avatar_upload_limit, :background_upload_limit, :banner_upload_limit, :background_image, :chat_limit, :description_limit, :shout_limit]} + {:metadata, + [ + :avatar_upload_limit, + :background_upload_limit, + :banner_upload_limit, + :background_image, + :chat_limit, + :description_limit, + :shout_limit + ]} ] - @show_keys @common_information_keys ++ [ - :uri, - :description, - :short_description, - :email, - :urls, - :stats, - :thumbnail, - :registrations, - :approval_required, - :contact_account, - :configuration, - # Extra (not present in Mastodon) - :max_toot_chars, - :max_media_attachments, - :poll_limits, - :upload_limit, - :avatar_upload_limit, - :background_upload_limit, - :banner_upload_limit, - :background_image, - :shout_limit, - :description_limit, - :chat_limit, - :pleroma - ] + @show_keys @common_information_keys ++ + [ + :uri, + :description, + :short_description, + :email, + :urls, + :stats, + :thumbnail, + :registrations, + :approval_required, + :contact_account, + :configuration, + # Extra (not present in Mastodon) + :max_toot_chars, + :max_media_attachments, + :poll_limits, + :upload_limit, + :avatar_upload_limit, + :background_upload_limit, + :banner_upload_limit, + :background_image, + :shout_limit, + :description_limit, + :chat_limit, + :pleroma + ] - @show2_keys @common_information_keys ++ [ - :domain, - :source_url, - :description, - :usage, - :thumbnail, - :configuration, - :registrations, - :contact, - # Extra (not present in Mastodon) - :pleroma - ] + @show2_keys @common_information_keys ++ + [ + :domain, + :source_url, + :description, + :usage, + :thumbnail, + :configuration, + :registrations, + :contact, + # Extra (not present in Mastodon) + :pleroma + ] @features [ "pleroma_api", @@ -158,7 +176,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do languages: ["en", "cs"], rules: InstanceView.render("rules.json", %{}), title: "test title", - version: InstanceView.mastodon_api_level() <> " (compatible; #{Pleroma.Application.named_version()})" + version: + InstanceView.mastodon_api_level() <> + " (compatible; #{Pleroma.Application.named_version()})" } Map.equal?(expected, filtered_info) @@ -194,7 +214,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_configuration(info) do configuration = info[:configuration] - filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @configuration_keys end) + + filtered_configuration = + Map.reject(configuration, fn {key, _value} -> key not in @configuration_keys end) expected = %{ accounts: %{ @@ -222,6 +244,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_configuration2(info) do configuration = info[:configuration] + filtered_configuration = configuration |> Map.reject(fn {key, _value} -> key not in @configuration2_keys end) @@ -274,11 +297,16 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_pleroma_configuration(info) do configuration = info[:pleroma] - filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @pleroma_configuration_keys end) + + filtered_configuration = + Map.reject(configuration, fn {key, _value} -> key not in @pleroma_configuration_keys end) # Tested elsewhere already metadata = Map.fetch!(filtered_configuration, :metadata) - filtered_metadata = Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) + + filtered_metadata = + Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) + filtered_configuration = %{filtered_configuration | metadata: filtered_metadata} expected = %{ @@ -293,19 +321,18 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do post_formats: ["text/plain"], birthday_required: true, birthday_min_age: 1, - translation: - %{ - source_languages: ["en", "pl"], - target_languages: ["en", "pl"] - }, + translation: %{ + source_languages: ["en", "pl"], + target_languages: ["en", "pl"] + }, base_urls: %{ media_proxy: "https://mediaproxy.example.com", upload: "https://upload.example.com" }, markup: %{ - allow_inline_images: true, - allow_headings: true, - allow_tables: true + allow_inline_images: true, + allow_headings: true, + allow_tables: true } }, stats: %{mau: Pleroma.User.active_user_count()}, @@ -317,11 +344,16 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_pleroma_configuration2(info) do configuration = info[:pleroma] - filtered_configuration = Map.reject(configuration, fn {key, _value} -> key not in @pleroma_configuration_keys end) + + filtered_configuration = + Map.reject(configuration, fn {key, _value} -> key not in @pleroma_configuration_keys end) # Tested elsewhere already metadata = Map.fetch!(filtered_configuration, :metadata) - filtered_metadata = Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) + + filtered_metadata = + Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) + filtered_configuration = %{filtered_configuration | metadata: filtered_metadata} |> filter_render(@pleroma_configuration2_filter, %{}) @@ -386,14 +418,26 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do output = InstanceView.federation() assert %{quarantined_instances: ["quarantine.example.com"]} = output - assert %{quarantined_instances_info: %{"quarantined_instances" => %{"quarantine.example.com" => %{"reason" => "crimes"}}}} = output + + assert %{ + quarantined_instances_info: %{ + "quarantined_instances" => %{"quarantine.example.com" => %{"reason" => "crimes"}} + } + } = output end test "rejected instances" do - clear_config([:instance, :rejected_instances], [{"rejected.example.com", "not enough #cofe posting"}]) + clear_config([:instance, :rejected_instances], [ + {"rejected.example.com", "not enough #cofe posting"} + ]) output = InstanceView.federation() - assert %{rejected_instances: %{"rejected.example.com" => %{"reason" => "not enough #cofe posting"}}} = output + + assert %{ + rejected_instances: %{ + "rejected.example.com" => %{"reason" => "not enough #cofe posting"} + } + } = output end test "federating" do @@ -405,29 +449,39 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do test "transparency" do clear_config([:instance, :quarantined_instances], [{"quarantine.example.com", "crimes"}]) - clear_config([:instance, :rejected_instances], [{"rejected.example.com", "not enough #cofe posting"}]) clear_config([:instance, :federating], true) clear_config([:mrf, :transparency], false) + clear_config([:instance, :rejected_instances], [ + {"rejected.example.com", "not enough #cofe posting"} + ]) + output = InstanceView.federation() assert %{enabled: true} == output end test "MRFs" do clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) - clear_config([:mrf, :transparency_exclusions], [{"media1.example.com", "the Fediverse doesn't need to know"}]) - clear_config([:mrf_simple, :media_removal], [{"media1.example.com", "NSFW"}, {"media2.example.com", "usual suspects"}]) clear_config([:mrf_simple, :reject], [{"rejected.example.com", "not enough #cofe posting"}]) + clear_config([:mrf, :transparency_exclusions], [ + {"media1.example.com", "the Fediverse doesn't need to know"} + ]) + + clear_config([:mrf_simple, :media_removal], [ + {"media1.example.com", "NSFW"}, + {"media2.example.com", "usual suspects"} + ]) + output = InstanceView.federation() expected = %{ mrf_simple: %{ reject: ["rejected.example.com"], - media_removal: ["media2.example.com"], + media_removal: ["media2.example.com"] }, mrf_hashtag: %{ - sensitive: ["nsfw"], + sensitive: ["nsfw"] }, exclusions: true, mrf_policies: ["SimplePolicy", "HashtagPolicy"], @@ -569,10 +623,21 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do describe "render domain_blocks.json" do setup do clear_config([:mrf, :transparency], true) - clear_config([:mrf, :transparency_exclusions], [{"removed1.example.com", "the Fediverse doesn't need to know"}]) clear_config([:mrf_simple, :media_removal], [{"media1.example.com", "NSFW"}]) - clear_config([:mrf_simple, :reject], [{"rejected.example.com", "not enough #cofe posting"}, {"rejected-without-reason.example.com", ""}]) - clear_config([:mrf_simple, :federated_timeline_removal], [{"removed1.example.com", "spam"}, {"removed2.example.com", "more spam"}]) + + clear_config([:mrf, :transparency_exclusions], [ + {"removed1.example.com", "the Fediverse doesn't need to know"} + ]) + + clear_config([:mrf_simple, :reject], [ + {"rejected.example.com", "not enough #cofe posting"}, + {"rejected-without-reason.example.com", ""} + ]) + + clear_config([:mrf_simple, :federated_timeline_removal], [ + {"removed1.example.com", "spam"}, + {"removed2.example.com", "more spam"} + ]) :ok end From 6c79cc792f856027fb8c1e9372ede341272d166f Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 27 Jun 2026 22:41:45 +0200 Subject: [PATCH 119/195] InstanceView: improve filtering in tests, simplify --- .../mastodon_api/views/instance_view_test.exs | 108 +++++++----------- 1 file changed, 42 insertions(+), 66 deletions(-) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index cae704b90..7bdd9a126 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -20,21 +20,6 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do :version ] - @configuration_keys [ - :accounts, - :statuses, - :media_attachments, - :polls - ] - - @configuration2_keys @configuration_keys ++ - [ - :urls, - :vapid, - :translation, - :timelines_access - ] - # Used for filtering out values from configuration.json render in configuration2.json render @configuration2_filter [ {:accounts, @@ -51,12 +36,6 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do {:timelines_access, [:live_feeds, :hashtag_feeds, :trending_link_feeds]} ] - @pleroma_configuration_keys [ - :metadata, - :stats, - :vapid_public_key - ] - @pleroma_configuration2_filter [ {:metadata, [ @@ -159,14 +138,37 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do ] # Filters out values from one layer deep nested Maps. - defp filter_render(%{}, [], acc), do: acc + # Filtering types: + # - :inclusive -> filters out everything that is not in the filters + # - :discriminative -> filters out what is in the filters + defp filter_render(input, filter, type, acc) - defp filter_render(input, filter, acc) do - {{filter_key, filters}, filter_rest} = List.pop_at(filter, 0) - {config_key_values, input_rest} = Map.pop!(input, filter_key) - filtered = Map.reject(config_key_values, fn {key, _} -> key not in filters end) + defp filter_render(_input_rest, [], type, acc) when type == :inclusive, do: acc - filter_render(input_rest, filter_rest, Map.put(acc, filter_key, filtered)) + defp filter_render(input_rest, [], type, acc) when type == :discriminative, + do: Map.merge(input_rest, acc) + + defp filter_render(input, filter, type, acc) do + {current_filter, filter_rest} = List.pop_at(filter, 0) + + case current_filter do + nil -> + filter_render(input, filter_rest, type, acc) + + {filter_key, filters} -> + {config_key_values, input_rest} = Map.pop!(input, filter_key) + + filtered = + case type do + :inclusive -> + Map.reject(config_key_values, fn {key, _} -> key not in filters end) + + :discriminative -> + Map.reject(config_key_values, fn {key, _} -> key in filters end) + end + + filter_render(input_rest, filter_rest, type, Map.put(acc, filter_key, filtered)) + end end defp check_common_information(info) do @@ -215,9 +217,6 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_configuration(info) do configuration = info[:configuration] - filtered_configuration = - Map.reject(configuration, fn {key, _value} -> key not in @configuration_keys end) - expected = %{ accounts: %{ max_featured_tags: 0 @@ -239,16 +238,13 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do } } - Map.equal?(expected, filtered_configuration) + Map.equal?(expected, configuration) end defp check_configuration2(info) do - configuration = info[:configuration] - - filtered_configuration = - configuration - |> Map.reject(fn {key, _value} -> key not in @configuration2_keys end) - |> filter_render(@configuration2_filter, %{}) + configuration = + info[:configuration] + |> filter_render(@configuration2_filter, :inclusive, %{}) expected = %{ accounts: %{ @@ -279,7 +275,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do } } - Map.equal?(expected, filtered_configuration) + Map.equal?(expected, configuration) end defp check_registrations(info) do @@ -296,18 +292,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do end defp check_pleroma_configuration(info) do - configuration = info[:pleroma] - - filtered_configuration = - Map.reject(configuration, fn {key, _value} -> key not in @pleroma_configuration_keys end) - - # Tested elsewhere already - metadata = Map.fetch!(filtered_configuration, :metadata) - - filtered_metadata = - Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) - - filtered_configuration = %{filtered_configuration | metadata: filtered_metadata} + configuration = + info[:pleroma] + |> filter_render([{:metadata, [:features, :federation]}], :discriminative, %{}) expected = %{ metadata: %{ @@ -339,24 +326,13 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) } - Map.equal?(expected, filtered_configuration) + Map.equal?(expected, configuration) end defp check_pleroma_configuration2(info) do - configuration = info[:pleroma] - - filtered_configuration = - Map.reject(configuration, fn {key, _value} -> key not in @pleroma_configuration_keys end) - - # Tested elsewhere already - metadata = Map.fetch!(filtered_configuration, :metadata) - - filtered_metadata = - Map.reject(metadata, fn {key, _value} -> key in [:features, :federation] end) - - filtered_configuration = - %{filtered_configuration | metadata: filtered_metadata} - |> filter_render(@pleroma_configuration2_filter, %{}) + configuration = + info[:pleroma] + |> filter_render(@pleroma_configuration2_filter, :inclusive, %{}) expected = %{ metadata: %{ @@ -370,7 +346,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do } } - Map.equal?(expected, filtered_configuration) + Map.equal?(expected, configuration) end # When this fails, a new feature flag was probably added. Add it to the macros above. From ef321846d4bc4860789ce24edeeeda6384fede67 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 27 Jun 2026 22:44:59 +0200 Subject: [PATCH 120/195] InstanceView: Use attribute for pleroma_configuration filter in test --- test/pleroma/web/mastodon_api/views/instance_view_test.exs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index 7bdd9a126..f7d058365 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -36,6 +36,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do {:timelines_access, [:live_feeds, :hashtag_feeds, :trending_link_feeds]} ] + @pleroma_configuration_filter [{:metadata, [:features, :federation]}] + @pleroma_configuration2_filter [ {:metadata, [ @@ -294,7 +296,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do defp check_pleroma_configuration(info) do configuration = info[:pleroma] - |> filter_render([{:metadata, [:features, :federation]}], :discriminative, %{}) + |> filter_render(@pleroma_configuration_filter, :discriminative, %{}) expected = %{ metadata: %{ From 6e03f1d7898b8b9cc5ccfe40d81954ef91d24f5c Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 27 Jun 2026 23:34:01 +0200 Subject: [PATCH 121/195] InstanceView: Tighten show.json render test, rename show2-only funcs --- .../mastodon_api/views/instance_view_test.exs | 53 ++++++++++++++++--- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index f7d058365..b0ef4b659 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -79,6 +79,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do :pleroma ] + # Checked in other check_* functions + @show_filter @common_information_keys ++ [:contact_account, :configuration, :pleroma] + @show2_keys @common_information_keys ++ [ :domain, @@ -188,7 +191,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do Map.equal?(expected, filtered_info) end - defp check_thumbnail(info) do + defp check_thumbnail2(info) do thumbnail = info[:thumbnail] expected = %{ @@ -209,7 +212,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do Map.equal?(expected, contact) end - defp check_contact_name(info, user) do + defp check_contact_account(info, user) do contact = info[:contact_account] view = Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user, for: nil}) @@ -280,7 +283,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do Map.equal?(expected, configuration) end - defp check_registrations(info) do + defp check_registrations2(info) do registrations = info[:registrations] expected = %{ @@ -351,6 +354,43 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do Map.equal?(expected, configuration) end + defp check_show(info) do + filter = @show_filter + filtered_info = Map.reject(info, fn {key, _value} -> key in filter end) + + expected = %{ + uri: Pleroma.Web.WebFinger.host(), + description: "Pleroma: An efficient and flexible fediverse server", + short_description: "Corndog Emporium!", + email: "nonexist@example.com", + urls: %{ + streaming_api: Pleroma.Web.Endpoint.websocket_url(), + }, + stats: Pleroma.Stats.get_stats(), + thumbnail: "https://example.com/thumbnail.png", + registrations: false, + approval_required: true, + max_toot_chars: 4096, + max_media_attachments: 1, + poll_limits: %{ + max_options: 4, + max_option_chars: 120, + min_expiration: 1, + max_expiration: 2 + }, + upload_limit: 1024, + avatar_upload_limit: 1024, + background_upload_limit: 1024, + banner_upload_limit: 1024, + background_image: Pleroma.Web.Endpoint.url() <> "/media/background.png", + shout_limit: 120, + description_limit: 120, + chat_limit: 120, + } + + Map.equal?(expected, filtered_info) + end + # When this fails, a new feature flag was probably added. Add it to the macros above. describe "uncofigurable features" do setup do: Enum.each(@configurable_features_flags, &clear_config(&1, false)) @@ -545,7 +585,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do expected_keys = Enum.sort(@show_keys) assert check_common_information(output) - assert check_contact_name(output, user) + assert check_show(output) + assert check_contact_account(output, user) assert check_configuration(output) assert check_pleroma_configuration(output) assert expected_keys == Enum.sort(Map.keys(output)) @@ -556,9 +597,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do expected_keys = Enum.sort(@show2_keys) assert check_common_information(output) - assert check_thumbnail(output) + assert check_thumbnail2(output) assert check_configuration2(output) - assert check_registrations(output) + assert check_registrations2(output) assert check_contact(output, user) assert check_pleroma_configuration2(output) assert expected_keys == Enum.sort(Map.keys(output)) From d19a9f768c996c845222805d6d6dd9b648580d95 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 27 Jun 2026 23:39:48 +0200 Subject: [PATCH 122/195] InstanceView: Tighten show2.json --- .../mastodon_api/views/instance_view_test.exs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index b0ef4b659..3f7c83968 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -96,6 +96,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do :pleroma ] + @show2_filter @common_information_keys ++ [:thumbnail, :configuration, :registrations, :contact, :pleroma] + @features [ "pleroma_api", "mastodon_api", @@ -391,6 +393,20 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do Map.equal?(expected, filtered_info) end + defp check_show2(info) do + filter = @show2_filter + filtered_info = Map.reject(info, fn {key, _value} -> key in filter end) + + expected = %{ + domain: Pleroma.Web.WebFinger.host(), + source_url: Pleroma.Application.repository(), + description: "Corndog Emporium!", + usage: %{users: %{active_month: Pleroma.User.active_user_count()}} + } + + Map.equal?(expected, filtered_info) + end + # When this fails, a new feature flag was probably added. Add it to the macros above. describe "uncofigurable features" do setup do: Enum.each(@configurable_features_flags, &clear_config(&1, false)) @@ -597,6 +613,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do expected_keys = Enum.sort(@show2_keys) assert check_common_information(output) + assert check_show2(output) assert check_thumbnail2(output) assert check_configuration2(output) assert check_registrations2(output) From 3027052b0dbc042af45135b041754b05ffe4893d Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sun, 28 Jun 2026 00:17:28 +0200 Subject: [PATCH 123/195] InstanceView: Remove wall of changed configs in show render tests. Switched to using config defaults instead. --- .../mastodon_api/views/instance_view_test.exs | 180 +++++++----------- 1 file changed, 70 insertions(+), 110 deletions(-) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index 3f7c83968..6acfbab99 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -182,9 +182,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do filtered_info = Map.reject(info, fn {key, _value} -> key not in @common_information_keys end) expected = %{ - languages: ["en", "cs"], + languages: ["en"], rules: InstanceView.render("rules.json", %{}), - title: "test title", + title: "Pleroma", version: InstanceView.mastodon_api_level() <> " (compatible; #{Pleroma.Application.named_version()})" @@ -197,7 +197,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do thumbnail = info[:thumbnail] expected = %{ - url: URI.merge(Pleroma.Web.Endpoint.url(), "https://example.com/thumbnail.png") |> to_string + url: URI.merge(Pleroma.Web.Endpoint.url(), "http://localhost:4001/instance/thumbnail.jpeg") |> to_string } expected == thumbnail @@ -207,7 +207,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do contact = info[:contact] expected = %{ - email: "nonexist@example.com", + email: "admin@example.com", account: Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user, for: nil}) } @@ -229,19 +229,19 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do max_featured_tags: 0 }, statuses: %{ - max_characters: 4096, - max_media_attachments: 1 + max_characters: 5_000, + max_media_attachments: 1_000 }, media_attachments: %{ - image_size_limit: 1024, - video_size_limit: 1024, + image_size_limit: 16_000_000, + video_size_limit: 16_000_000, supported_mime_types: ["application/octet-stream"] }, polls: %{ - max_options: 4, - max_characters_per_option: 120, - min_expiration: 1, - max_expiration: 2 + max_options: 20, + max_characters_per_option: 200, + min_expiration: 0, + max_expiration: 365 * 24 * 60 * 60 } } @@ -256,24 +256,24 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do expected = %{ accounts: %{ max_pinned_statuses: 1, - max_profile_fields: 1, - profile_field_name_limit: 1, - profile_field_value_limit: 1 + max_profile_fields: 10, + profile_field_name_limit: 512, + profile_field_value_limit: 2048 }, statuses: %{ characters_reserved_per_url: 0 }, urls: %{ streaming: Pleroma.Web.Endpoint.websocket_url(), - status: "https://status.example.com" + status: nil }, vapid: %{ public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) }, translation: %{enabled: true}, timelines_access: %{ - live_feeds: %{local: "authenticated", remote: "authenticated"}, - hashtag_feeds: %{local: "authenticated", remote: "authenticated"}, + live_feeds: %{local: "public", remote: "public"}, + hashtag_feeds: %{local: "public", remote: "public"}, # not implemented in Pleroma trending_link_feeds: %{ local: "disabled", @@ -289,8 +289,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do registrations = info[:registrations] expected = %{ - enabled: false, - approval_required: true, + enabled: true, + approval_required: false, message: nil, url: nil } @@ -305,28 +305,31 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do expected = %{ metadata: %{ - account_activation_required: true, + account_activation_required: false, fields_limits: %{ - max_fields: 1, - max_remote_fields: 1, - name_length: 1, - value_length: 1 + max_fields: 10, + max_remote_fields: 20, + name_length: 512, + value_length: 2048 }, - post_formats: ["text/plain"], - birthday_required: true, - birthday_min_age: 1, + post_formats: [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode", + "text/x.misskeymarkdown" + ], + birthday_required: false, + birthday_min_age: 0, translation: %{ source_languages: ["en", "pl"], target_languages: ["en", "pl"] }, - base_urls: %{ - media_proxy: "https://mediaproxy.example.com", - upload: "https://upload.example.com" - }, + base_urls: %{}, markup: %{ allow_inline_images: true, - allow_headings: true, - allow_tables: true + allow_headings: false, + allow_tables: false } }, stats: %{mau: Pleroma.User.active_user_count()}, @@ -343,13 +346,13 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do expected = %{ metadata: %{ - avatar_upload_limit: 1024, - background_upload_limit: 1024, - banner_upload_limit: 1024, - background_image: Pleroma.Web.Endpoint.url() <> "/media/background.png", - chat_limit: 120, - description_limit: 120, - shout_limit: 120 + avatar_upload_limit: 2_000_000, + background_upload_limit: 4_000_000, + banner_upload_limit: 4_000_000, + background_image: Pleroma.Web.Endpoint.url() <> "/images/city.jpg", + chat_limit: 5_000, + description_limit: 5_000, + shout_limit: 5_000 } } @@ -363,31 +366,31 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do expected = %{ uri: Pleroma.Web.WebFinger.host(), description: "Pleroma: An efficient and flexible fediverse server", - short_description: "Corndog Emporium!", - email: "nonexist@example.com", + short_description: "", + email: "admin@example.com", urls: %{ streaming_api: Pleroma.Web.Endpoint.websocket_url(), }, stats: Pleroma.Stats.get_stats(), - thumbnail: "https://example.com/thumbnail.png", - registrations: false, - approval_required: true, - max_toot_chars: 4096, - max_media_attachments: 1, + thumbnail: "http://localhost:4001/instance/thumbnail.jpeg", + registrations: true, + approval_required: false, + max_toot_chars: 5_000, + max_media_attachments: 1_000, poll_limits: %{ - max_options: 4, - max_option_chars: 120, - min_expiration: 1, - max_expiration: 2 + max_options: 20, + max_option_chars: 200, + min_expiration: 0, + max_expiration: 365 * 24 * 60 * 60 }, - upload_limit: 1024, - avatar_upload_limit: 1024, - background_upload_limit: 1024, - banner_upload_limit: 1024, - background_image: Pleroma.Web.Endpoint.url() <> "/media/background.png", - shout_limit: 120, - description_limit: 120, - chat_limit: 120, + upload_limit: 16_000_000, + avatar_upload_limit: 2_000_000, + background_upload_limit: 4_000_000, + banner_upload_limit: 4_000_000, + background_image: Pleroma.Web.Endpoint.url() <> "/images/city.jpg", + shout_limit: 5_000, + description_limit: 5_000, + chat_limit: 5_000, } Map.equal?(expected, filtered_info) @@ -400,7 +403,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do expected = %{ domain: Pleroma.Web.WebFinger.host(), source_url: Pleroma.Application.repository(), - description: "Corndog Emporium!", + description: "", usage: %{users: %{active_month: Pleroma.User.active_user_count()}} } @@ -541,57 +544,14 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do user = insert(:user) - # Wall of configs for testing whether they are rendered properly + # contact in show2.json + clear_config([:instance, :contact_username], user.nickname) + + # Pleroma.Language.Translation.configured?() -> true clear_config([Pleroma.Language.Translation, :provider], TranslationMock) - clear_config([:instance, :languages], ["en", "cs"]) - clear_config([:instance, :name], "test title") - clear_config([:instance, :contact_username], user.nickname) - clear_config([:instance, :limit], 4096) - clear_config([:instance, :max_media_attachments], 1) - clear_config([:instance, :upload_limit], 1024) - clear_config([:instance, :poll_limits, :max_options], 4) - clear_config([:instance, :poll_limits, :max_option_chars], 120) - clear_config([:instance, :poll_limits, :min_expiration], 1) - clear_config([:instance, :poll_limits, :max_expiration], 2) - clear_config([:instance, :account_activation_required], true) - clear_config([:instance, :max_account_fields], 1) - clear_config([:instance, :max_remote_account_fields], 1) - clear_config([:instance, :account_field_name_length], 1) - clear_config([:instance, :account_field_value_length], 1) - clear_config([:instance, :allowed_post_formats], ["text/plain"]) - clear_config([:instance, :birthday_required], true) - clear_config([:instance, :birthday_min_age], 1) - - clear_config([:instance, :short_description], "Corndog Emporium!") - clear_config([:instance, :instance_thumbnail], "https://example.com/thumbnail.png") - clear_config([:instance, :registrations_open], false) - clear_config([:instance, :account_approval_required], true) - - clear_config([:instance, :max_pinned_statuses], 1) - clear_config([:instance, :status_page], "https://status.example.com") - - clear_config([:instance, :email], "nonexist@example.com") - - clear_config([:instance, :avatar_upload_limit], 1024) - clear_config([:instance, :background_upload_limit], 1024) - clear_config([:instance, :banner_upload_limit], 1024) - clear_config([:instance, :background_image], "/media/background.png") - clear_config([:instance, :chat_limit], 120) - clear_config([:instance, :description_limit], 120) - - clear_config([:media_proxy, :enabled], true) - clear_config([:media_proxy, :base_url], "https://mediaproxy.example.com") - - clear_config([Pleroma.Upload, :base_url], "https://upload.example.com") - - clear_config([:markup, :allow_inline_images], true) - clear_config([:markup, :allow_headings], true) - clear_config([:markup, :allow_tables], true) - - clear_config([:restrict_unauthenticated, :timelines], %{local: true, federated: true}) - - clear_config([:shout, :limit], 120) + # Deprecated, but still used in view + clear_config([:instance, :chat_limit], 5_000) %{user: user} end From dd070883778107e2dba73821ceb66aeefb3d6a82 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sun, 28 Jun 2026 00:19:38 +0200 Subject: [PATCH 124/195] InstanceView: lint tests --- .../mastodon_api/views/instance_view_test.exs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index 6acfbab99..8f066a6f5 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -96,7 +96,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do :pleroma ] - @show2_filter @common_information_keys ++ [:thumbnail, :configuration, :registrations, :contact, :pleroma] + @show2_filter @common_information_keys ++ + [:thumbnail, :configuration, :registrations, :contact, :pleroma] @features [ "pleroma_api", @@ -197,7 +198,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do thumbnail = info[:thumbnail] expected = %{ - url: URI.merge(Pleroma.Web.Endpoint.url(), "http://localhost:4001/instance/thumbnail.jpeg") |> to_string + url: + URI.merge(Pleroma.Web.Endpoint.url(), "http://localhost:4001/instance/thumbnail.jpeg") + |> to_string } expected == thumbnail @@ -313,11 +316,11 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do value_length: 2048 }, post_formats: [ - "text/plain", - "text/html", - "text/markdown", - "text/bbcode", - "text/x.misskeymarkdown" + "text/plain", + "text/html", + "text/markdown", + "text/bbcode", + "text/x.misskeymarkdown" ], birthday_required: false, birthday_min_age: 0, @@ -369,7 +372,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do short_description: "", email: "admin@example.com", urls: %{ - streaming_api: Pleroma.Web.Endpoint.websocket_url(), + streaming_api: Pleroma.Web.Endpoint.websocket_url() }, stats: Pleroma.Stats.get_stats(), thumbnail: "http://localhost:4001/instance/thumbnail.jpeg", @@ -390,7 +393,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do background_image: Pleroma.Web.Endpoint.url() <> "/images/city.jpg", shout_limit: 5_000, description_limit: 5_000, - chat_limit: 5_000, + chat_limit: 5_000 } Map.equal?(expected, filtered_info) From 964feb1e470ba666b70c9acd3d329043a849e030 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sun, 28 Jun 2026 00:36:43 +0200 Subject: [PATCH 125/195] InstanceView: Remove unreachable guards in filter_render --- .../mastodon_api/views/instance_view_test.exs | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs index 8f066a6f5..66485be0e 100644 --- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs @@ -157,26 +157,19 @@ defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do do: Map.merge(input_rest, acc) defp filter_render(input, filter, type, acc) do - {current_filter, filter_rest} = List.pop_at(filter, 0) + {{filter_key, filters}, filter_rest} = List.pop_at(filter, 0) + {config_key_values, input_rest} = Map.pop!(input, filter_key) - case current_filter do - nil -> - filter_render(input, filter_rest, type, acc) + filtered = + case type do + :inclusive -> + Map.reject(config_key_values, fn {key, _} -> key not in filters end) - {filter_key, filters} -> - {config_key_values, input_rest} = Map.pop!(input, filter_key) + :discriminative -> + Map.reject(config_key_values, fn {key, _} -> key in filters end) + end - filtered = - case type do - :inclusive -> - Map.reject(config_key_values, fn {key, _} -> key not in filters end) - - :discriminative -> - Map.reject(config_key_values, fn {key, _} -> key in filters end) - end - - filter_render(input_rest, filter_rest, type, Map.put(acc, filter_key, filtered)) - end + filter_render(input_rest, filter_rest, type, Map.put(acc, filter_key, filtered)) end defp check_common_information(info) do From a020b8a1901720238e8749d8487ddb218b82472b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 29 Jun 2026 23:47:43 +0200 Subject: [PATCH 126/195] Elixir 1.19: Ensure default primary logger configg level is debug Setting the default handler and ex_syslogger handler levels does not necessarily have to set the primary logger log level, at least in the future. Without this, the primary log level might be lower than the configured console/syslog levels, which would cause some desired logs to not be logged. --- config/config.exs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/config.exs b/config/config.exs index 37a2590ca..80183a3d9 100644 --- a/config/config.exs +++ b/config/config.exs @@ -133,12 +133,17 @@ config :pleroma, Pleroma.Web.Endpoint, ] # Configures Elixir's Logger +# Primary config +config :logger, level: :debug + +# Console config config :logger, :default_handler, level: :debug config :logger, :default_formatter, format: "\n$time $metadata[$level] $message\n", metadata: [:actor, :path, :type, :user] +# Syslog config config :logger, :ex_syslogger, level: :debug, ident: "pleroma", From 7d9175350d0f2ce715048ac1795ec4790d192c3b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 30 Jun 2026 00:24:48 +0200 Subject: [PATCH 127/195] Elixir 1.19: Completely overwrite formatter for default handler :logger.update_handler_config/3 can merge configs, :logger.set_handler_config/3 instead resets the rest to defaults. --- lib/pleroma/config/deprecation_warnings.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index e77785483..8f8786ee7 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -435,7 +435,7 @@ defmodule Pleroma.Config.DeprecationWarnings do """) if log_level, do: :logger.update_handler_config(:default, :level, log_level) - :logger.update_handler_config(:default, :formatter, formatter) + :logger.set_handler_config(:default, :formatter, formatter) end end From f4b415c54e475f891cffe9363d4e19d58a062ad3 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 22 May 2026 12:12:11 -0700 Subject: [PATCH 128/195] Improve RSS compliance The core feed reader issue was that RSS 2.0 is a simple text element and can only appear once per . By having multiple tags with rel attributes, parsers were getting confused and resolving entries to the wrong URL. Now each item has exactly one : the post URL. Everything else uses valid or dedicated extension elements where appropriate. --- changelog.d/rss.fix | 1 + lib/pleroma/web/feed/feed_view.ex | 151 +++++++++++++++++- .../templates/feed/feed/_activity.atom.eex | 24 +-- .../web/templates/feed/feed/_activity.rss.eex | 35 ++-- .../feed/feed/_tag_activity.atom.eex | 32 ++-- .../templates/feed/feed/_tag_activity.xml.eex | 22 ++- .../web/templates/feed/feed/tag.atom.eex | 6 +- .../web/templates/feed/feed/tag.rss.eex | 5 + .../web/templates/feed/feed/user.atom.eex | 6 +- .../web/templates/feed/feed/user.rss.eex | 12 +- test/pleroma/web/feed/tag_controller_test.exs | 2 +- 11 files changed, 212 insertions(+), 84 deletions(-) create mode 100644 changelog.d/rss.fix diff --git a/changelog.d/rss.fix b/changelog.d/rss.fix new file mode 100644 index 000000000..a3455698a --- /dev/null +++ b/changelog.d/rss.fix @@ -0,0 +1 @@ +Fix RSS feeds to be W3C compliant and render better in RSS readers diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index e1ee33d62..a433b8403 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -97,6 +97,64 @@ defmodule Pleroma.Web.Feed.FeedView do end end + def rss_content_encoded(data) do + data + |> feed_content_encoded() + |> String.replace("]]>", "]]>") + end + + def atom_content_encoded(data), do: feed_content_encoded(data) + + defp feed_content_encoded(data) do + base_content = + case activity_content(data) do + "" -> + data["summary"] || data["type"] || + "" + |> escape() + + content -> + content + end + + attachments_html = + (data["attachment"] || []) + |> Enum.map(&rss_attachment_preview/1) + |> Enum.reject(&(&1 == "")) + |> Enum.join("

") + + [base_content, attachments_html] + |> Enum.reject(&(&1 == "")) + |> Enum.join("

") + end + + defp rss_attachment_preview(attachment) do + href = attachment_href(attachment) + + if is_binary(href) and href != "" do + media_type = attachment_type(attachment) || "application/octet-stream" + escaped_href = escape(href) + name = attachment["name"] || "Attachment" + escaped_name = escape(name) + + cond do + String.starts_with?(media_type, "image/") -> + ~s(

#{escaped_name}

) + + String.starts_with?(media_type, "video/") -> + ~s(

#{escaped_name}

) + + String.starts_with?(media_type, "audio/") -> + ~s(

#{escaped_name}

) + + true -> + ~s(

#{escaped_name}

) + end + else + "" + end + end + def activity_content(%{"content" => content}) do content |> String.replace(~r/[\n\r]/, "") @@ -106,16 +164,99 @@ defmodule Pleroma.Web.Feed.FeedView do def activity_context(activity), do: escape(activity.data["context"]) + def feed_self_url(conn), do: Phoenix.Controller.current_url(conn) + + def activity_link(activity, data) do + cond do + activity.local -> data["id"] || data["url"] + true -> data["external_url"] || data["id"] || data["url"] + end + end + def attachment_href(attachment) do - attachment["url"] - |> hd() + attachment + |> attachment_url_data() |> Map.get("href") end def attachment_type(attachment) do - attachment["url"] - |> hd() - |> Map.get("mediaType") + attachment + |> attachment_url_data() + |> Map.get("mediaType") || attachment["mediaType"] + end + + def attachment_size(attachment) do + attachment + |> attachment_url_data() + |> Map.get("size", 0) + end + + def attachment_size_positive(attachment) do + case attachment_size(attachment) do + size when is_integer(size) and size > 0 -> + size + + size when is_binary(size) -> + case Integer.parse(size) do + {parsed, ""} when parsed > 0 -> parsed + _ -> nil + end + + _ -> + nil + end + end + + def attachment_medium(attachment) do + case attachment_type(attachment) do + "image/" <> _rest -> "image" + "video/" <> _rest -> "video" + "audio/" <> _rest -> "audio" + _ -> "document" + end + end + + def attachment_previewable?(attachment) do + attachment_medium(attachment) in ["image", "video", "audio"] + end + + def attachment_description(attachment) do + attachment["name"] || "" + end + + def media_content_xml(attachment) do + href = escape(attachment_href(attachment) || "") + type = escape(attachment_type(attachment) || "application/octet-stream") + medium = escape(attachment_medium(attachment)) + + file_size_attr = + case attachment_size_positive(attachment) do + size when is_integer(size) -> ~s( fileSize="#{size}") + _ -> "" + end + + description_xml = + case attachment_description(attachment) do + "" -> + "" + + description -> + ~s(\n #{escape(description)}) + end + + """ + + nonadult#{description_xml} + + """ + end + + defp attachment_url_data(attachment) do + case attachment["url"] do + [first | _] when is_map(first) -> first + %{} = map -> map + _ -> %{} + end end def get_href(id) do diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index b774f7984..f87d97cb4 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -3,7 +3,7 @@ http://activitystrea.ms/schema/1.0/post <%= @data["id"] %> <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> - <%= activity_description(@data) %> + <%= escape(atom_content_encoded(@data)) %> <%= to_rfc3339(@data["published"]) %> <%= to_rfc3339(@data["published"]) %> @@ -15,35 +15,23 @@ <%= escape(@data["summary"]) %> <% end %> - <%= if @activity.local do %> - - - <% else %> - - <% end %> + + <%= for tag <- Pleroma.Object.hashtags(@object) do %> <% end %> <%= for attachment <- @data["attachment"] || [] do %> - + <%= unless attachment_previewable?(attachment) do %> + + <% end %> <% end %> <%= if @data["inReplyTo"] do %> <% end %> - <%= for id <- @activity.recipients do %> - <%= if id == Pleroma.Constants.as_public() do %> - - <% else %> - <%= unless Regex.match?(~r/^#{Pleroma.Web.Endpoint.url()}.+followers$/, id) do %> - - <% end %> - <% end %> - <% end %> - <%= for {emoji, file} <- @data["emoji"] || %{} do %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex index 7de98f736..a8c2aa342 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -1,45 +1,32 @@ http://activitystrea.ms/schema/1.0/note http://activitystrea.ms/schema/1.0/post - <%= @data["id"] %> + <%= @data["id"] %> <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> <%= activity_description(@data) %> + ]]> <%= to_rfc2822(@data["published"]) %> <%= activity_context(@activity) %> - <%= if @activity.local do %> - <%= @data["id"] %> - <% else %> - <%= @data["external_url"] %> - <% end %> - - <%= activity_context(@activity) %> + <%= activity_link(@activity, @data) %> <%= for tag <- Pleroma.Object.hashtags(@object) do %> - + <%= if tag && tag != "" do %> + <%= tag %> + <% end %> <% end %> <%= for attachment <- @data["attachment"] || [] do %> - + <%= size = attachment_size_positive(attachment) %> + <%= unless attachment_previewable?(attachment) do %> + + <% end %> + <%= media_content_xml(attachment) %> <% end %> <%= if @data["inReplyTo"] do %> <% end %> - - <%= for id <- @activity.recipients do %> - <%= if id == Pleroma.Constants.as_public() do %> - http://activityschema.org/collection/public - <% else %> - <%= unless Regex.match?(~r/^#{Pleroma.Web.Endpoint.url()}.+followers$/, id) do %> - <%= id %> - <% end %> - <% end %> - <% end %> - - <%= for {emoji, file} <- @data["emoji"] || %{} do %> - <%= file %> - <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex index 03c222975..ae762f9ed 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -6,7 +6,7 @@ <%= @data["id"] %> <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> - <%= activity_description(@data) %> + <%= escape(atom_content_encoded(@data)) %> <%= to_rfc3339(@data["published"]) %> <%= to_rfc3339(@data["published"]) %> @@ -15,34 +15,22 @@ <%= if @data["summary"] != "" do %> - <%= @data["summary"] %> + <%= escape(@data["summary"]) %> <% end %> - <%= if @activity.local do %> - - - <% else %> - - <% end %> - - <%= for id <- @activity.recipients do %> - <%= if id == Pleroma.Constants.as_public() do %> - - <% else %> - <%= unless Regex.match?(~r/^#{Pleroma.Web.Endpoint.url()}.+followers$/, id) do %> - - <% end %> - <% end %> - <% end %> + + <%= for tag <- Pleroma.Object.hashtags(@object) do %> <% end %> + <%= for attachment <- @data["attachment"] || [] do %> + <%= unless attachment_previewable?(attachment) do %> + + <% end %> + <% end %> + <%= for {emoji, file} <- @data["emoji"] || %{} do %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex index 1b8c34b87..6ae3c5a55 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex @@ -1,14 +1,22 @@ - <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> - - - <%= activity_context(@activity) %> - <%= activity_context(@activity) %> + <%= activity_link(@activity, @data) %> + <%= activity_link(@activity, @data) %> <%= to_rfc2822(@data["published"]) %> - + <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> <%= activity_description(@data) %> + ]]> + <%= for attachment <- @data["attachment"] || [] do %> - + <%= size = attachment_size_positive(attachment) %> + <%= unless attachment_previewable?(attachment) do %> + + <% end %> + <%= media_content_xml(attachment) %> <% end %> + <%= for tag <- Pleroma.Object.hashtags(@object) do %> + <%= if tag && tag != "" do %> + <%= tag %> + <% end %> + <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/tag.atom.eex b/lib/pleroma/web/templates/feed/feed/tag.atom.eex index 3449c97ff..1646aeec7 100644 --- a/lib/pleroma/web/templates/feed/feed/tag.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/tag.atom.eex @@ -9,10 +9,12 @@ <%= Routes.tag_feed_url(@conn, :feed, @tag) <> ".atom" %> #<%= @tag %> - <%= Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) %> + <%= with subtitle when subtitle not in [nil, ""] <- Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) do %> + <%= subtitle %> + <% end %> <%= feed_logo() %> <%= most_recent_update(@activities) %> - " type="application/atom+xml"/> + <%= for activity <- @activities do %> <%= render Phoenix.Controller.view_module(@conn), "_tag_activity.atom", Map.merge(assigns, prepare_activity(activity, actor: true)) %> diff --git a/lib/pleroma/web/templates/feed/feed/tag.rss.eex b/lib/pleroma/web/templates/feed/feed/tag.rss.eex index a87f9bf50..618b89ba3 100644 --- a/lib/pleroma/web/templates/feed/feed/tag.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/tag.rss.eex @@ -1,6 +1,8 @@ @@ -8,7 +10,10 @@ <%= Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) %> <%= "#{Routes.tag_feed_url(@conn, :feed, @tag)}.rss" %> <%= feed_logo() %> + <%= feed_logo() %> 2b90d9 + <%= most_recent_update(@activities) || to_rfc2822(NaiveDateTime.utc_now()) %> + <%= Pleroma.Application.named_version() %> <%= for activity <- @activities do %> <%= render Phoenix.Controller.view_module(@conn), "_tag_activity.xml", Map.merge(assigns, prepare_activity(activity)) %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/user.atom.eex b/lib/pleroma/web/templates/feed/feed/user.atom.eex index c2c77cfed..cc6883727 100644 --- a/lib/pleroma/web/templates/feed/feed/user.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/user.atom.eex @@ -8,10 +8,12 @@ <%= Routes.user_feed_url(@conn, :feed, @user.nickname) <> ".atom" %> <%= @user.nickname <> "'s timeline" %> - <%= escape(@user.bio) %> + <%= if @user.bio && @user.bio != "" do %> + <%= escape(@user.bio) %> + <% end %> <%= most_recent_update(@activities, @user, :atom) %> <%= logo(@user) %> - " type="application/atom+xml"/> + <%= render Phoenix.Controller.view_module(@conn), "_author.atom", assigns %> diff --git a/lib/pleroma/web/templates/feed/feed/user.rss.eex b/lib/pleroma/web/templates/feed/feed/user.rss.eex index b907a7e57..e2150d8b6 100644 --- a/lib/pleroma/web/templates/feed/feed/user.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/user.rss.eex @@ -1,26 +1,32 @@ <%= @user.nickname <> "'s timeline" %> - <%= "#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss" %> - " + + <%= "#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss" %> <%= escape(@user.bio) %> <%= logo(@user) %> <%= @user.nickname <> "'s timeline" %> <%= "#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss" %> + <%= most_recent_update(@activities, @user, :rss) %> + <%= logo(@user) %> + <%= Pleroma.Application.named_version() %> <%= render Phoenix.Controller.view_module(@conn), "_author.rss", assigns %> <%= if last_activity(@activities) do %> - <%= "#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss?max_id=#{last_activity(@activities).id}" %> + - <%= activity_link(@activity, @data) %> + <%= escape(activity_link(@activity, @data) || "") %> <%= for tag <- Pleroma.Object.hashtags(@object) do %> <%= if tag && tag != "" do %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex index 09945605c..a8796353f 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex @@ -1,6 +1,6 @@ - <%= activity_link(@activity, @data) %> - <%= activity_link(@activity, @data) %> + <%= escape(activity_link(@activity, @data) || "") %> + <%= escape(activity_link(@activity, @data) || "") %> <%= to_rfc2822(@data["published"]) %> <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> <%= activity_description(@data) %> diff --git a/test/pleroma/web/feed/tag_controller_test.exs b/test/pleroma/web/feed/tag_controller_test.exs index 9e8874455..eef524cc4 100644 --- a/test/pleroma/web/feed/tag_controller_test.exs +++ b/test/pleroma/web/feed/tag_controller_test.exs @@ -167,6 +167,8 @@ defmodule Pleroma.Web.Feed.TagControllerTest do ~c"yeah #PleromaArt" ] + assert xpath(xml, ~x"//channel/item/guid/@isPermaLink"sl) == ["true", "true"] + assert xpath(xml, ~x"//channel/item/pubDate/text()"sl) == [ FeedView.to_rfc2822(activity2.data["published"]), FeedView.to_rfc2822(activity1.data["published"]) @@ -232,6 +234,37 @@ defmodule Pleroma.Web.Feed.TagControllerTest do refute response =~ "" end + test "escapes and round-trips RSS item permalinks", %{conn: conn} do + user = insert(:user, local: false) + permalink = "https://remote.example/post?first=one&second=two" + + note = + insert(:note, + user: user, + object_local: false, + data: %{ + "content" => "remote #rsslink", + "external_url" => permalink, + "summary" => "", + "tag" => ["rsslink"] + } + ) + + insert(:note_activity, user: user, note: note, local: false, object_local: false) + + response = + conn + |> put_req_header("accept", "application/rss+xml") + |> get(tag_feed_path(conn, :feed, "rsslink.rss")) + |> response(200) + + assert response =~ "https://remote.example/post?first=one&second=two" + + xml = parse(response) + assert xpath(xml, ~x"//channel/item/guid/text()"s) == permalink + assert xpath(xml, ~x"//channel/item/link/text()"s) == permalink + end + describe "private instance" do setup do: clear_config([:instance, :public], false) From 2d667e1d2a2cfecea99cdcd660460882b2dfbf20 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 20:44:58 +0400 Subject: [PATCH 189/195] style(feed): format enclosure fixtures --- test/pleroma/web/feed/tag_controller_test.exs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/pleroma/web/feed/tag_controller_test.exs b/test/pleroma/web/feed/tag_controller_test.exs index eef524cc4..a23c25274 100644 --- a/test/pleroma/web/feed/tag_controller_test.exs +++ b/test/pleroma/web/feed/tag_controller_test.exs @@ -30,8 +30,7 @@ defmodule Pleroma.Web.Feed.TagControllerTest do %{ "url" => [ %{ - "href" => - "https://peertube.moe/video.mp4?first=one&second=two", + "href" => "https://peertube.moe/video.mp4?first=one&second=two", "mediaType" => "video/mp4", "size" => "123", "type" => "Link" @@ -115,8 +114,7 @@ defmodule Pleroma.Web.Feed.TagControllerTest do %{ "url" => [ %{ - "href" => - "https://peertube.moe/video.mp4?first=one&second=two", + "href" => "https://peertube.moe/video.mp4?first=one&second=two", "mediaType" => "video/mp4", "size" => "123", "type" => "Link" From 5f71532442c7ad45c34663a6b3cf08f081a35132 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 20:59:13 +0400 Subject: [PATCH 190/195] fix(feed): tighten enclosure metadata --- lib/pleroma/web/feed/feed_view.ex | 10 ++++++---- .../web/templates/feed/feed/_activity.atom.eex | 2 +- .../web/templates/feed/feed/_activity.rss.eex | 9 ++++----- .../web/templates/feed/feed/_tag_activity.atom.eex | 2 +- .../web/templates/feed/feed/_tag_activity.xml.eex | 9 ++++----- test/pleroma/web/feed/feed_view_test.exs | 13 +++++++++++++ test/pleroma/web/feed/user_controller_test.exs | 9 ++++++--- 7 files changed, 35 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index dcbb8404f..fc29db2b7 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -131,7 +131,7 @@ defmodule Pleroma.Web.Feed.FeedView do end attachments_html = - if data["sensitive"] do + if sensitive?(data) do "" else (data["attachment"] || []) @@ -181,6 +181,8 @@ defmodule Pleroma.Web.Feed.FeedView do def activity_context(activity), do: escape(activity.data["context"]) + def sensitive?(data), do: data["sensitive"] in [true, "true"] + def feed_self_url(conn) do conn |> Phoenix.Controller.current_url() @@ -237,15 +239,15 @@ defmodule Pleroma.Web.Feed.FeedView do end end - def attachment_previewable?(attachment) do - attachment_medium(attachment) in ["image", "video", "audio"] + def rss_enclosure_attachment(attachments) do + Enum.find(attachments || [], &(not is_nil(attachment_size_positive(&1)))) end def attachment_description(attachment) do attachment["name"] || "" end - def media_content_xml(_attachment, true), do: "" + def media_content_xml(_attachment, sensitive) when sensitive in [true, "true"], do: "" def media_content_xml(attachment, _sensitive) do href = escape(attachment_href(attachment) || "") diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index bc67e12b3..91ef947f7 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -21,7 +21,7 @@ <% end %> - <%= unless @data["sensitive"] do %> + <%= unless sensitive?(@data) do %> <%= for attachment <- @data["attachment"] || [] do %> <%= if size = attachment_size_positive(attachment) do %> " type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>" length="<%= size %>"/> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex index 393501dad..4d71a45c7 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -18,15 +18,14 @@ <% end %> <% end %> - <%= unless @data["sensitive"] do %> - <%= with [attachment | _] <- @data["attachment"] || [] do %> - <%= size = attachment_size_positive(attachment) %> - " type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>" length="<%= size || 0 %>" /> + <%= unless sensitive?(@data) do %> + <%= with attachment when not is_nil(attachment) <- rss_enclosure_attachment(@data["attachment"]) do %> + " type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>" length="<%= attachment_size_positive(attachment) %>" /> <% end %> <% end %> <%= for attachment <- @data["attachment"] || [] do %> - <%= media_content_xml(attachment, @data["sensitive"]) %> + <%= media_content_xml(attachment, sensitive?(@data)) %> <% end %> <%= if @data["inReplyTo"] do %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex index ce5d62de6..44c4806cf 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -24,7 +24,7 @@ <% end %> - <%= unless @data["sensitive"] do %> + <%= unless sensitive?(@data) do %> <%= for attachment <- @data["attachment"] || [] do %> <%= if size = attachment_size_positive(attachment) do %> " type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>" length="<%= size %>"/> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex index a8796353f..64e4c1d81 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex @@ -6,15 +6,14 @@ <%= activity_description(@data) %> ]]> - <%= unless @data["sensitive"] do %> - <%= with [attachment | _] <- @data["attachment"] || [] do %> - <%= size = attachment_size_positive(attachment) %> - " type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>" length="<%= size || 0 %>"/> + <%= unless sensitive?(@data) do %> + <%= with attachment when not is_nil(attachment) <- rss_enclosure_attachment(@data["attachment"]) do %> + " type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>" length="<%= attachment_size_positive(attachment) %>"/> <% end %> <% end %> <%= for attachment <- @data["attachment"] || [] do %> - <%= media_content_xml(attachment, @data["sensitive"]) %> + <%= media_content_xml(attachment, sensitive?(@data)) %> <% end %> <%= for tag <- Pleroma.Object.hashtags(@object) do %> diff --git a/test/pleroma/web/feed/feed_view_test.exs b/test/pleroma/web/feed/feed_view_test.exs index 45ed3c3ff..786b6df07 100644 --- a/test/pleroma/web/feed/feed_view_test.exs +++ b/test/pleroma/web/feed/feed_view_test.exs @@ -35,6 +35,7 @@ defmodule Pleroma.Web.Feed.FeedViewTest do assert FeedView.atom_content_encoded(data) == "content warning" assert FeedView.rss_content_encoded(data) == "content warning" assert FeedView.media_content_xml(attachment, true) == "" + assert FeedView.media_content_xml(attachment, "true") == "" media_content = FeedView.media_content_xml(attachment, false) assert media_content =~ "spoiler" @@ -43,4 +44,16 @@ defmodule Pleroma.Web.Feed.FeedViewTest do assert FeedView.media_content_xml(Map.delete(attachment, "name"), false) == ~s(\n) end + + test "does not treat a legacy false string as sensitive" do + data = %{ + "attachment" => [ + %{"url" => [%{"href" => "https://example.com/image.png", "mediaType" => "image/png"}]} + ], + "content" => "visible", + "sensitive" => "false" + } + + assert FeedView.atom_content_encoded(data) =~ " response(200) |> parse() - assert xpath(rss_xml, ~x"//item/enclosure/@url"sl) == ["https://example.com/file.bin"] - assert xpath(rss_xml, ~x"//item/enclosure/@type"sl) == ["application/octet-stream"] - assert xpath(rss_xml, ~x"//item/enclosure/@length"sl) == ["0"] + assert xpath(rss_xml, ~x"//item/enclosure/@url"sl) == [] + assert xpath(rss_xml, ~x"//item/media:content/@url"sl) == ["https://example.com/file.bin"] + + assert xpath(rss_xml, ~x"//item/media:content/@type"sl) == [ + "application/octet-stream" + ] end test "returns 404 for a missing feed", %{conn: conn} do From c5230d98c5bbb4ba70516f825fec4d1fb60d836d Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 21:04:35 +0400 Subject: [PATCH 191/195] fix(feed): complete Atom XML metadata --- lib/pleroma/web/feed/feed_view.ex | 2 +- .../templates/feed/feed/_activity.atom.eex | 2 +- .../feed/feed/_tag_activity.atom.eex | 2 +- .../web/templates/feed/feed/tag.atom.eex | 2 +- test/pleroma/web/feed/tag_controller_test.exs | 43 +++++++++++++++++++ .../pleroma/web/feed/user_controller_test.exs | 28 ++++++++++++ 6 files changed, 75 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index fc29db2b7..b409b1923 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -43,7 +43,7 @@ defmodule Pleroma.Web.Feed.FeedView do def most_recent_update(activities, :rss) do activities |> Enum.map(fn activity -> - case Object.normalize(activity, fetch: false) do + case activity.object do %Object{updated_at: updated_at} -> updated_at _ -> activity.updated_at end diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index 91ef947f7..f94ba0793 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -1,7 +1,7 @@ http://activitystrea.ms/schema/1.0/note http://activitystrea.ms/schema/1.0/post - <%= @data["id"] %> + <%= escape(@data["id"] || "") %> <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> <%= escape(atom_content_encoded(@data)) %> <%= to_rfc3339(@data["published"]) %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex index 44c4806cf..6c74c2ab7 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -4,7 +4,7 @@ <%= render Phoenix.Controller.view_module(@conn), "_tag_author.atom", assigns %> - <%= @data["id"] %> + <%= escape(@data["id"] || "") %> <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> <%= escape(atom_content_encoded(@data)) %> <%= to_rfc3339(@data["published"]) %> diff --git a/lib/pleroma/web/templates/feed/feed/tag.atom.eex b/lib/pleroma/web/templates/feed/feed/tag.atom.eex index 1646aeec7..fb04544d3 100644 --- a/lib/pleroma/web/templates/feed/feed/tag.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/tag.atom.eex @@ -13,7 +13,7 @@ <%= subtitle %> <% end %> <%= feed_logo() %> - <%= most_recent_update(@activities) %> + <%= most_recent_update(@activities) || to_rfc3339(NaiveDateTime.utc_now()) %> <%= for activity <- @activities do %> diff --git a/test/pleroma/web/feed/tag_controller_test.exs b/test/pleroma/web/feed/tag_controller_test.exs index a23c25274..35e29ed8c 100644 --- a/test/pleroma/web/feed/tag_controller_test.exs +++ b/test/pleroma/web/feed/tag_controller_test.exs @@ -232,6 +232,49 @@ defmodule Pleroma.Web.Feed.TagControllerTest do refute response =~ "" end + test "provides a valid updated timestamp for an empty Atom feed", %{conn: conn} do + response = + conn + |> put_req_header("accept", "application/atom+xml") + |> get(tag_feed_path(conn, :feed, "empty.atom")) + |> response(200) + + updated = response |> parse() |> xpath(~x"//feed/updated/text()"s) + assert {:ok, _datetime, 0} = DateTime.from_iso8601(updated) + end + + test "escapes Atom IDs and tag feed self links", %{conn: conn} do + user = insert(:user, local: false) + object_id = "https://remote.example/object?first=one&second=two" + + note = + insert(:note, + user: user, + object_local: false, + data: %{ + "content" => "remote #atomlink", + "external_url" => object_id, + "id" => object_id, + "summary" => "", + "tag" => ["atomlink"] + } + ) + + insert(:note_activity, user: user, note: note, local: false, object_local: false) + + response = + conn + |> put_req_header("accept", "application/atom+xml") + |> get("/tags/atomlink.atom?third=three&fourth=four") + |> response(200) + + assert response =~ "https://remote.example/object?first=one&second=two" + assert response =~ "third=three&fourth=four" + + xml = parse(response) + assert xpath(xml, ~x"//feed/entry/id/text()"s) == object_id + end + test "escapes and round-trips RSS item permalinks", %{conn: conn} do user = insert(:user, local: false) permalink = "https://remote.example/post?first=one&second=two" diff --git a/test/pleroma/web/feed/user_controller_test.exs b/test/pleroma/web/feed/user_controller_test.exs index b78ddb63a..458f5ec8d 100644 --- a/test/pleroma/web/feed/user_controller_test.exs +++ b/test/pleroma/web/feed/user_controller_test.exs @@ -150,6 +150,34 @@ defmodule Pleroma.Web.Feed.UserControllerTest do end end + test "keeps fallback summary markup inert in rendered Atom content", %{ + conn: conn, + object: object, + user: user + } do + data = + object.data + |> Map.put("content", "") + |> Map.put("summary", "") + + object + |> Ecto.Changeset.change(data: data) + |> Pleroma.Repo.update!() + + response = + conn + |> get("/users/#{user.nickname}/feed.atom") + |> response(200) + + assert response =~ "&lt;script&gt;alert(&#39;feed&#39;)&lt;/script&gt;" + + content_values = response |> parse() |> xpath(~x"//entry/content/text()"sl) + + assert Enum.any?(content_values, fn content -> + String.starts_with?(content, "<script>alert('feed')</script>") + end) + end + test "does not expose sensitive attachments as enclosures", %{ conn: conn, object: object, From 5d8b5a73d2d23dac3b79ff74431bca1b02a7fe70 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2026 21:05:16 +0400 Subject: [PATCH 192/195] style(feed): format Atom fallback assertion --- test/pleroma/web/feed/user_controller_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/pleroma/web/feed/user_controller_test.exs b/test/pleroma/web/feed/user_controller_test.exs index 458f5ec8d..d12904ec0 100644 --- a/test/pleroma/web/feed/user_controller_test.exs +++ b/test/pleroma/web/feed/user_controller_test.exs @@ -169,7 +169,8 @@ defmodule Pleroma.Web.Feed.UserControllerTest do |> get("/users/#{user.nickname}/feed.atom") |> response(200) - assert response =~ "&lt;script&gt;alert(&#39;feed&#39;)&lt;/script&gt;" + assert response =~ + "&lt;script&gt;alert(&#39;feed&#39;)&lt;/script&gt;" content_values = response |> parse() |> xpath(~x"//entry/content/text()"sl) From d5f48101b806f973ded8f5cf3276538b269ec3b4 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sat, 25 Jul 2026 00:06:03 +0400 Subject: [PATCH 193/195] fix(media-proxy): safely detect extensionless images --- changelog.d/mediaproxy-content-type.fix | 1 + lib/pleroma/reverse_proxy.ex | 138 +++++++++-- .../attachment_type_sniffer.ex | 59 ----- .../object_validators/attachment_validator.ex | 21 +- .../web/mastodon_api/views/status_view.ex | 1 + .../web/media_proxy/media_proxy_controller.ex | 1 + test/pleroma/reverse_proxy_test.exs | 178 ++++++-------- .../attachment_type_sniffer_test.exs | 178 -------------- .../attachment_validator_test.exs | 221 ------------------ .../mastodon_api/views/status_view_test.exs | 17 ++ .../media_proxy_controller_test.exs | 5 +- 11 files changed, 211 insertions(+), 609 deletions(-) create mode 100644 changelog.d/mediaproxy-content-type.fix delete mode 100644 lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex delete mode 100644 test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs diff --git a/changelog.d/mediaproxy-content-type.fix b/changelog.d/mediaproxy-content-type.fix new file mode 100644 index 000000000..f36dd6283 --- /dev/null +++ b/changelog.d/mediaproxy-content-type.fix @@ -0,0 +1 @@ +Detect image content types for extensionless MediaProxy responses. diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index 11f041c57..061b2de9b 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -17,6 +17,7 @@ defmodule Pleroma.ReverseProxy do @max_read_duration :timer.seconds(30) @max_body_length :infinity @failed_request_ttl :timer.seconds(60) + @sniff_bytes 8 * 1024 @methods ~w(GET HEAD) @allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], []) @@ -63,6 +64,9 @@ defmodule Pleroma.ReverseProxy do is always set (overriding any upstream header) so the media can be embedded in pages; the filename is derived from the content type + * `sniff_content_type` (default `false`): detects an image MIME type from the first + response chunk when the upstream type is missing or `application/octet-stream`. + * `req_headers`, `resp_headers` additional headers. * `http`: options for [hackney](https://github.com/benoitc/hackney) or [gun](https://github.com/ninenines/gun). @@ -71,11 +75,15 @@ defmodule Pleroma.ReverseProxy do @default_options [pool: :media] @inline_content_types [ + "image/apng", + "image/avif", + "image/bmp", "image/gif", "image/jpeg", "image/jpg", "image/png", "image/svg+xml", + "image/webp", "audio/mpeg", "audio/mp3", "video/webm", @@ -94,6 +102,7 @@ defmodule Pleroma.ReverseProxy do | {:req_headers, [{String.t(), String.t()}]} | {:resp_headers, [{String.t(), String.t()}]} | {:inline_content_types, boolean() | list(String.t())} + | {:sniff_content_type, boolean()} | {:redirect_on_failure, boolean()} @spec call(Plug.Conn.t(), String.t(), list(option())) :: Plug.Conn.t() @@ -186,12 +195,14 @@ defmodule Pleroma.ReverseProxy do defp response(conn, client, url, status, headers, opts) do Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}") + {headers, client, prefetched} = maybe_prefetch_for_content_type(headers, client, opts) + result = conn |> put_resp_headers(build_resp_headers(headers, opts)) |> streaming_compat |> send_chunked(status) - |> chunk_reply(client, opts) + |> chunk_reply(client, opts, prefetched) case result do {:ok, conn} -> @@ -211,18 +222,28 @@ defmodule Pleroma.ReverseProxy do end end - defp chunk_reply(conn, client, opts) do + defp chunk_reply(conn, client, opts, :none) do chunk_reply(conn, client, opts, 0, 0) end - defp chunk_reply(conn, client, opts, sent_so_far, duration) do - with {:ok, duration} <- - check_read_duration( - duration, - Keyword.get(opts, :max_read_duration, @max_read_duration) + defp chunk_reply(conn, _client, _opts, :done), do: {:ok, conn} + defp chunk_reply(conn, _client, _opts, {:error, error}), do: {:error, error, conn} + + defp chunk_reply(conn, client, opts, {:ok, data, duration}) do + with :ok <- + body_size_constraint( + byte_size(data), + Keyword.get(opts, :max_body_length, @max_body_length) ), - {:ok, data, client} <- client().stream_body(client), - {:ok, duration} <- increase_read_duration(duration), + {:ok, conn} <- chunk(conn, data) do + chunk_reply(conn, client, opts, byte_size(data), duration) + else + {:error, error} -> {:error, error, conn} + end + end + + defp chunk_reply(conn, client, opts, sent_so_far, duration) do + with {:ok, data, client, duration} <- read_chunk(client, duration, opts), sent_so_far = sent_so_far + byte_size(data), :ok <- body_size_constraint( @@ -237,6 +258,66 @@ defmodule Pleroma.ReverseProxy do end end + defp read_chunk(client, duration, opts) do + with {:ok, timer} <- + check_read_duration( + duration, + Keyword.get(opts, :max_read_duration, @max_read_duration) + ), + {:ok, data, client} <- client().stream_body(client), + {:ok, duration} <- increase_read_duration(timer) do + {:ok, data, client, duration} + end + end + + defp maybe_prefetch_for_content_type(headers, client, opts) do + if Keyword.get(opts, :sniff_content_type, false) and generic_content_type?(headers) do + case read_chunk(client, 0, opts) do + {:ok, data, client, duration} -> + {maybe_put_image_content_type(headers, data), client, {:ok, data, duration}} + + :done -> + {headers, client, :done} + + {:error, error} -> + {headers, client, {:error, error}} + end + else + {headers, client, :none} + end + end + + defp generic_content_type?(headers) do + headers + |> get_content_type() + |> String.trim() + |> String.downcase() + |> then(&(&1 in ["", "application/octet-stream"])) + end + + defp maybe_put_image_content_type(headers, data) do + with false <- data == "", + {:ok, %{mime_type: "image/" <> _ = content_type}} <- + Majic.perform({:bytes, binary_part(data, 0, min(byte_size(data), @sniff_bytes))}, + pool: Pleroma.MajicPool + ) do + [ + {"content-type", content_type} + | Enum.reject(headers, fn {key, _} -> key == "content-type" end) + ] + else + _ -> headers + end + rescue + error -> + Logger.debug("#{__MODULE__}: content-type sniffing failed: #{Exception.message(error)}") + headers + catch + kind, reason -> + Logger.debug("#{__MODULE__}: content-type sniffing failed: #{kind}: #{inspect(reason)}") + headers + end + defp head_response(conn, url, code, headers, opts) do Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}") @@ -283,17 +364,34 @@ defmodule Pleroma.ReverseProxy do |> Enum.filter(fn {k, _} -> k in @keep_req_headers end) |> build_req_range_or_encoding_header(opts) |> build_req_user_agent_header(opts) - |> Keyword.merge(Keyword.get(opts, :req_headers, [])) + |> merge_headers(Keyword.get(opts, :req_headers, [])) + |> maybe_force_identity_encoding(opts) + end + + defp merge_headers(headers, extra_headers) do + Enum.reduce(extra_headers, headers, fn {key, value}, headers -> + List.keystore(headers, String.downcase(key), 0, {String.downcase(key), value}) + end) + end + + defp maybe_force_identity_encoding(headers, opts) do + if Keyword.get(opts, :sniff_content_type, false) do + List.keystore(headers, "accept-encoding", 0, {"accept-encoding", "identity"}) + else + headers + end end # Disable content-encoding if any @range_headers are requested (see #1823). defp build_req_range_or_encoding_header(headers, _opts) do range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end) - if range? && List.keymember?(headers, "accept-encoding", 0) do - List.keydelete(headers, "accept-encoding", 0) - else - headers + cond do + range? && List.keymember?(headers, "accept-encoding", 0) -> + List.keydelete(headers, "accept-encoding", 0) + + true -> + headers end end @@ -386,7 +484,7 @@ defmodule Pleroma.ReverseProxy do disposition = "attachment; filename=\"#{name}\"" - List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) + replace_header(headers, "content-disposition", disposition) else if opt == true do headers @@ -400,11 +498,15 @@ defmodule Pleroma.ReverseProxy do "inline" end - List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) + replace_header(headers, "content-disposition", disposition) end end end + defp replace_header(headers, key, value) do + [{key, value} | Enum.reject(headers, fn {header, _} -> header == key end)] + end + defp inline_filename(content_type) do case MIME.extensions(content_type) do [ext | _] when ext != "" -> "inline.#{ext}" @@ -443,7 +545,9 @@ defmodule Pleroma.ReverseProxy do end end - defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit} + defp check_read_duration(_, _), do: {:ok, :no_duration_limit} + + defp increase_read_duration(:no_duration_limit), do: {:ok, :no_duration_limit} defp increase_read_duration({previous_duration, started}) when is_integer(previous_duration) and is_integer(started) do diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex deleted file mode 100644 index 19215b627..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_type_sniffer.ex +++ /dev/null @@ -1,59 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer do - @moduledoc """ - Best-effort MIME sniffing for remote attachments whose declared type is - unusable (missing or `application/octet-stream`). - - Fetches the first chunk of the URL and runs it through libmagic. Only - `image/*` results are returned; anything else, or any failure, yields `nil` — - so this never reclassifies non-image media and never blocks ingestion. This - exists because some remotes (e.g. extensionless Cloudflare Images URLs) ship - attachments with no usable `mediaType`, which would otherwise render in the - timeline as a clickable link instead of an inline image. - """ - - alias Pleroma.HTTP - - require Logger - - # libmagic only needs the file header; a few KB is plenty and keeps the - # request cheap even when the origin ignores Range. - @sniff_bytes 8 * 1024 - @timeout 5_000 - - @spec sniff_image_type(binary() | nil) :: {:ok, binary() | nil} - def sniff_image_type(url) when is_binary(url) and url != "" do - do_sniff(url) - rescue - e -> - Logger.debug( - "Attachment type sniff failed for #{url}: #{Exception.format(:error, e, __STACKTRACE__)}" - ) - - {:ok, nil} - end - - def sniff_image_type(_), do: {:ok, nil} - - defp do_sniff(url) do - headers = [{"range", "bytes=0-#{@sniff_bytes - 1}"}] - opts = [pool: :media, timeout: @timeout, recv_timeout: @timeout] - - with {:ok, %Tesla.Env{status: status, body: body}} when status in 200..299 <- - HTTP.get(url, headers, opts), - false <- blank?(body), - {:ok, %{mime_type: mime}} <- Majic.perform({:bytes, body}, pool: Pleroma.MajicPool), - true <- String.starts_with?(mime, "image/") do - {:ok, mime} - else - _ -> {:ok, nil} - end - end - - defp blank?(nil), do: true - defp blank?(""), do: true - defp blank?(_), do: false -end diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index b6e30a438..5ee9e7549 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators - alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer import Ecto.Changeset @@ -62,27 +61,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do end def fix_media_type(data) do - current = data["mediaType"] || data["mimeType"] || "application/octet-stream" - - resolved = - if sniffable?(current) do - case AttachmentTypeSniffer.sniff_image_type(data["href"]) do - {:ok, mime} when is_binary(mime) -> mime - _ -> current - end - else - current - end - - Map.put(data, "mediaType", resolved) + Map.put_new(data, "mediaType", data["mimeType"] || "application/octet-stream") end - # Only sniff when the declared type carries no real information. Everything - # else (including unrecognized-but-present types) is left as the remote sent it. - defp sniffable?("application/octet-stream"), do: true - defp sniffable?(""), do: true - defp sniffable?(_), do: false - defp handle_href(href, mediaType, data) do [ %{ diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 6949c5f2d..725dc2ce7 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -620,6 +620,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do String.contains?(media_type, "image") -> "image" String.contains?(media_type, "video") -> "video" String.contains?(media_type, "audio") -> "audio" + attachment["type"] == "Image" -> "image" true -> "unknown" end diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index a0aafc32e..93f692008 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -208,6 +208,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do defp media_proxy_opts do Config.get([:media_proxy, :proxy_opts], []) + |> Keyword.put_new(:sniff_content_type, true) end defp sandbox(conn, _params) do diff --git a/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs index 5f75f2a07..ad1d75f33 100644 --- a/test/pleroma/reverse_proxy_test.exs +++ b/test/pleroma/reverse_proxy_test.exs @@ -222,6 +222,56 @@ defmodule Pleroma.ReverseProxyTest do assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"] end + test "sniffs and preserves an image body with a generic content type", %{conn: conn} do + body = File.read!("test/fixtures/image.jpg") + + ClientMock + |> expect(:request, fn :get, "/extensionless", headers, _, _ -> + assert {"accept-encoding", "identity"} in headers + {:ok, 200, [{"content-type", "application/octet-stream"}], %{body: body}} + end) + |> expect(:stream_body, fn %{body: ^body} = client -> + {:ok, body, Map.delete(client, :body)} + end) + |> expect(:stream_body, fn %{} -> :done end) + + conn = + ReverseProxy.call(conn, "/extensionless", + sniff_content_type: true, + max_read_duration: :infinity, + req_headers: [{"accept-encoding", "gzip"}] + ) + + assert conn.resp_body == body + assert Conn.get_resp_header(conn, "content-type") == ["image/jpeg"] + + assert Conn.get_resp_header(conn, "content-disposition") == [ + "inline; filename=\"inline.jpg\"" + ] + end + + test "preserves a generic content type for a non-image body", %{conn: conn} do + body = "not an image" + + ClientMock + |> expect(:request, fn :get, "/extensionless", _, _, _ -> + {:ok, 200, [], %{body: body}} + end) + |> expect(:stream_body, fn %{body: ^body} = client -> + {:ok, body, Map.delete(client, :body)} + end) + |> expect(:stream_body, fn %{} -> :done end) + + conn = ReverseProxy.call(conn, "/extensionless", sniff_content_type: true) + + assert conn.resp_body == body + assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"] + + assert Conn.get_resp_header(conn, "content-disposition") == [ + "attachment; filename=\"extensionless\"" + ] + end + defp headers_mock(_) do ClientMock |> expect(:request, fn :get, "/headers", headers, _, _ -> @@ -298,21 +348,21 @@ defmodule Pleroma.ReverseProxyTest do end end - defp disposition_headers_mock(headers, url \\ "/disposition") do + defp disposition_headers_mock(headers) do ClientMock - |> expect(:request, fn :get, ^url, _, _, _ -> - Registry.register(ClientMock, url, 0) + |> expect(:request, fn :get, "/disposition", _, _, _ -> + Registry.register(ClientMock, "/disposition", 0) - {:ok, 200, headers, %{url: url}} + {:ok, 200, headers, %{url: "/disposition"}} end) - |> expect(:stream_body, 2, fn %{url: ^url} = client -> - case Registry.lookup(ClientMock, url) do + |> expect(:stream_body, 2, fn %{url: "/disposition"} = client -> + case Registry.lookup(ClientMock, "/disposition") do [{_, 0}] -> - Registry.update_value(ClientMock, url, &(&1 + 1)) + Registry.update_value(ClientMock, "/disposition", &(&1 + 1)) {:ok, "", client} [{_, 1}] -> - Registry.unregister(ClientMock, url) + Registry.unregister(ClientMock, "/disposition") :done end end) @@ -337,6 +387,7 @@ defmodule Pleroma.ReverseProxyTest do disposition_headers_mock([ {"content-type", "image/png"}, {"content-disposition", "attachment; filename=\"filename.png\""}, + {"content-disposition", "attachment; filename=\"duplicate.png\""}, {"content-length", "0"} ]) @@ -347,23 +398,6 @@ defmodule Pleroma.ReverseProxyTest do refute String.starts_with?(disposition, "attachment") end - test "forces inline based on content type even without a file extension in the url", %{ - conn: conn - } do - disposition_headers_mock( - [ - {"content-type", "image/jpeg"}, - {"content-length", "0"} - ], - "/original" - ) - - conn = ReverseProxy.call(conn, "/original") - - [disposition] = Conn.get_resp_header(conn, "content-disposition") - assert disposition == "inline; filename=\"inline.jpg\"" - end - test "with content-disposition header", %{conn: conn} do disposition_headers_mock([ {"content-disposition", "attachment; filename=\"filename.jpg\""}, @@ -390,69 +424,6 @@ defmodule Pleroma.ReverseProxyTest do assert {"content-disposition", "attachment; filename=\"upstream.png\""} in conn.resp_headers end - test "with inline_content_types: true does not synthesise inline when upstream is absent", %{ - conn: conn - } do - disposition_headers_mock([ - {"content-type", "image/png"}, - {"content-length", "0"} - ]) - - conn = ReverseProxy.call(conn, "/disposition", inline_content_types: true) - - assert Conn.get_resp_header(conn, "content-disposition") == [] - end - - test "with inline_content_types: false forces attachment for everything", %{ - conn: conn - } do - disposition_headers_mock([ - {"content-type", "image/png"}, - {"content-disposition", "inline; filename=\"pic.png\""}, - {"content-length", "0"} - ]) - - conn = ReverseProxy.call(conn, "/disposition", inline_content_types: false) - - [disposition] = Conn.get_resp_header(conn, "content-disposition") - assert String.starts_with?(disposition, "attachment") - end - - test "with inline_content_types: false derives attachment filename from the URL basename when no upstream filename", %{ - conn: conn - } do - # No content-disposition header: the attachment branch falls back to - # attachment_name, which by default comes from MediaProxy.filename/1 - # (the URL basename). - disposition_headers_mock([ - {"content-type", "image/png"}, - {"content-length", "0"} - ]) - - conn = ReverseProxy.call(conn, "/disposition", inline_content_types: false) - - [disposition] = Conn.get_resp_header(conn, "content-disposition") - assert disposition == "attachment; filename=\"disposition\"" - end - - test "with inline_content_types: false honours an explicit attachment_name opt", %{ - conn: conn - } do - disposition_headers_mock([ - {"content-type", "image/png"}, - {"content-length", "0"} - ]) - - conn = - ReverseProxy.call(conn, "/disposition", - inline_content_types: false, - attachment_name: "custom.bin" - ) - - [disposition] = Conn.get_resp_header(conn, "content-disposition") - assert disposition == "attachment; filename=\"custom.bin\"" - end - test "forces bare inline for a whitelisted type with no MIME extension", %{ conn: conn } do @@ -469,36 +440,17 @@ defmodule Pleroma.ReverseProxyTest do assert disposition == "inline" end - test "honours a custom inline_content_types whitelist", %{conn: conn} do - # image/bmp is NOT in the default whitelist; with a custom whitelist - # that includes it, the proxy should force inline. + test "serves modern browser image types inline", %{conn: conn} do disposition_headers_mock([ - {"content-type", "image/bmp"}, - {"content-disposition", "attachment; filename=\"upstream.bmp\""}, + {"content-type", "image/webp"}, {"content-length", "0"} ]) - conn = - ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"]) + conn = ReverseProxy.call(conn, "/disposition") - [disposition] = Conn.get_resp_header(conn, "content-disposition") - assert String.starts_with?(disposition, "inline") - assert String.ends_with?(disposition, "inline.bmp\"") - end - - test "treats a type outside the whitelist as attachment even on custom whitelist", %{ - conn: conn - } do - disposition_headers_mock([ - {"content-type", "image/png"}, - {"content-disposition", "attachment; filename=\"filename.png\""}, - {"content-length", "0"} - ]) - - conn = - ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"]) - - assert {"content-disposition", "attachment; filename=\"filename.png\""} in conn.resp_headers + assert Conn.get_resp_header(conn, "content-disposition") == [ + "inline; filename=\"inline.webp\"" + ] end end diff --git a/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs b/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs deleted file mode 100644 index 52b1b67a1..000000000 --- a/test/pleroma/web/activity_pub/object_validators/attachment_type_sniffer_test.exs +++ /dev/null @@ -1,178 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSnifferTest do - use Pleroma.DataCase, async: true - - alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer - - # @sniff_bytes must mirror the value in the module under test; we assert the - # Range header that the sniffer sends, so the test should fail loudly if the - # constant drifts. - @sniff_bytes 8 * 1024 - @range_header "bytes=0-#{@sniff_bytes - 1}" - - describe "sniff_image_type/1 with a real image body" do - test "returns image/jpeg for a JPEG body" do - url = "https://example.com/media/image.jpg" - jpeg = File.read!("test/fixtures/image.jpg") - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: jpeg} - end) - - assert {:ok, "image/jpeg"} = AttachmentTypeSniffer.sniff_image_type(url) - end - - test "returns image/png for a PNG body" do - url = "https://example.com/media/image.png" - png = File.read!("test/fixtures/image.png") - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: png} - end) - - assert {:ok, "image/png"} = AttachmentTypeSniffer.sniff_image_type(url) - end - - test "returns image/gif for a GIF body" do - url = "https://example.com/media/image.gif" - gif = File.read!("test/fixtures/image.gif") - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: gif} - end) - - assert {:ok, "image/gif"} = AttachmentTypeSniffer.sniff_image_type(url) - end - end - - describe "sniff_image_type/1 sends a ranged request" do - test "sends a Range header asking for the first chunk" do - url = "https://example.com/media/ranged" - jpeg = File.read!("test/fixtures/image.jpg") - - Tesla.Mock.mock(fn - %{method: :get, url: ^url, headers: headers} -> - headers_map = Enum.into(headers, %{}) - assert headers_map["range"] == @range_header - - %Tesla.Env{status: 200, body: jpeg} - - %{method: :get, url: ^url} -> - # If the sniffer did not send the Range header, fail explicitly. - flunk("expected Range header to be sent with the request") - end) - - assert {:ok, "image/jpeg"} = AttachmentTypeSniffer.sniff_image_type(url) - end - end - - describe "sniff_image_type/1 with a non-image body" do - test "returns nil for plain text" do - url = "https://example.com/media/note.txt" - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: "just some plain text, not an image at all"} - end) - - assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) - end - - test "returns nil for an HTML body" do - url = "https://example.com/page" - html = File.read!("test/fixtures/rel_me_anchor.html") - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: html} - end) - - assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) - end - - test "returns nil for an empty body" do - url = "https://example.com/media/empty" - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: ""} - end) - - assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) - end - - test "returns nil for a nil body" do - url = "https://example.com/media/nil-body" - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: nil} - end) - - assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) - end - end - - describe "sniff_image_type/1 on HTTP failure" do - test "returns nil on a 404" do - url = "https://example.com/media/missing" - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 404, body: ""} - end) - - assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) - end - - test "returns nil on a 500" do - url = "https://example.com/media/oops" - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 500, body: "server error"} - end) - - assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) - end - - test "returns nil on a 3xx (not in 200..299)" do - url = "https://example.com/media/redirect" - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 302, body: ""} - end) - - assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) - end - - test "returns nil on a transport-level error" do - url = "https://example.com/media/econnrefused" - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - {:error, :econnrefused} - end) - - assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) - end - - test "returns nil when the HTTP adapter raises" do - url = "https://example.com/media/raises" - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - raise "boom" - end) - - assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url) - end - end - - describe "sniff_image_type/1 guard clauses" do - test "returns nil for nil input without making a request" do - # No Tesla mock is configured; if a request were made, Tesla.Mock would - # raise and fail the test. - assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(nil) - end - - test "returns nil for an empty-string URL without making a request" do - assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type("") - end - end -end diff --git a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs index 870ad90cc..744ae8704 100644 --- a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs @@ -215,226 +215,5 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidatorTest do AttachmentValidator.cast_and_validate(attachment) |> Ecto.Changeset.apply_action(:insert) end - - test "sniffs image/jpeg for octet-stream attachments whose body is an image" do - url = "https://example.com/media/no-extension/original" - jpeg = File.read!("test/fixtures/image.jpg") - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: jpeg} - end) - - attachment = %{ - "type" => "Document", - "mediaType" => "application/octet-stream", - "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}] - } - - {:ok, attachment} = - attachment - |> AttachmentValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) - - assert [%{href: ^url, mediaType: "image/jpeg"}] = attachment.url - end - - test "sniffs image/jpeg for attachments with a missing mediaType" do - url = "https://example.com/media/missing-type/original" - jpeg = File.read!("test/fixtures/image.jpg") - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: jpeg} - end) - - attachment = %{ - "type" => "Document", - "url" => [%{"type" => "Link", "href" => url}] - } - - {:ok, attachment} = - attachment - |> AttachmentValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) - - assert [%{href: ^url, mediaType: "image/jpeg"}] = attachment.url - end - - test "leaves non-image octet-stream attachments as octet-stream" do - url = "https://example.com/media/some-document" - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: "just some plain text, not an image at all"} - end) - - attachment = %{ - "type" => "Document", - "mediaType" => "application/octet-stream", - "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}] - } - - {:ok, attachment} = - attachment - |> AttachmentValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) - - assert [%{mediaType: "application/octet-stream"}] = attachment.url - end - - test "does not sniff when the remote already provided a real mediaType" do - # No Tesla mock is set up: if the validator tried to fetch, Tesla.Mock - # would raise and fail the test. A real image type must be kept as-is. - attachment = %{ - "type" => "Document", - "mediaType" => "image/png", - "url" => [ - %{"type" => "Link", "href" => "https://example.com/x", "mediaType" => "image/png"} - ] - } - - {:ok, attachment} = - attachment - |> AttachmentValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) - - assert [%{mediaType: "image/png"}] = attachment.url - end - end - - describe "fix_media_type fallbacks and sniffing" do - test "uses mimeType as a fallback when mediaType is absent (real type, not sniffed)" do - # No Tesla mock: if the validator tried to fetch, the test would fail. - # A real image type coming from mimeType must be preserved as-is on the - # outer attachment and on the synthesised url entry (string url form). - attachment = %{ - "type" => "Document", - "mimeType" => "image/png", - "url" => "https://example.com/x.png" - } - - {:ok, attachment} = - attachment - |> AttachmentValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) - - assert attachment.mediaType == "image/png" - assert [%{mediaType: "image/png"}] = attachment.url - end - - test "prefers mediaType over mimeType when both are present" do - attachment = %{ - "type" => "Document", - "mediaType" => "image/gif", - "mimeType" => "image/png", - "url" => "https://example.com/x.gif" - } - - {:ok, attachment} = - attachment - |> AttachmentValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) - - assert attachment.mediaType == "image/gif" - assert [%{mediaType: "image/gif"}] = attachment.url - end - - test "sniffs when the url entry's mediaType is application/octet-stream and the body is an image" do - # The outer attachment mediaType is not sniffed (no top-level href); only - # the url entry is. We assert only on the url entry. - url = "https://example.com/media/mime-type-octet" - jpeg = File.read!("test/fixtures/image.jpg") - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: jpeg} - end) - - attachment = %{ - "type" => "Document", - "mediaType" => "application/octet-stream", - "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}] - } - - {:ok, attachment} = - attachment - |> AttachmentValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) - - assert [%{mediaType: "image/jpeg"}] = attachment.url - end - - test "sniffs when mediaType is the empty string and the body is an image" do - url = "https://example.com/media/empty-mediatype" - png = File.read!("test/fixtures/image.png") - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 200, body: png} - end) - - attachment = %{ - "type" => "Document", - "mediaType" => "", - "url" => [%{"type" => "Link", "href" => url, "mediaType" => ""}] - } - - {:ok, attachment} = - attachment - |> AttachmentValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) - - assert [%{mediaType: "image/png"}] = attachment.url - end - - test "falls back to the declared type when the sniffer HTTP request fails" do - url = "https://example.com/media/sniff-404" - - Tesla.Mock.mock(fn %{method: :get, url: ^url} -> - %Tesla.Env{status: 404, body: ""} - end) - - attachment = %{ - "type" => "Document", - "mediaType" => "application/octet-stream", - "url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}] - } - - {:ok, attachment} = - attachment - |> AttachmentValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) - - assert [%{mediaType: "application/octet-stream"}] = attachment.url - end - - test "sniffs each url entry independently" do - jpeg_url = "https://example.com/media/a" - octet_url = "https://example.com/media/b" - jpeg = File.read!("test/fixtures/image.jpg") - - Tesla.Mock.mock(fn - %{method: :get, url: ^jpeg_url} -> - %Tesla.Env{status: 200, body: jpeg} - - %{method: :get, url: ^octet_url} -> - %Tesla.Env{status: 200, body: "definitely not an image"} - end) - - attachment = %{ - "type" => "Document", - "mediaType" => "application/octet-stream", - "url" => [ - %{"type" => "Link", "href" => jpeg_url, "mediaType" => "application/octet-stream"}, - %{"type" => "Link", "href" => octet_url, "mediaType" => "application/octet-stream"} - ] - } - - {:ok, attachment} = - attachment - |> AttachmentValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) - - assert [ - %{href: ^jpeg_url, mediaType: "image/jpeg"}, - %{href: ^octet_url, mediaType: "application/octet-stream"} - ] = attachment.url - end end end diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index 76123cd0f..faeca050e 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -668,6 +668,23 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do assert expected == StatusView.render("attachment.json", %{attachment: object}) assert_schema(expected, "Attachment", api_spec) end + + test "uses the ActivityStreams image type when the MIME type is generic" do + object = %{ + "type" => "Image", + "url" => [ + %{ + "mediaType" => "application/octet-stream", + "href" => "https://example.com/extensionless" + } + ] + } + + attachment = StatusView.render("attachment.json", %{attachment: object}) + + assert attachment.type == "image" + assert attachment.pleroma.mime_type == "application/octet-stream" + end end test "put the url advertised in the Activity in to the url attribute" do diff --git a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs index f7e52483c..800159c21 100644 --- a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs +++ b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs @@ -71,7 +71,10 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do test "it performs ReverseProxy.call with valid signature", %{conn: conn, url: url} do with_mock Pleroma.ReverseProxy, - call: fn _conn, _url, _opts -> %Conn{status: :success} end do + call: fn _conn, _url, opts -> + assert opts[:sniff_content_type] + %Conn{status: :success} + end do assert %Conn{status: :success} = get(conn, url) end end From 5ddd337045f6999ad884210055ed4492b63181a7 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sat, 25 Jul 2026 09:26:26 +0400 Subject: [PATCH 194/195] chore: remove development log --- DEVLOG.md | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 DEVLOG.md diff --git a/DEVLOG.md b/DEVLOG.md deleted file mode 100644 index 1e5d2b63d..000000000 --- a/DEVLOG.md +++ /dev/null @@ -1,14 +0,0 @@ -# Development Log - -## Gun pool validation - -- The previous unique origin registry forced concurrent HTTP/1 requests onto one connection. -- Pool identity omitted proxy routes and credentials, allowing incompatible routes to reuse a connection. -- Gun 2.2 CONNECT requests require their tunnel stream reference on subsequent requests; Tesla's bundled Gun adapter does not preserve it. -- CONNECT and SOCKS5 setup returned before the destination tunnel was ready and did not pass authentication credentials. -- The existing global `max_connections` limit and idle reclaimer bound HTTP/1 sibling and many-host connection growth. -- SOCKS4 is not supported by Gun 2.2 and is now rejected explicitly by the Gun adapter. -- Gun reports a stale stream error to the process that calls `cancel/2`; pool workers must consume that reply so one HTTP/2 cancellation cannot terminate other multiplexed requests. -- Streamed uploads must be registered before body enumeration and cancelled if enumeration exits, throws, or raises, otherwise a partial HTTP/1 request can remain reusable. -- Gun 2.4 bounds HTTP/1 response header and trailer buffers, rejects invalid request headers by default, and fixes cross-origin HTTP/2 push handling; Gun 2.4.1 includes Cowlib 2.17.1 to repair HTTP/2 HPACK decoding. -- Plain HTTP proxy requests must use absolute-form request targets; CONNECT is reserved for HTTPS tunnels because forwarding proxies commonly reject CONNECT to port 80. From 99befcaf1bf2326d2196e5640a8834009a720290 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sat, 25 Jul 2026 10:51:09 +0400 Subject: [PATCH 195/195] fix(activitypub): deduplicate account favourites --- lib/pleroma/web/activity_pub/activity_pub.ex | 16 +++++++ ...00_add_activity_favorites_lookup_index.exs | 20 ++++++++ .../controllers/account_controller_test.exs | 46 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 priv/repo/migrations/20260725000000_add_activity_favorites_lookup_index.exs diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index a44c5b1ff..10d3fe063 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1025,6 +1025,20 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_assigned_account(query, _), do: query defp restrict_favorited_by(query, %{favorited_by: ap_id}) do + newer_favorite = + from(newer_favorite in Activity, + where: newer_favorite.actor == ^ap_id, + where: newer_favorite.id > parent_as(:favorited_activity).id, + where: fragment("?->>'type' = ?", newer_favorite.data, "Like"), + where: + fragment( + "associated_object_id(?) = associated_object_id(?)", + newer_favorite.data, + parent_as(:favorited_activity).data + ), + select: 1 + ) + from( [activity, object: object] in query, join: favorited_activity in Activity, @@ -1033,6 +1047,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do favorited_activity.actor == ^ap_id and fragment("?->>'type' = ?", favorited_activity.data, "Like") and fragment("associated_object_id(?) = (?)->>'id'", favorited_activity.data, object.data), + where: fragment("(?)->'likes' \\? (?)", object.data, ^ap_id), + where: not exists(subquery(newer_favorite)), select: %Activity{activity | pagination_id: favorited_activity.id} ) end diff --git a/priv/repo/migrations/20260725000000_add_activity_favorites_lookup_index.exs b/priv/repo/migrations/20260725000000_add_activity_favorites_lookup_index.exs new file mode 100644 index 000000000..34bc04555 --- /dev/null +++ b/priv/repo/migrations/20260725000000_add_activity_favorites_lookup_index.exs @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.AddActivityFavoritesLookupIndex do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def change do + create_if_not_exists( + index(:activities, [:actor, "associated_object_id(data)", "id DESC"], + name: :activities_favorites_lookup_index, + where: "data->>'type' = 'Like'", + concurrently: true + ) + ) + end +end diff --git a/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs index e93b277cc..bdf9f03c1 100644 --- a/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs @@ -5,9 +5,12 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do use Pleroma.Web.ConnCase + alias Pleroma.Activity alias Pleroma.Config + alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User + alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI import Pleroma.Factory @@ -127,6 +130,49 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do assert Enum.map(response, & &1["id"]) == [second_post.id, first_post.id, third_post.id] end + test "returns a status once when duplicate favorite activities exist", %{ + conn: conn, + user: user + } do + older_activity = insert(:note_activity) + {:ok, _} = CommonAPI.favorite(older_activity.id, user) + + activity = insert(:note_activity) + {:ok, favorite} = CommonAPI.favorite(activity.id, user) + + duplicate = + Repo.insert!(%Activity{ + actor: favorite.actor, + data: Map.put(favorite.data, "id", Utils.generate_activity_id()), + local: favorite.local, + recipients: favorite.recipients + }) + + conn = get(conn, "/api/v1/pleroma/accounts/#{user.id}/favourites?limit=1") + response = json_response_and_validate_schema(conn, :ok) + + assert Enum.map(response, & &1["id"]) == [activity.id] + assert [link_header] = get_resp_header(conn, "link") + assert link_header =~ "max_id=#{duplicate.id}" + assert link_header =~ "min_id=#{duplicate.id}" + + response = + conn + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites?max_id=#{duplicate.id}&limit=1") + |> json_response_and_validate_schema(:ok) + + assert Enum.map(response, & &1["id"]) == [older_activity.id] + + {:ok, _} = CommonAPI.unfavorite(activity.id, user) + + response = + conn + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + |> json_response_and_validate_schema(:ok) + + assert Enum.map(response, & &1["id"]) == [older_activity.id] + end + test "returns favorited DM only when user is logged in and he is one of recipients", %{ current_user: current_user, user: user