From 2d8ad2267e4b249d7690c2b5a884ed5ccd176fee Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 28 Nov 2024 22:12:05 +0100 Subject: [PATCH 01/10] mix: Bump captcha for OpenBSD make fixes Ref: https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha/-/merge_requests/10 Ref: https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha/-/merge_requests/9 --- changelog.d/bump-captcha-posix-make.fix | 1 + mix.exs | 2 +- mix.lock | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 changelog.d/bump-captcha-posix-make.fix diff --git a/changelog.d/bump-captcha-posix-make.fix b/changelog.d/bump-captcha-posix-make.fix new file mode 100644 index 000000000..9af489164 --- /dev/null +++ b/changelog.d/bump-captcha-posix-make.fix @@ -0,0 +1 @@ +- Fix building "captcha" library with OpenBSD make \ No newline at end of file diff --git a/mix.exs b/mix.exs index 6e071cd1f..b7fa68624 100644 --- a/mix.exs +++ b/mix.exs @@ -193,7 +193,7 @@ defmodule Pleroma.Mixfile do ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"}, {:captcha, git: "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", - ref: "6630c42aaaab124e697b4e513190c89d8b64e410"}, + ref: "e7b7cc34cc16b383461b966484c297e4ec9aeef6"}, {:restarter, path: "./restarter"}, {:majic, "~> 1.0"}, {:open_api_spex, "~> 3.16"}, diff --git a/mix.lock b/mix.lock index 9b53ede62..b8e3bccbe 100644 --- a/mix.lock +++ b/mix.lock @@ -10,7 +10,7 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cachex": {:hex, :cachex, "3.6.0", "14a1bfbeee060dd9bec25a5b6f4e4691e3670ebda28c8ba2884b12fe30b36bf8", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "ebf24e373883bc8e0c8d894a63bbe102ae13d918f790121f5cfe6e485cc8e2e2"}, "calendar": {:hex, :calendar, "1.0.0", "f52073a708528482ec33d0a171954ca610fe2bd28f1e871f247dc7f1565fa807", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "990e9581920c82912a5ee50e62ff5ef96da6b15949a2ee4734f935fdef0f0a6f"}, - "captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "6630c42aaaab124e697b4e513190c89d8b64e410", [ref: "6630c42aaaab124e697b4e513190c89d8b64e410"]}, + "captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "e7b7cc34cc16b383461b966484c297e4ec9aeef6", [ref: "e7b7cc34cc16b383461b966484c297e4ec9aeef6"]}, "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.9", "e8d3364f310da6ce6463c3dd20cf90ae7bbecbf6c5203b98bf9b48035592649b", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9dcab3d0f3038621f1601f13539e7a9ee99843862e66ad62827b0c42b2f58a54"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, From 33cf49e860c8c3a14c32f0b37b4432e86ee2f433 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 13 Jun 2025 10:17:27 -0700 Subject: [PATCH 02/10] Resurrect MRF.QuietReply This was not working correctly because the Publisher was stripping the public address from the cc when federating unlisted activities --- .../web/activity_pub/mrf/quiet_reply.ex | 62 ++++++++ .../web/activity_pub/mrf/quiet_reply_test.exs | 140 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/mrf/quiet_reply.ex create mode 100644 test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs diff --git a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex new file mode 100644 index 000000000..66080c47d --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +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. + """ + require Pleroma.Constants + + alias Pleroma.User + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @impl true + def history_awareness, do: :auto + + @impl true + def filter( + %{ + "type" => "Create", + "to" => to, + "cc" => cc, + "object" => %{ + "actor" => actor, + "type" => "Note", + "inReplyTo" => in_reply_to + } + } = activity + ) do + with true <- is_binary(in_reply_to), + false <- match?([], cc), + %User{follower_address: followers_collection, local: true} <- + User.get_by_ap_id(actor) do + updated_to = + to + |> Kernel.++([followers_collection]) + |> Kernel.--([Pleroma.Constants.as_public()]) + + updated_cc = + [Pleroma.Constants.as_public() | cc] + |> Kernel.--([followers_collection]) + + updated_activity = + activity + |> Map.put("to", updated_to) + |> Map.put("cc", updated_cc) + |> put_in(["object", "to"], updated_to) + |> put_in(["object", "cc"], updated_cc) + + {:ok, updated_activity} + else + _ -> {:ok, activity} + end + end + + @impl true + def filter(activity), do: {:ok, activity} + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs b/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs new file mode 100644 index 000000000..79e64d650 --- /dev/null +++ b/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs @@ -0,0 +1,140 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.QuietReplyTest do + use Pleroma.DataCase + import Pleroma.Factory + + require Pleroma.Constants + + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.MRF.QuietReply + alias Pleroma.Web.CommonAPI + + test "replying to public post is forced to be quiet" do + batman = insert(:user, nickname: "batman") + robin = insert(:user, nickname: "robin") + + {:ok, post} = CommonAPI.post(batman, %{status: "To the Batmobile!"}) + + reply = %{ + "type" => "Create", + "actor" => robin.ap_id, + "to" => [ + batman.ap_id, + Pleroma.Constants.as_public() + ], + "cc" => [robin.follower_address], + "object" => %{ + "type" => "Note", + "actor" => robin.ap_id, + "content" => "@batman Wait up, I forgot my spandex!", + "to" => [ + batman.ap_id, + Pleroma.Constants.as_public() + ], + "cc" => [robin.follower_address], + "inReplyTo" => Object.normalize(post).data["id"] + } + } + + expected_to = [batman.ap_id, robin.follower_address] + expected_cc = [Pleroma.Constants.as_public()] + + assert {:ok, filtered} = QuietReply.filter(reply) + + assert expected_to == filtered["to"] + assert expected_cc == filtered["cc"] + assert expected_to == filtered["object"]["to"] + assert expected_cc == filtered["object"]["cc"] + end + + test "replying to unlisted post is unmodified" do + batman = insert(:user, nickname: "batman") + robin = insert(:user, nickname: "robin") + + {:ok, post} = CommonAPI.post(batman, %{status: "To the Batmobile!", visibility: "private"}) + + reply = %{ + "type" => "Create", + "actor" => robin.ap_id, + "to" => [batman.ap_id], + "cc" => [], + "object" => %{ + "type" => "Note", + "actor" => robin.ap_id, + "content" => "@batman Wait up, I forgot my spandex!", + "to" => [batman.ap_id], + "cc" => [], + "inReplyTo" => Object.normalize(post).data["id"] + } + } + + assert {:ok, filtered} = QuietReply.filter(reply) + + assert match?(^filtered, reply) + end + + test "replying direct is unmodified" do + batman = insert(:user, nickname: "batman") + robin = insert(:user, nickname: "robin") + + {:ok, post} = CommonAPI.post(batman, %{status: "To the Batmobile!"}) + + reply = %{ + "type" => "Create", + "actor" => robin.ap_id, + "to" => [batman.ap_id], + "cc" => [], + "object" => %{ + "type" => "Note", + "actor" => robin.ap_id, + "content" => "@batman Wait up, I forgot my spandex!", + "to" => [batman.ap_id], + "cc" => [], + "inReplyTo" => Object.normalize(post).data["id"] + } + } + + assert {:ok, filtered} = QuietReply.filter(reply) + + assert match?(^filtered, reply) + end + + test "replying followers-only is unmodified" do + batman = insert(:user, nickname: "batman") + robin = insert(:user, nickname: "robin") + + {:ok, post} = CommonAPI.post(batman, %{status: "To the Batmobile!"}) + + reply = %{ + "type" => "Create", + "actor" => robin.ap_id, + "to" => [batman.ap_id, robin.follower_address], + "cc" => [], + "object" => %{ + "type" => "Note", + "actor" => robin.ap_id, + "content" => "@batman Wait up, I forgot my spandex!", + "to" => [batman.ap_id, robin.follower_address], + "cc" => [], + "inReplyTo" => Object.normalize(post).data["id"] + } + } + + assert {:ok, filtered} = QuietReply.filter(reply) + + assert match?(^filtered, reply) + end + + test "non-reply posts are unmodified" do + batman = insert(:user, nickname: "batman") + + {:ok, post} = CommonAPI.post(batman, %{status: "To the Batmobile!"}) + + assert {:ok, filtered} = QuietReply.filter(post) + + assert match?(^filtered, post) + end +end From 37d4ed883c1b042df12facaba82d44929008b918 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 19 Jun 2025 14:50:45 -0700 Subject: [PATCH 03/10] Change MRF logic to match when there is an inReplyTo and the public address is in the "to" field Update the method to alter the to/cc fields for consistency and modify the tests to work without requiring a specific order items in the list --- .../web/activity_pub/mrf/quiet_reply.ex | 5 +- .../web/activity_pub/mrf/quiet_reply_test.exs | 13 +- .../o_auth/oauth_authorization_flow_test.exs | 339 ++++++++++++++++++ 3 files changed, 347 insertions(+), 10 deletions(-) create mode 100644 test/pleroma/web/o_auth/oauth_authorization_flow_test.exs diff --git a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex index 66080c47d..b3eb0b390 100644 --- a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex +++ b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex @@ -29,12 +29,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.QuietReply do } = activity ) do with true <- is_binary(in_reply_to), - false <- match?([], cc), + true <- Pleroma.Constants.as_public() in to, %User{follower_address: followers_collection, local: true} <- User.get_by_ap_id(actor) do updated_to = - to - |> Kernel.++([followers_collection]) + [followers_collection | to] |> Kernel.--([Pleroma.Constants.as_public()]) updated_cc = diff --git a/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs b/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs index 79e64d650..f66383bf5 100644 --- a/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs +++ b/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs @@ -39,15 +39,14 @@ defmodule Pleroma.Web.ActivityPub.MRF.QuietReplyTest do } } - expected_to = [batman.ap_id, robin.follower_address] - expected_cc = [Pleroma.Constants.as_public()] - assert {:ok, filtered} = QuietReply.filter(reply) - assert expected_to == filtered["to"] - assert expected_cc == filtered["cc"] - assert expected_to == filtered["object"]["to"] - assert expected_cc == filtered["object"]["cc"] + assert batman.ap_id in filtered["to"] + assert batman.ap_id in filtered["object"]["to"] + assert robin.follower_address in filtered["to"] + assert robin.follower_address in filtered["object"]["to"] + assert Pleroma.Constants.as_public() in filtered["cc"] + assert Pleroma.Constants.as_public() in filtered["object"]["cc"] end test "replying to unlisted post is unmodified" do diff --git a/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs b/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs new file mode 100644 index 000000000..fdd8cbdb4 --- /dev/null +++ b/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs @@ -0,0 +1,339 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.OAuthAuthorizationFlowTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + alias Pleroma.Helpers.AuthHelper + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.OAuthController + alias Pleroma.Web.OAuth.Token + + @session_opts [ + store: :cookie, + key: "_test", + signing_salt: "cooldude" + ] + + setup do + clear_config([:instance, :account_activation_required], false) + clear_config([:instance, :account_approval_required], false) + end + + describe "OAuth authorization flow with external integration" do + test "complete OAuth flow: create user, create app, authorize, get token, use token" do + # Step 1: Create a user + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + # Step 2: Create a new OAuth client with the required scopes + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + redirect_uris: "urn:ietf:wg:oauth:2.0:oob" + ) + + # Step 3: Set up a logged in session + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) + + # Step 4: Access the /oauth/authorize endpoint with the specified parameters + authorize_params = %{ + "client_id" => app.client_id, + "response_type" => "code", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "force_login" => "False", + "state" => "None", + "lang" => "None" + } + + # First, get the authorization page + conn = get(conn, "/oauth/authorize", authorize_params) + assert html_response(conn, 200) + + # Step 5: Submit the authorization (simulate user approving the app) + authorization_data = %{ + "authorization" => %{ + "client_id" => app.client_id, + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "state" => "None" + } + } + + conn = post(conn, "/oauth/authorize", authorization_data) + + # Should get the OOB authorization page with the code + assert html_response(conn, 200) + + # Extract the authorization code from the response + response = html_response(conn, 200) + assert response =~ "Successfully authorized" + assert response =~ "Token code is" + + # Parse the authorization code from the response + code_match = Regex.run(~r/Token code is
([a-zA-Z0-9_-]+)/, response) + assert code_match + [_, authorization_code] = code_match + + # Step 6: Exchange the authorization code for an access token + token_conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "authorization_code", + "code" => authorization_code, + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + token_response = json_response(token_conn, 200) + assert %{"access_token" => access_token, "token_type" => "Bearer"} = token_response + assert token_response["scope"] == "read write follow push" + + # Verify the token was created in the database + token_record = Repo.get_by(Token, token: access_token) + assert token_record + assert token_record.scopes == ["read", "write", "follow", "push"] + assert token_record.user_id == user.id + assert token_record.app_id == app.id + + # Step 7: Use the token to access a protected endpoint + protected_conn = + build_conn() + |> put_req_header("authorization", "Bearer #{access_token}") + |> get("/api/v1/accounts/verify_credentials") + + # Should get a 200 response with user information + user_info = json_response(protected_conn, 200) + assert user_info["id"] == to_string(user.id) + assert user_info["username"] == user.nickname + assert user_info["acct"] == user.nickname + + # Step 8: Test that the token has the correct scopes by accessing different endpoints + # Test read:accounts scope (should work) + conn_with_token = + build_conn() + |> put_req_header("authorization", "Bearer #{access_token}") + + # This should work because we have "read" scope + conn_with_token + |> get("/api/v1/accounts/#{user.id}") + |> json_response(200) + + # Test write:accounts scope (should work) - with proper content-type + conn_with_token + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/accounts/update_credentials", %{"display_name" => "Test Name"}) + |> json_response(200) + + # Test that the token is properly associated with the user + assert token_record.user_id == user.id + assert token_record.app_id == app.id + end + + test "OAuth flow with force_login=false and existing session" do + # Create a user and app + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + redirect_uris: "urn:ietf:wg:oauth:2.0:oob" + ) + + # Create an existing token for the same user and app + existing_token = insert(:oauth_token, user: user, app: app, scopes: ["read", "write"]) + + # Set up a logged in session with the existing token + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(existing_token.token) + + # Access the authorize endpoint with force_login=false + authorize_params = %{ + "client_id" => app.client_id, + "response_type" => "code", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "force_login" => "False", + "state" => "test_state" + } + + # Should redirect to the OOB page with the existing token + conn = get(conn, "/oauth/authorize", authorize_params) + assert html_response(conn, 200) + assert html_response(conn, 200) =~ "Authorization exists" + end + + test "OAuth flow with different scopes than existing token" do + # Create a user and app + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + redirect_uris: "urn:ietf:wg:oauth:2.0:oob" + ) + + # Create an existing token with different scopes + existing_token = insert(:oauth_token, user: user, app: app, scopes: ["read"]) + + # Set up a logged in session + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(existing_token.token) + + # Access the authorize endpoint requesting more scopes + authorize_params = %{ + "client_id" => app.client_id, + "response_type" => "code", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "force_login" => "False", + "state" => "test_state" + } + + # Should show the authorization page because scopes are different + conn = get(conn, "/oauth/authorize", authorize_params) + assert html_response(conn, 200) + assert html_response(conn, 200) =~ "Authorization exists" + end + + test "OAuth flow with invalid client_id" do + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) + + # Try to authorize with invalid client_id + authorize_params = %{ + "client_id" => "invalid_client_id", + "response_type" => "code", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "force_login" => "False" + } + + conn = get(conn, "/oauth/authorize", authorize_params) + # Should still render the page but with error or missing app info + assert html_response(conn, 200) + end + + test "OAuth flow with unlisted redirect_uri" do + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + # Different from requested + redirect_uris: "https://example.com/callback" + ) + + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) + + # Try to authorize with unlisted redirect_uri + authorize_params = %{ + "client_id" => app.client_id, + "response_type" => "code", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "force_login" => "False" + } + + conn = get(conn, "/oauth/authorize", authorize_params) + # Should still render the page but with error about unlisted redirect_uri + assert html_response(conn, 200) + end + + test "OAuth flow with expired authorization code" do + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + redirect_uris: "urn:ietf:wg:oauth:2.0:oob" + ) + + # Create an expired authorization + expired_auth = + insert(:oauth_authorization, + user: user, + app: app, + # 1 hour ago + valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), -3600), + scopes: ["read", "write", "follow", "push"] + ) + + # Try to exchange expired code for token + conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "authorization_code", + "code" => expired_auth.token, + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + # Should get an error + response = json_response(conn, 400) + assert %{"error" => _} = response + end + + test "OAuth flow with used authorization code" do + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + redirect_uris: "urn:ietf:wg:oauth:2.0:oob" + ) + + # Create an authorization and mark it as used + auth = + insert(:oauth_authorization, + user: user, + app: app, + scopes: ["read", "write", "follow", "push"] + ) + + {:ok, _} = Authorization.use_token(auth) + + # Try to exchange used code for token + conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "authorization_code", + "code" => auth.token, + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + # Should get an error + response = json_response(conn, 400) + assert %{"error" => _} = response + end + end +end From 56aab905e898429b7e2744c3ed2afc96f2a9e97c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 27 Jun 2025 10:56:04 -0700 Subject: [PATCH 04/10] Queue individual jobs for each user that needs to be deleted when deleting an instance. --- changelog.d/delete-instance.change | 1 + lib/pleroma/instances/instance.ex | 13 ------- lib/pleroma/workers/delete_worker.ex | 11 ++++-- test/pleroma/workers/delete_worker_test.exs | 39 +++++++++++++++++++++ 4 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 changelog.d/delete-instance.change create mode 100644 test/pleroma/workers/delete_worker_test.exs diff --git a/changelog.d/delete-instance.change b/changelog.d/delete-instance.change new file mode 100644 index 000000000..9d84dac54 --- /dev/null +++ b/changelog.d/delete-instance.change @@ -0,0 +1 @@ +Deleting an instance queues individual jobs for each user that needs to be deleted from the server. diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index 33f1229d0..7bf38deee 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Instances.Instance do alias Pleroma.Instances.Instance alias Pleroma.Maps alias Pleroma.Repo - alias Pleroma.User alias Pleroma.Workers.DeleteWorker use Ecto.Schema @@ -300,16 +299,4 @@ defmodule Pleroma.Instances.Instance do DeleteWorker.new(%{"op" => "delete_instance", "host" => host}) |> Oban.insert() end - - def perform(:delete_instance, host) when is_binary(host) do - User.Query.build(%{nickname: "@#{host}"}) - |> Repo.chunk_stream(100, :batches) - |> Stream.each(fn users -> - users - |> Enum.each(fn user -> - User.perform(:delete, user) - end) - end) - |> Stream.run() - end end diff --git a/lib/pleroma/workers/delete_worker.ex b/lib/pleroma/workers/delete_worker.ex index 6a1c7bb38..4f52edd28 100644 --- a/lib/pleroma/workers/delete_worker.ex +++ b/lib/pleroma/workers/delete_worker.ex @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.DeleteWorker do - alias Pleroma.Instances.Instance alias Pleroma.User use Oban.Worker, queue: :slow @@ -15,7 +14,15 @@ defmodule Pleroma.Workers.DeleteWorker do end def perform(%Job{args: %{"op" => "delete_instance", "host" => host}}) do - Instance.perform(:delete_instance, host) + Pleroma.Repo.transaction(fn -> + User.Query.build(%{nickname: "@#{host}"}) + |> Pleroma.Repo.all() + |> Enum.each(fn user -> + %{"op" => "delete_user", "user_id" => user.id} + |> __MODULE__.new() + |> Oban.insert() + end) + end) end @impl true diff --git a/test/pleroma/workers/delete_worker_test.exs b/test/pleroma/workers/delete_worker_test.exs new file mode 100644 index 000000000..b914aaee2 --- /dev/null +++ b/test/pleroma/workers/delete_worker_test.exs @@ -0,0 +1,39 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.DeleteWorkerTest do + use Pleroma.DataCase, async: true + use Oban.Testing, repo: Pleroma.Repo + + import Pleroma.Factory + + alias Pleroma.Instances.Instance + alias Pleroma.Tests.ObanHelpers + alias Pleroma.Workers.DeleteWorker + + describe "instance deletion" do + test "creates individual Oban jobs for each user when deleting an instance" do + user1 = insert(:user, nickname: "alice@example.com", name: "Alice") + user2 = insert(:user, nickname: "bob@example.com", name: "Bob") + + {:ok, job} = Instance.delete_users_and_activities("example.com") + + assert_enqueued( + worker: DeleteWorker, + args: %{"op" => "delete_instance", "host" => "example.com"} + ) + + {:ok, :ok} = ObanHelpers.perform(job) + + delete_user_jobs = all_enqueued(worker: DeleteWorker, args: %{"op" => "delete_user"}) + + assert length(delete_user_jobs) == 2 + + user_ids = [user1.id, user2.id] + job_user_ids = Enum.map(delete_user_jobs, fn job -> job.args["user_id"] end) + + assert Enum.sort(user_ids) == Enum.sort(job_user_ids) + end + end +end From 81155a229216b2d58c63f5a0f418d1354ced6990 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 19 Jun 2025 14:53:37 -0700 Subject: [PATCH 05/10] changelog for MRF.QuietReply --- changelog.d/mrf-quietreply.add | 1 + .../o_auth/oauth_authorization_flow_test.exs | 339 ------------------ 2 files changed, 1 insertion(+), 339 deletions(-) create mode 100644 changelog.d/mrf-quietreply.add delete mode 100644 test/pleroma/web/o_auth/oauth_authorization_flow_test.exs diff --git a/changelog.d/mrf-quietreply.add b/changelog.d/mrf-quietreply.add new file mode 100644 index 000000000..4ed20bce6 --- /dev/null +++ b/changelog.d/mrf-quietreply.add @@ -0,0 +1 @@ +Added MRF.QuietReply which prevents replies to public posts from being published to the timelines diff --git a/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs b/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs deleted file mode 100644 index fdd8cbdb4..000000000 --- a/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs +++ /dev/null @@ -1,339 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.OAuth.OAuthAuthorizationFlowTest do - use Pleroma.Web.ConnCase - - import Pleroma.Factory - - alias Pleroma.Helpers.AuthHelper - alias Pleroma.Repo - alias Pleroma.User - alias Pleroma.Web.OAuth.App - alias Pleroma.Web.OAuth.Authorization - alias Pleroma.Web.OAuth.OAuthController - alias Pleroma.Web.OAuth.Token - - @session_opts [ - store: :cookie, - key: "_test", - signing_salt: "cooldude" - ] - - setup do - clear_config([:instance, :account_activation_required], false) - clear_config([:instance, :account_approval_required], false) - end - - describe "OAuth authorization flow with external integration" do - test "complete OAuth flow: create user, create app, authorize, get token, use token" do - # Step 1: Create a user - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - # Step 2: Create a new OAuth client with the required scopes - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - redirect_uris: "urn:ietf:wg:oauth:2.0:oob" - ) - - # Step 3: Set up a logged in session - conn = - build_conn() - |> Plug.Session.call(Plug.Session.init(@session_opts)) - |> fetch_session() - |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) - - # Step 4: Access the /oauth/authorize endpoint with the specified parameters - authorize_params = %{ - "client_id" => app.client_id, - "response_type" => "code", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "force_login" => "False", - "state" => "None", - "lang" => "None" - } - - # First, get the authorization page - conn = get(conn, "/oauth/authorize", authorize_params) - assert html_response(conn, 200) - - # Step 5: Submit the authorization (simulate user approving the app) - authorization_data = %{ - "authorization" => %{ - "client_id" => app.client_id, - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "state" => "None" - } - } - - conn = post(conn, "/oauth/authorize", authorization_data) - - # Should get the OOB authorization page with the code - assert html_response(conn, 200) - - # Extract the authorization code from the response - response = html_response(conn, 200) - assert response =~ "Successfully authorized" - assert response =~ "Token code is" - - # Parse the authorization code from the response - code_match = Regex.run(~r/Token code is
([a-zA-Z0-9_-]+)/, response) - assert code_match - [_, authorization_code] = code_match - - # Step 6: Exchange the authorization code for an access token - token_conn = - build_conn() - |> post("/oauth/token", %{ - "grant_type" => "authorization_code", - "code" => authorization_code, - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "client_id" => app.client_id, - "client_secret" => app.client_secret - }) - - token_response = json_response(token_conn, 200) - assert %{"access_token" => access_token, "token_type" => "Bearer"} = token_response - assert token_response["scope"] == "read write follow push" - - # Verify the token was created in the database - token_record = Repo.get_by(Token, token: access_token) - assert token_record - assert token_record.scopes == ["read", "write", "follow", "push"] - assert token_record.user_id == user.id - assert token_record.app_id == app.id - - # Step 7: Use the token to access a protected endpoint - protected_conn = - build_conn() - |> put_req_header("authorization", "Bearer #{access_token}") - |> get("/api/v1/accounts/verify_credentials") - - # Should get a 200 response with user information - user_info = json_response(protected_conn, 200) - assert user_info["id"] == to_string(user.id) - assert user_info["username"] == user.nickname - assert user_info["acct"] == user.nickname - - # Step 8: Test that the token has the correct scopes by accessing different endpoints - # Test read:accounts scope (should work) - conn_with_token = - build_conn() - |> put_req_header("authorization", "Bearer #{access_token}") - - # This should work because we have "read" scope - conn_with_token - |> get("/api/v1/accounts/#{user.id}") - |> json_response(200) - - # Test write:accounts scope (should work) - with proper content-type - conn_with_token - |> put_req_header("content-type", "application/json") - |> patch("/api/v1/accounts/update_credentials", %{"display_name" => "Test Name"}) - |> json_response(200) - - # Test that the token is properly associated with the user - assert token_record.user_id == user.id - assert token_record.app_id == app.id - end - - test "OAuth flow with force_login=false and existing session" do - # Create a user and app - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - redirect_uris: "urn:ietf:wg:oauth:2.0:oob" - ) - - # Create an existing token for the same user and app - existing_token = insert(:oauth_token, user: user, app: app, scopes: ["read", "write"]) - - # Set up a logged in session with the existing token - conn = - build_conn() - |> Plug.Session.call(Plug.Session.init(@session_opts)) - |> fetch_session() - |> AuthHelper.put_session_token(existing_token.token) - - # Access the authorize endpoint with force_login=false - authorize_params = %{ - "client_id" => app.client_id, - "response_type" => "code", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "force_login" => "False", - "state" => "test_state" - } - - # Should redirect to the OOB page with the existing token - conn = get(conn, "/oauth/authorize", authorize_params) - assert html_response(conn, 200) - assert html_response(conn, 200) =~ "Authorization exists" - end - - test "OAuth flow with different scopes than existing token" do - # Create a user and app - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - redirect_uris: "urn:ietf:wg:oauth:2.0:oob" - ) - - # Create an existing token with different scopes - existing_token = insert(:oauth_token, user: user, app: app, scopes: ["read"]) - - # Set up a logged in session - conn = - build_conn() - |> Plug.Session.call(Plug.Session.init(@session_opts)) - |> fetch_session() - |> AuthHelper.put_session_token(existing_token.token) - - # Access the authorize endpoint requesting more scopes - authorize_params = %{ - "client_id" => app.client_id, - "response_type" => "code", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "force_login" => "False", - "state" => "test_state" - } - - # Should show the authorization page because scopes are different - conn = get(conn, "/oauth/authorize", authorize_params) - assert html_response(conn, 200) - assert html_response(conn, 200) =~ "Authorization exists" - end - - test "OAuth flow with invalid client_id" do - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - conn = - build_conn() - |> Plug.Session.call(Plug.Session.init(@session_opts)) - |> fetch_session() - |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) - - # Try to authorize with invalid client_id - authorize_params = %{ - "client_id" => "invalid_client_id", - "response_type" => "code", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "force_login" => "False" - } - - conn = get(conn, "/oauth/authorize", authorize_params) - # Should still render the page but with error or missing app info - assert html_response(conn, 200) - end - - test "OAuth flow with unlisted redirect_uri" do - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - # Different from requested - redirect_uris: "https://example.com/callback" - ) - - conn = - build_conn() - |> Plug.Session.call(Plug.Session.init(@session_opts)) - |> fetch_session() - |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) - - # Try to authorize with unlisted redirect_uri - authorize_params = %{ - "client_id" => app.client_id, - "response_type" => "code", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "force_login" => "False" - } - - conn = get(conn, "/oauth/authorize", authorize_params) - # Should still render the page but with error about unlisted redirect_uri - assert html_response(conn, 200) - end - - test "OAuth flow with expired authorization code" do - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - redirect_uris: "urn:ietf:wg:oauth:2.0:oob" - ) - - # Create an expired authorization - expired_auth = - insert(:oauth_authorization, - user: user, - app: app, - # 1 hour ago - valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), -3600), - scopes: ["read", "write", "follow", "push"] - ) - - # Try to exchange expired code for token - conn = - build_conn() - |> post("/oauth/token", %{ - "grant_type" => "authorization_code", - "code" => expired_auth.token, - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "client_id" => app.client_id, - "client_secret" => app.client_secret - }) - - # Should get an error - response = json_response(conn, 400) - assert %{"error" => _} = response - end - - test "OAuth flow with used authorization code" do - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - redirect_uris: "urn:ietf:wg:oauth:2.0:oob" - ) - - # Create an authorization and mark it as used - auth = - insert(:oauth_authorization, - user: user, - app: app, - scopes: ["read", "write", "follow", "push"] - ) - - {:ok, _} = Authorization.use_token(auth) - - # Try to exchange used code for token - conn = - build_conn() - |> post("/oauth/token", %{ - "grant_type" => "authorization_code", - "code" => auth.token, - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "client_id" => app.client_id, - "client_secret" => app.client_secret - }) - - # Should get an error - response = json_response(conn, 400) - assert %{"error" => _} = response - end - end -end From ca616e9e73aae9a3f27a44db928bdfe82add21d1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 27 Jun 2025 12:10:25 -0700 Subject: [PATCH 06/10] Fix Instance and Admin API controller tests for deleting instances Ensure the job was queued, remove the other test validation. We already prove elsewhere that Pleroma.User.delete/1 works, so repeating that here is a waste. --- test/pleroma/instances/instance_test.exs | 35 +++++-------------- .../controllers/instance_controller_test.exs | 14 ++++---- 2 files changed, 14 insertions(+), 35 deletions(-) diff --git a/test/pleroma/instances/instance_test.exs b/test/pleroma/instances/instance_test.exs index 6a718be21..4b03655cb 100644 --- a/test/pleroma/instances/instance_test.exs +++ b/test/pleroma/instances/instance_test.exs @@ -6,9 +6,8 @@ defmodule Pleroma.Instances.InstanceTest do alias Pleroma.Instances alias Pleroma.Instances.Instance alias Pleroma.Repo - alias Pleroma.Tests.ObanHelpers - alias Pleroma.Web.CommonAPI + use Oban.Testing, repo: Pleroma.Repo use Pleroma.DataCase import ExUnit.CaptureLog @@ -213,32 +212,14 @@ defmodule Pleroma.Instances.InstanceTest do end end - test "delete_users_and_activities/1 deletes remote instance users and activities" do - [mario, luigi, _peach, wario] = - users = [ - insert(:user, nickname: "mario@mushroom.kingdom", name: "Mario"), - insert(:user, nickname: "luigi@mushroom.kingdom", name: "Luigi"), - insert(:user, nickname: "peach@mushroom.kingdom", name: "Peach"), - insert(:user, nickname: "wario@greedville.biz", name: "Wario") - ] + test "delete_users_and_activities/1 schedules a job to delete the instance and users" do + insert(:user, nickname: "mario@mushroom.kingdom", name: "Mario") - {:ok, post1} = CommonAPI.post(mario, %{status: "letsa go!"}) - {:ok, post2} = CommonAPI.post(luigi, %{status: "itsa me... luigi"}) - {:ok, post3} = CommonAPI.post(wario, %{status: "WHA-HA-HA!"}) + {:ok, _job} = Instance.delete_users_and_activities("mushroom.kingdom") - {:ok, job} = Instance.delete_users_and_activities("mushroom.kingdom") - :ok = ObanHelpers.perform(job) - - [mario, luigi, peach, wario] = Repo.reload(users) - - refute mario.is_active - refute luigi.is_active - refute peach.is_active - refute peach.name == "Peach" - - assert wario.is_active - assert wario.name == "Wario" - - assert [nil, nil, %{}] = Repo.reload([post1, post2, post3]) + assert_enqueued( + worker: Pleroma.Workers.DeleteWorker, + args: %{"op" => "delete_instance", "host" => "mushroom.kingdom"} + ) end end diff --git a/test/pleroma/web/admin_api/controllers/instance_controller_test.exs b/test/pleroma/web/admin_api/controllers/instance_controller_test.exs index 6cca623f3..5adcd069d 100644 --- a/test/pleroma/web/admin_api/controllers/instance_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/instance_controller_test.exs @@ -8,8 +8,6 @@ defmodule Pleroma.Web.AdminAPI.InstanceControllerTest do import Pleroma.Factory - alias Pleroma.Repo - alias Pleroma.Tests.ObanHelpers alias Pleroma.Web.CommonAPI setup_all do @@ -69,19 +67,19 @@ defmodule Pleroma.Web.AdminAPI.InstanceControllerTest do test "DELETE /instances/:instance", %{conn: conn} do clear_config([:instance, :admin_privileges], [:instances_delete]) - user = insert(:user, nickname: "lain@lain.com") - post = insert(:note_activity, user: user) + insert(:user, nickname: "lain@lain.com") response = conn |> delete("/api/pleroma/admin/instances/lain.com") |> json_response(200) - [:ok] = ObanHelpers.perform_all() - assert response == "lain.com" - refute Repo.reload(user).is_active - refute Repo.reload(post) + + assert_enqueued( + worker: Pleroma.Workers.DeleteWorker, + args: %{"op" => "delete_instance", "host" => "lain.com"} + ) clear_config([:instance, :admin_privileges], []) From 122ad4603a5fa09a8a26f0a419b85b5dc56d7fe3 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Jul 2025 10:56:07 -0700 Subject: [PATCH 07/10] Use correct Endpoint host and WebFinger domains in tests --- .../web_finger/web_finger_controller_test.exs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/test/pleroma/web/web_finger/web_finger_controller_test.exs b/test/pleroma/web/web_finger/web_finger_controller_test.exs index be44e3a8b..a0f6663b4 100644 --- a/test/pleroma/web/web_finger/web_finger_controller_test.exs +++ b/test/pleroma/web/web_finger/web_finger_controller_test.exs @@ -34,6 +34,9 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do end test "Webfinger JRD" do + clear_config([Pleroma.Web.Endpoint, :url, :host], "hyrule.world") + clear_config([Pleroma.Web.WebFinger, :domain], "hyrule.world") + user = insert(:user, ap_id: "https://hyrule.world/users/zelda", @@ -43,10 +46,10 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do response = build_conn() |> put_req_header("accept", "application/jrd+json") - |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@localhost") + |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@hyrule.world") |> json_response(200) - assert response["subject"] == "acct:#{user.nickname}@localhost" + assert response["subject"] == "acct:#{user.nickname}@hyrule.world" assert response["aliases"] == [ "https://hyrule.world/users/zelda", @@ -55,6 +58,9 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do end test "Webfinger defaults to JSON when no Accept header is provided" do + clear_config([Pleroma.Web.Endpoint, :url, :host], "hyrule.world") + clear_config([Pleroma.Web.WebFinger, :domain], "hyrule.world") + user = insert(:user, ap_id: "https://hyrule.world/users/zelda", @@ -63,10 +69,10 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do response = build_conn() - |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@localhost") + |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@hyrule.world") |> json_response(200) - assert response["subject"] == "acct:#{user.nickname}@localhost" + assert response["subject"] == "acct:#{user.nickname}@hyrule.world" assert response["aliases"] == [ "https://hyrule.world/users/zelda", @@ -102,6 +108,9 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do end test "Webfinger XML" do + clear_config([Pleroma.Web.Endpoint, :url, :host], "hyrule.world") + clear_config([Pleroma.Web.WebFinger, :domain], "hyrule.world") + user = insert(:user, ap_id: "https://hyrule.world/users/zelda", @@ -129,6 +138,9 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do end test "Returns JSON when format is not supported" do + clear_config([Pleroma.Web.Endpoint, :url, :host], "hyrule.world") + clear_config([Pleroma.Web.WebFinger, :domain], "hyrule.world") + user = insert(:user, ap_id: "https://hyrule.world/users/zelda", @@ -138,10 +150,10 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do response = build_conn() |> put_req_header("accept", "text/html") - |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@localhost") + |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@hyrule.world") |> json_response(200) - assert response["subject"] == "acct:#{user.nickname}@localhost" + assert response["subject"] == "acct:#{user.nickname}@hyrule.world" assert response["aliases"] == [ "https://hyrule.world/users/zelda", From 736686b4e2b6e37408b2e46b5acfd4284ddd17c3 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Jul 2025 11:19:52 -0700 Subject: [PATCH 08/10] Add specific tests for Webfinger aliases / also_known_as Also reorganize similar tests to be grouped together --- .../web_finger/web_finger_controller_test.exs | 165 ++++++++++++------ 1 file changed, 108 insertions(+), 57 deletions(-) diff --git a/test/pleroma/web/web_finger/web_finger_controller_test.exs b/test/pleroma/web/web_finger/web_finger_controller_test.exs index a0f6663b4..ef52a4b85 100644 --- a/test/pleroma/web/web_finger/web_finger_controller_test.exs +++ b/test/pleroma/web/web_finger/web_finger_controller_test.exs @@ -33,28 +33,46 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do assert match?(^response_xml, expected_xml) end - test "Webfinger JRD" do - clear_config([Pleroma.Web.Endpoint, :url, :host], "hyrule.world") - clear_config([Pleroma.Web.WebFinger, :domain], "hyrule.world") + describe "Webfinger" do + test "JRD" do + clear_config([Pleroma.Web.Endpoint, :url, :host], "hyrule.world") + clear_config([Pleroma.Web.WebFinger, :domain], "hyrule.world") - user = - insert(:user, - ap_id: "https://hyrule.world/users/zelda", - also_known_as: ["https://mushroom.kingdom/users/toad"] - ) + user = + insert(:user, + ap_id: "https://hyrule.world/users/zelda" + ) - response = - build_conn() - |> put_req_header("accept", "application/jrd+json") - |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@hyrule.world") - |> json_response(200) + response = + build_conn() + |> put_req_header("accept", "application/jrd+json") + |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@hyrule.world") + |> json_response(200) - assert response["subject"] == "acct:#{user.nickname}@hyrule.world" + assert response["subject"] == "acct:#{user.nickname}@hyrule.world" - assert response["aliases"] == [ - "https://hyrule.world/users/zelda", - "https://mushroom.kingdom/users/toad" - ] + assert response["aliases"] == [ + "https://hyrule.world/users/zelda" + ] + end + + test "XML" do + clear_config([Pleroma.Web.Endpoint, :url, :host], "hyrule.world") + clear_config([Pleroma.Web.WebFinger, :domain], "hyrule.world") + + user = + insert(:user, + ap_id: "https://hyrule.world/users/zelda" + ) + + response = + build_conn() + |> put_req_header("accept", "application/xrd+xml") + |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@localhost") + |> response(200) + + assert response =~ "https://hyrule.world/users/zelda" + end end test "Webfinger defaults to JSON when no Accept header is provided" do @@ -63,8 +81,7 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do user = insert(:user, - ap_id: "https://hyrule.world/users/zelda", - also_known_as: ["https://mushroom.kingdom/users/toad"] + ap_id: "https://hyrule.world/users/zelda" ) response = @@ -75,11 +92,63 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do assert response["subject"] == "acct:#{user.nickname}@hyrule.world" assert response["aliases"] == [ - "https://hyrule.world/users/zelda", - "https://mushroom.kingdom/users/toad" + "https://hyrule.world/users/zelda" ] end + describe "Webfinger returns also_known_as / aliases in the response" do + test "JSON" do + clear_config([Pleroma.Web.Endpoint, :url, :host], "hyrule.world") + clear_config([Pleroma.Web.WebFinger, :domain], "hyrule.world") + + user = + insert(:user, + ap_id: "https://hyrule.world/users/zelda", + also_known_as: [ + "https://mushroom.kingdom/users/toad", + "https://luigi.mansion/users/kingboo" + ] + ) + + response = + build_conn() + |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@hyrule.world") + |> json_response(200) + + assert response["subject"] == "acct:#{user.nickname}@hyrule.world" + + assert response["aliases"] == [ + "https://hyrule.world/users/zelda", + "https://mushroom.kingdom/users/toad", + "https://luigi.mansion/users/kingboo" + ] + end + + test "XML" do + clear_config([Pleroma.Web.Endpoint, :url, :host], "hyrule.world") + clear_config([Pleroma.Web.WebFinger, :domain], "hyrule.world") + + user = + insert(:user, + ap_id: "https://hyrule.world/users/zelda", + also_known_as: [ + "https://mushroom.kingdom/users/toad", + "https://luigi.mansion/users/kingboo" + ] + ) + + response = + build_conn() + |> put_req_header("accept", "application/xrd+xml") + |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@localhost") + |> response(200) + + assert response =~ "https://hyrule.world/users/zelda" + assert response =~ "https://mushroom.kingdom/users/toad" + assert response =~ "https://luigi.mansion/users/kingboo" + end + end + test "reach user on tld, while pleroma is running on subdomain" do clear_config([Pleroma.Web.Endpoint, :url, :host], "sub.example.com") @@ -97,44 +166,26 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do assert response["aliases"] == ["https://sub.example.com/users/#{user.nickname}"] end - test "it returns 404 when user isn't found (JSON)" do - result = - build_conn() - |> put_req_header("accept", "application/jrd+json") - |> get("/.well-known/webfinger?resource=acct:jimm@localhost") - |> json_response(404) + describe "it returns 404 when user isn't found" do + test "JSON" do + result = + build_conn() + |> put_req_header("accept", "application/jrd+json") + |> get("/.well-known/webfinger?resource=acct:jimm@localhost") + |> json_response(404) - assert result == "Couldn't find user" - end + assert result == "Couldn't find user" + end - test "Webfinger XML" do - clear_config([Pleroma.Web.Endpoint, :url, :host], "hyrule.world") - clear_config([Pleroma.Web.WebFinger, :domain], "hyrule.world") + test "XML" do + result = + build_conn() + |> put_req_header("accept", "application/xrd+xml") + |> get("/.well-known/webfinger?resource=acct:jimm@localhost") + |> response(404) - user = - insert(:user, - ap_id: "https://hyrule.world/users/zelda", - also_known_as: ["https://mushroom.kingdom/users/toad"] - ) - - response = - build_conn() - |> put_req_header("accept", "application/xrd+xml") - |> get("/.well-known/webfinger?resource=acct:#{user.nickname}@localhost") - |> response(200) - - assert response =~ "https://hyrule.world/users/zelda" - assert response =~ "https://mushroom.kingdom/users/toad" - end - - test "it returns 404 when user isn't found (XML)" do - result = - build_conn() - |> put_req_header("accept", "application/xrd+xml") - |> get("/.well-known/webfinger?resource=acct:jimm@localhost") - |> response(404) - - assert result == "Couldn't find user" + assert result == "Couldn't find user" + end end test "Returns JSON when format is not supported" do From 17987e39908d8771b844142d62fcbfa795562815 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Jul 2025 12:08:36 -0700 Subject: [PATCH 09/10] Enforce an exact domain match for WebFinger resolution The regex was not being terminated with an $ --- changelog.d/webfinger-resolution.fix | 1 + lib/pleroma/web/web_finger.ex | 4 ++-- test/pleroma/web/web_finger_test.exs | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 changelog.d/webfinger-resolution.fix diff --git a/changelog.d/webfinger-resolution.fix b/changelog.d/webfinger-resolution.fix new file mode 100644 index 000000000..71b927bb0 --- /dev/null +++ b/changelog.d/webfinger-resolution.fix @@ -0,0 +1 @@ +Enforce an exact domain match for WebFinger resolution diff --git a/lib/pleroma/web/web_finger.ex b/lib/pleroma/web/web_finger.ex index e653b3338..a53d58caa 100644 --- a/lib/pleroma/web/web_finger.ex +++ b/lib/pleroma/web/web_finger.ex @@ -35,9 +35,9 @@ defmodule Pleroma.Web.WebFinger do regex = if webfinger_domain = Pleroma.Config.get([__MODULE__, :domain]) do - ~r/(acct:)?(?[a-z0-9A-Z_\.-]+)@(#{host}|#{webfinger_domain})/ + ~r/(acct:)?(?[a-z0-9A-Z_\.-]+)@(#{host}|#{webfinger_domain})$/ else - ~r/(acct:)?(?[a-z0-9A-Z_\.-]+)@#{host}/ + ~r/(acct:)?(?[a-z0-9A-Z_\.-]+)@#{host}$/ end with %{"username" => username} <- Regex.named_captures(regex, resource), diff --git a/test/pleroma/web/web_finger_test.exs b/test/pleroma/web/web_finger_test.exs index aefe7b0c2..923074ed5 100644 --- a/test/pleroma/web/web_finger_test.exs +++ b/test/pleroma/web/web_finger_test.exs @@ -39,6 +39,23 @@ defmodule Pleroma.Web.WebFingerTest do end end + test "requires exact match for Endpoint host or WebFinger domain" do + clear_config([Pleroma.Web.WebFinger, :domain], "pleroma.dev") + user = insert(:user) + + assert {:error, "Couldn't find user"} == + WebFinger.webfinger("#{user.nickname}@#{Pleroma.Web.Endpoint.host()}xxxx", "JSON") + + assert {:error, "Couldn't find user"} == + WebFinger.webfinger("#{user.nickname}@pleroma.devxxxx", "JSON") + + assert {:ok, _} = + WebFinger.webfinger("#{user.nickname}@#{Pleroma.Web.Endpoint.host()}", "JSON") + + assert {:ok, _} = + WebFinger.webfinger("#{user.nickname}@pleroma.dev", "JSON") + end + describe "fingering" do test "returns error for nonsensical input" do assert {:error, _} = WebFinger.finger("bliblablu") From f031532c411872be108bf7e3e042aa76cdba518e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 8 Jul 2025 19:08:44 +0200 Subject: [PATCH 10/10] Fix endorsement state display in relationship view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/endorsement-state.fix | 1 + lib/pleroma/user_relationship.ex | 3 ++- lib/pleroma/web/mastodon_api/views/account_view.ex | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 changelog.d/endorsement-state.fix diff --git a/changelog.d/endorsement-state.fix b/changelog.d/endorsement-state.fix new file mode 100644 index 000000000..cc3b6d9e9 --- /dev/null +++ b/changelog.d/endorsement-state.fix @@ -0,0 +1 @@ +Fix endorsement state display in relationship view diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index 5b48d321a..07b6e46f7 100644 --- a/lib/pleroma/user_relationship.ex +++ b/lib/pleroma/user_relationship.ex @@ -193,7 +193,8 @@ defmodule Pleroma.UserRelationship do {[:mute], []} nil -> - {[:block, :mute, :notification_mute, :reblog_mute], [:block, :inverse_subscription]} + {[:block, :mute, :notification_mute, :reblog_mute, :endorsement], + [:block, :inverse_subscription]} unknown -> raise "Unsupported :subset option value: #{inspect(unknown)}" diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 8d28dd69a..03a2fc55a 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -168,9 +168,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do UserRelationship.exists?( user_relationships, :endorsement, - target, reading_user, - &User.endorses?(&2, &1) + target, + &User.endorses?(&1, &2) ) } end