From a72f9cff232c6afd7aff8ab41b21d82779d8a086 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 16 Oct 2025 15:03:54 +0200 Subject: [PATCH 001/163] 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 002/163] 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 003/163] 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 004/163] 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 005/163] 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 006/163] 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 007/163] 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 008/163] 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 009/163] 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 010/163] 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 011/163] 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 012/163] 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 013/163] 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 014/163] 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 015/163] 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 016/163] 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 017/163] 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 018/163] 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 019/163] 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 020/163] 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 021/163] 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 022/163] 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 023/163] 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 024/163] 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 025/163] 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 026/163] 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 027/163] 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 028/163] 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 029/163] 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 030/163] 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 031/163] 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 032/163] 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 033/163] 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 034/163] 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 035/163] 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 036/163] 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 037/163] 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 038/163] 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 039/163] 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 040/163] 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 041/163] 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 042/163] 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 043/163] 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 044/163] 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 045/163] 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 046/163] 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 047/163] 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 784a319f2eb9b98e6e034c38b462dd1e11390359 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 14 May 2026 00:22:24 +0200 Subject: [PATCH 048/163] 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 049/163] 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 050/163] 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 051/163] "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 052/163] 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 053/163] 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 054/163] 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 055/163] 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 056/163] 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 057/163] 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 058/163] 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 059/163] 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 060/163] 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 061/163] 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 062/163] 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 063/163] 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 064/163] 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 065/163] 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 066/163] 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 067/163] 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 068/163] 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 069/163] 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 070/163] 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 071/163] 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 072/163] 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 073/163] 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 074/163] 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 075/163] 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 076/163] 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 077/163] 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 078/163] 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 079/163] 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 080/163] 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 081/163] 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 082/163] 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 083/163] 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 084/163] 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 085/163] 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 086/163] 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 087/163] 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 088/163] 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 089/163] 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 090/163] 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 091/163] 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 092/163] 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 093/163] 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 094/163] 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 095/163] 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 096/163] 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 157/163] 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 158/163] 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 159/163] 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 160/163] 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 161/163] 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 162/163] 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 163/163] 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