From 065200e92e3917a84ad64d1e9a4e9361cf5e62a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicole=20Miko=C5=82ajczyk?= Date: Tue, 6 May 2025 12:37:37 +0200 Subject: [PATCH 01/78] Support new Mastodon API for endorsed accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicole Mikołajczyk --- changelog.d/endorsements-api.change | 1 + .../api_spec/operations/account_operation.ex | 26 +++++++++++++-- .../operations/pleroma_account_operation.ex | 19 ----------- .../controllers/account_controller.ex | 24 +++++++++++--- .../controllers/account_controller.ex | 25 +------------- lib/pleroma/web/router.ex | 11 +++++-- .../controllers/account_controller_test.exs | 33 ++++++++++++++++--- .../controllers/account_controller_test.exs | 25 -------------- 8 files changed, 84 insertions(+), 80 deletions(-) create mode 100644 changelog.d/endorsements-api.change diff --git a/changelog.d/endorsements-api.change b/changelog.d/endorsements-api.change new file mode 100644 index 000000000..279392c66 --- /dev/null +++ b/changelog.d/endorsements-api.change @@ -0,0 +1 @@ +Support new Mastodon API for endorsed accounts diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 21a779dcb..d0e0b45ab 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -383,6 +383,28 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do } end + def endorsements_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Endorsements", + description: "Returns endorsed accounts", + operationId: "AccountController.endorsements", + parameters: [ + with_relationships_param(), + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} + ], + responses: %{ + 200 => + Operation.response( + "Array of Accounts", + "application/json", + array_of_accounts() + ), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + def remove_from_followers_operation do %Operation{ tags: ["Account actions"], @@ -485,11 +507,11 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do } end - def endorsements_operation do + def own_endorsements_operation do %Operation{ tags: ["Retrieve account information"], summary: "Endorsements", - operationId: "AccountController.endorsements", + operationId: "AccountController.own_endorsements", description: "Returns endorsed accounts", security: [%{"oAuth" => ["read:accounts"]}], responses: %{ diff --git a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex index b8b37d7cf..b144c2ac0 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex @@ -64,25 +64,6 @@ defmodule Pleroma.Web.ApiSpec.PleromaAccountOperation do } end - def endorsements_operation do - %Operation{ - tags: ["Retrieve account information"], - summary: "Endorsements", - description: "Returns endorsed accounts", - operationId: "PleromaAPI.AccountController.endorsements", - parameters: [with_relationships_param(), id_param()], - responses: %{ - 200 => - Operation.response( - "Array of Accounts", - "application/json", - AccountOperation.array_of_accounts() - ), - 404 => Operation.response("Not Found", "application/json", ApiError) - } - } - end - def subscribe_operation do %Operation{ deprecated: true, diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 68157b0c4..4bd6891d0 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -38,7 +38,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OAuthScopesPlug, %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} - when action in [:show, :followers, :following] + when action in [:show, :followers, :following, :endorsements] ) plug( @@ -50,7 +50,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OAuthScopesPlug, %{scopes: ["read:accounts"]} - when action in [:verify_credentials, :endorsements] + when action in [:verify_credentials, :endorsements, :own_endorsements] ) plug( @@ -89,7 +89,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @relationship_actions [:follow, :unfollow, :remove_from_followers] @needs_account ~W( followers following lists follow unfollow mute unmute block unblock - note endorse unendorse remove_from_followers + note endorse unendorse endorsements remove_from_followers )a plug( @@ -549,6 +549,22 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end end + @doc "GET /api/v1/accounts/:id/endorsements" + def endorsements(%{assigns: %{user: for_user, account: user}} = conn, params) do + users = + user + |> User.endorsed_users_relation(_restrict_deactivated = true) + |> Pleroma.Repo.all() + + conn + |> render("index.json", + for: for_user, + users: users, + as: :user, + embed_relationships: embed_relationships?(params) + ) + end + @doc "POST /api/v1/accounts/:id/remove_from_followers" def remove_from_followers(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do {:error, "Can not unfollow yourself"} @@ -624,7 +640,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end @doc "GET /api/v1/endorsements" - def endorsements(%{assigns: %{user: user}} = conn, params) do + def own_endorsements(%{assigns: %{user: user}} = conn, params) do users = user |> User.endorsed_users_relation(_restrict_deactivated = true) diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index 591391b60..0b8e0b7ad 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do only: [ json_response: 3, add_link_headers: 2, - embed_relationships?: 1, assign_account_by_id: 2 ] @@ -45,12 +44,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do %{scopes: ["read:favourites"], fallback: :proceed_unauthenticated} when action == :favourites ) - plug( - OAuthScopesPlug, - %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} - when action == :endorsements - ) - plug( OAuthScopesPlug, %{scopes: ["read:accounts"]} when action == :birthdays @@ -60,7 +53,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do plug( :assign_account_by_id - when action in [:favourites, :endorsements, :subscribe, :unsubscribe] + when action in [:favourites, :subscribe, :unsubscribe] ) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaAccountOperation @@ -109,22 +102,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do ) end - @doc "GET /api/v1/pleroma/accounts/:id/endorsements" - def endorsements(%{assigns: %{user: for_user, account: user}} = conn, params) do - users = - user - |> User.endorsed_users_relation(_restrict_deactivated = true) - |> Pleroma.Repo.all() - - conn - |> render("index.json", - for: for_user, - users: users, - as: :user, - embed_relationships: embed_relationships?(params) - ) - end - @doc "POST /api/v1/pleroma/accounts/:id/subscribe" def subscribe(%{assigns: %{user: user, account: subscription_target}} = conn, _params) do with {:ok, _subscription} <- User.subscribe(user, subscription_target) do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index f2f9d7246..b3f915bd9 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -607,7 +607,6 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:api) get("/accounts/:id/favourites", AccountController, :favourites) - get("/accounts/:id/endorsements", AccountController, :endorsements) get("/statuses/:id/quotes", StatusController, :quotes) end @@ -636,6 +635,11 @@ defmodule Pleroma.Web.Router do get("/accounts/:id/scrobbles", ScrobbleController, :index) end + scope "/api/v1/pleroma", Pleroma.Web.MastodonAPI do + pipe_through(:api) + get("/accounts/:id/endorsements", AccountController, :endorsements) + end + scope "/api/v2/pleroma", Pleroma.Web.PleromaAPI do scope [] do pipe_through(:authenticated_api) @@ -652,7 +656,7 @@ defmodule Pleroma.Web.Router do get("/accounts/relationships", AccountController, :relationships) get("/accounts/familiar_followers", AccountController, :familiar_followers) get("/accounts/:id/lists", AccountController, :lists) - get("/endorsements", AccountController, :endorsements) + get("/endorsements", AccountController, :own_endorsements) get("/blocks", AccountController, :blocks) get("/mutes", AccountController, :mutes) @@ -666,6 +670,8 @@ defmodule Pleroma.Web.Router do post("/accounts/:id/note", AccountController, :note) post("/accounts/:id/pin", AccountController, :endorse) post("/accounts/:id/unpin", AccountController, :unendorse) + post("/accounts/:id/endorse", AccountController, :endorse) + post("/accounts/:id/unendorse", AccountController, :unendorse) post("/accounts/:id/remove_from_followers", AccountController, :remove_from_followers) get("/conversations", ConversationController, :index) @@ -781,6 +787,7 @@ defmodule Pleroma.Web.Router do get("/accounts/:id/statuses", AccountController, :statuses) get("/accounts/:id/followers", AccountController, :followers) get("/accounts/:id/following", AccountController, :following) + get("/accounts/:id/endorsements", AccountController, :endorsements) get("/accounts/:id", AccountController, :show) post("/accounts", AccountController, :create) diff --git a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs index cd3107f32..245d4374c 100644 --- a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs @@ -2123,7 +2123,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do assert %{"id" => ^id1, "endorsed" => true} = conn |> put_req_header("content-type", "application/json") - |> post("/api/v1/accounts/#{id1}/pin") + |> post("/api/v1/accounts/#{id1}/endorse") |> json_response_and_validate_schema(200) assert [%{"id" => ^id1}] = @@ -2142,7 +2142,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do assert %{"id" => ^id1, "endorsed" => false} = conn |> put_req_header("content-type", "application/json") - |> post("/api/v1/accounts/#{id1}/unpin") + |> post("/api/v1/accounts/#{id1}/unendorse") |> json_response_and_validate_schema(200) assert [] = @@ -2161,15 +2161,40 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do conn |> put_req_header("content-type", "application/json") - |> post("/api/v1/accounts/#{id1}/pin") + |> post("/api/v1/accounts/#{id1}/endorse") |> json_response_and_validate_schema(200) assert %{"error" => "You have already pinned the maximum number of users"} = conn |> assign(:user, user) - |> post("/api/v1/accounts/#{id2}/pin") + |> post("/api/v1/accounts/#{id2}/endorse") |> json_response_and_validate_schema(400) end + + test "returns a list of pinned accounts", %{conn: conn} do + clear_config([:instance, :max_endorsed_users], 3) + + %{id: id1} = user1 = insert(:user) + %{id: id2} = user2 = insert(:user) + %{id: id3} = user3 = insert(:user) + + CommonAPI.follow(user2, user1) + CommonAPI.follow(user3, user1) + + User.endorse(user1, user2) + User.endorse(user1, user3) + + [%{"id" => ^id2}, %{"id" => ^id3}] = + conn + |> get("/api/v1/accounts/#{id1}/endorsements") + |> json_response_and_validate_schema(200) + end + + test "returns 404 error when specified user is not exist", %{conn: conn} do + conn = get(conn, "/api/v1/accounts/test/endorsements") + + assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} + end end describe "familiar followers" do 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 61880e2c0..38a928f30 100644 --- a/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs @@ -280,31 +280,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do end end - describe "account endorsements" do - test "returns a list of pinned accounts", %{conn: conn} do - %{id: id1} = user1 = insert(:user) - %{id: id2} = user2 = insert(:user) - %{id: id3} = user3 = insert(:user) - - CommonAPI.follow(user2, user1) - CommonAPI.follow(user3, user1) - - User.endorse(user1, user2) - User.endorse(user1, user3) - - [%{"id" => ^id2}, %{"id" => ^id3}] = - conn - |> get("/api/v1/pleroma/accounts/#{id1}/endorsements") - |> json_response_and_validate_schema(200) - end - - test "returns 404 error when specified user is not exist", %{conn: conn} do - conn = get(conn, "/api/v1/pleroma/accounts/test/endorsements") - - assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} - end - end - describe "birthday reminders" do test "returns a list of friends having birthday on specified day" do %{user: user, conn: conn} = oauth_access(["read:accounts"]) From cb7086cb187cff202803806ede509b3075a3718f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sun, 7 Sep 2025 22:37:48 +0200 Subject: [PATCH 02/78] Use end-of-string in regex for local `get_by_nickname` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/local-nickname-regex.fix | 1 + lib/pleroma/user.ex | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/local-nickname-regex.fix diff --git a/changelog.d/local-nickname-regex.fix b/changelog.d/local-nickname-regex.fix new file mode 100644 index 000000000..81ddd9cff --- /dev/null +++ b/changelog.d/local-nickname-regex.fix @@ -0,0 +1 @@ +Use end-of-string in regex for local `get_by_nickname` diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 84551afd5..21ac31f33 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1364,7 +1364,7 @@ defmodule Pleroma.User do @spec get_by_nickname(String.t()) :: User.t() | nil def get_by_nickname(nickname) do Repo.get_by(User, nickname: nickname) || - if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do + if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()}$)i, nickname) do Repo.get_by(User, nickname: local_nickname(nickname)) end end From 3be0d206bdb1b182002b9f3b2ab9cda5d2ffe89d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sun, 7 Sep 2025 22:52:11 +0200 Subject: [PATCH 03/78] Allow "invisible" and "ellipsis" classes for span tags to match Mastodon behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/scrubber-span-classes.change | 1 + priv/scrubbers/default.ex | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 changelog.d/scrubber-span-classes.change diff --git a/changelog.d/scrubber-span-classes.change b/changelog.d/scrubber-span-classes.change new file mode 100644 index 000000000..4ba5dfa91 --- /dev/null +++ b/changelog.d/scrubber-span-classes.change @@ -0,0 +1 @@ +Allow "invisible" and "ellipsis" classes for span tags to match Mastodon behavior diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index dad9dc1a1..def92ae66 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -80,7 +80,9 @@ defmodule Pleroma.HTML.Scrubber.Default do Meta.allow_tag_with_this_attribute_values(:span, "class", [ "h-card", "recipients-inline", - "quote-inline" + "quote-inline", + "invisible", + "ellipsis" ]) Meta.allow_tag_with_these_attributes(:span, ["lang"]) From c3c57ef6c46bb5bd610bfa117445b6ff906fc687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sun, 7 Sep 2025 23:15:50 +0200 Subject: [PATCH 04/78] remove duplicated code from notificationview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/notification-view-deduplicate.skip | 1 + .../web/mastodon_api/views/notification_view.ex | 16 ++-------------- 2 files changed, 3 insertions(+), 14 deletions(-) create mode 100644 changelog.d/notification-view-deduplicate.skip diff --git a/changelog.d/notification-view-deduplicate.skip b/changelog.d/notification-view-deduplicate.skip new file mode 100644 index 000000000..769352692 --- /dev/null +++ b/changelog.d/notification-view-deduplicate.skip @@ -0,0 +1 @@ +remove duplicated code from notificationview diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index c277af98b..27532c42a 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -106,27 +106,15 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do } case notification.type do - "mention" -> + type when type in ["mention", "status", "poll"] -> put_status(response, activity, reading_user, status_render_opts) - "status" -> - put_status(response, activity, reading_user, status_render_opts) - - "favourite" -> - put_status(response, parent_activity_fn.(), reading_user, status_render_opts) - - "reblog" -> - put_status(response, parent_activity_fn.(), reading_user, status_render_opts) - - "update" -> + type when type in ["favourite", "reblog", "update"] -> put_status(response, parent_activity_fn.(), reading_user, status_render_opts) "move" -> put_target(response, activity, reading_user, %{}) - "poll" -> - put_status(response, activity, reading_user, status_render_opts) - "pleroma:emoji_reaction" -> response |> put_status(parent_activity_fn.(), reading_user, status_render_opts) From 32bc8ec5805fe45ef8f976f68539a4119a3989e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sun, 7 Sep 2025 23:12:31 +0200 Subject: [PATCH 05/78] Stream marker updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/stream-marker-updates.add | 1 + lib/pleroma/notification.ex | 13 +++++++--- lib/pleroma/web/streamer.ex | 11 ++++++++ lib/pleroma/web/views/streamer_view.ex | 14 +++++++++++ test/pleroma/notification_test.exs | 35 ++++++++++++++++++++++++-- 5 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 changelog.d/stream-marker-updates.add diff --git a/changelog.d/stream-marker-updates.add b/changelog.d/stream-marker-updates.add new file mode 100644 index 000000000..e9fda3e59 --- /dev/null +++ b/changelog.d/stream-marker-updates.add @@ -0,0 +1 @@ +Stream marker updates diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 75f4ba503..59b32bbf0 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -281,10 +281,15 @@ defmodule Pleroma.Notification do select: n.id ) - Multi.new() - |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) - |> Marker.multi_set_last_read_id(user, "notifications") - |> Repo.transaction() + {:ok, %{marker: marker}} = + Multi.new() + |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() + + Streamer.stream(["user", "user:notification"], marker) + + {:ok, %{marker: marker}} end @spec read_one(User.t(), String.t()) :: diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index cc149e04c..aba42ee78 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.Streamer do alias Pleroma.Chat.MessageReference alias Pleroma.Config alias Pleroma.Conversation.Participation + alias Pleroma.Marker alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User @@ -321,6 +322,16 @@ defmodule Pleroma.Web.Streamer do end) end + defp do_stream(topic, %Marker{} = marker) do + Registry.dispatch(@registry, "#{topic}:#{marker.user_id}", fn list -> + Enum.each(list, fn {pid, _auth} -> + text = StreamerView.render("marker.json", marker) + + send(pid, {:text, text}) + end) + end) + end + defp do_stream(topic, item) do Logger.debug("Trying to push to #{topic}") Logger.debug("Pushing item to #{topic}") diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 079a37351..5806ba9ab 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.StreamerView do alias Pleroma.Activity alias Pleroma.Conversation.Participation + alias Pleroma.Marker alias Pleroma.Notification alias Pleroma.User alias Pleroma.Web.MastodonAPI.NotificationView @@ -164,6 +165,19 @@ defmodule Pleroma.Web.StreamerView do |> Jason.encode!() end + def render("marker.json", %Marker{} = marker) do + %{ + event: "marker", + payload: + Pleroma.Web.MastodonAPI.MarkerView.render( + "markers.json", + markers: [marker] + ) + |> Jason.encode!() + } + |> Jason.encode!() + end + def render("pleroma_respond.json", %{type: type, result: result} = params) do %{ event: "pleroma:respond", diff --git a/test/pleroma/notification_test.exs b/test/pleroma/notification_test.exs index 4b20e07cf..2ad950e84 100644 --- a/test/pleroma/notification_test.exs +++ b/test/pleroma/notification_test.exs @@ -17,6 +17,7 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.NotificationView + alias Pleroma.Web.Streamer setup do Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig) @@ -446,8 +447,7 @@ defmodule Pleroma.NotificationTest do describe "set_read_up_to()" do test "it sets all notifications as read up to a specified notification ID" do - user = insert(:user) - other_user = insert(:user) + [user, other_user] = insert_pair(:user) {:ok, _activity} = CommonAPI.post(user, %{ @@ -486,6 +486,37 @@ defmodule Pleroma.NotificationTest do assert m.last_read_id == to_string(n2.id) end + + @tag needs_streamer: true + test "it send updated marker to the 'user' and the 'user:notification' stream" do + %{user: user, token: oauth_token} = oauth_access(["read"]) + other_user = insert(:user) + + {:ok, _activity} = + CommonAPI.post(other_user, %{ + status: "hi @#{user.nickname}!" + }) + + [%{id: notification_id}] = Notification.for_user(user) + + notification_id = to_string(notification_id) + + task = + Task.async(fn -> + {:ok, _topic} = + Streamer.get_topic_and_add_socket("user:notification", user, oauth_token) + + assert_receive {:text, event}, 4_000 + + assert %{"event" => "marker", "payload" => payload} = Jason.decode!(event) + + assert %{"notifications" => %{"last_read_id" => ^notification_id}} = + Jason.decode!(payload) + end) + + Notification.set_read_up_to(user, notification_id) + Task.await(task) + end end describe "for_user_since/2" do From 546c03b2c6eb56aa35f583a9ad103679171f3b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 19 Sep 2025 16:27:34 +0200 Subject: [PATCH 06/78] `remote_url` links to unproxied URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/remote-url.fix | 1 + lib/pleroma/web/mastodon_api/views/status_view.ex | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 changelog.d/remote-url.fix diff --git a/changelog.d/remote-url.fix b/changelog.d/remote-url.fix new file mode 100644 index 000000000..9be84a878 --- /dev/null +++ b/changelog.d/remote-url.fix @@ -0,0 +1 @@ +`remote_url` links to unproxied URL diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 4b5ac9c3b..c1c695419 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -602,7 +602,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do def render("attachment.json", %{attachment: attachment}) do [attachment_url | _] = attachment["url"] media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image" - href = attachment_url["href"] |> MediaProxy.url() + href_remote = attachment_url["href"] + href = href_remote |> MediaProxy.url() href_preview = attachment_url["href"] |> MediaProxy.preview_url() meta = render("attachment_meta.json", %{attachment: attachment}) @@ -641,7 +642,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do %{ id: attachment_id, url: href, - remote_url: href, + remote_url: href_remote, preview_url: href_preview, text_url: href, type: type, From ef9bcb373a96efc38091246cef4d93fa2788b6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sat, 27 Sep 2025 11:44:45 +0200 Subject: [PATCH 07/78] Use Mastodon-compatible route for quotes list and param for quotes count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/mastodon-quotes-updates.change | 1 + .../API/differences_in_mastoapi_responses.md | 1 - .../operations/pleroma_status_operation.ex | 3 +- .../api_spec/operations/status_operation.ex | 21 ++++++++ lib/pleroma/web/api_spec/schemas/status.ex | 9 +++- .../controllers/status_controller.ex | 43 ++++++++++++++++- .../web/mastodon_api/views/status_view.ex | 1 + .../controllers/status_controller.ex | 44 ++--------------- lib/pleroma/web/router.ex | 1 + .../controllers/status_controller_test.exs | 43 +++++++++++++++++ .../mastodon_api/views/status_view_test.exs | 3 +- .../controllers/status_controller_test.exs | 48 +++++-------------- 12 files changed, 137 insertions(+), 81 deletions(-) create mode 100644 changelog.d/mastodon-quotes-updates.change diff --git a/changelog.d/mastodon-quotes-updates.change b/changelog.d/mastodon-quotes-updates.change new file mode 100644 index 000000000..4c01ec106 --- /dev/null +++ b/changelog.d/mastodon-quotes-updates.change @@ -0,0 +1 @@ +Use Mastodon-compatible route for quotes list and param for quotes count diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index 4326c4255..a3d33fbcd 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -39,7 +39,6 @@ Has these additional fields under the `pleroma` object: - `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. - `parent_visible`: If the parent of this post is visible to the user or not. - `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise. -- `quotes_count`: the count of status quotes. - `non_anonymous`: true if the source post specifies the poll results are not anonymous. Currently only implemented by Smithereen. - `bookmark_folder`: the ID of the folder bookmark is stored within (if any). - `list_id`: the ID of the list the post is addressed to (if any, only returned to author). diff --git a/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex index 77c604952..c0919f726 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex @@ -19,7 +19,8 @@ defmodule Pleroma.Web.ApiSpec.PleromaStatusOperation do %Operation{ tags: ["Retrieve status information"], summary: "Quoted by", - description: "View quotes for a given status", + deprecated: true, + description: "View quotes for a given status. Use /api/v1/statuses/:id/quotes instead.", operationId: "PleromaAPI.StatusController.quotes", parameters: [id_param() | pagination_params()], security: [%{"oAuth" => ["read:statuses"]}], diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 75ecda321..500fbb64e 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -549,6 +549,27 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do } end + def quotes_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Quoted by", + description: "View quotes for a given status", + operationId: "StatusController.quotes", + parameters: [id_param() | pagination_params()], + security: [%{"oAuth" => ["read:statuses"]}], + responses: %{ + 200 => + Operation.response( + "Array of Status", + "application/json", + array_of_statuses() + ), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + def array_of_statuses do %Schema{type: :array, items: Status, example: [Status.schema().example]} end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 25548d75b..ac1735e35 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -219,7 +219,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do }, quotes_count: %Schema{ type: :integer, - description: "How many statuses quoted this status" + deprecated: true, + description: + "How many statuses quoted this status. Deprecated, use `quotes_count` from parent object instead." }, local: %Schema{ type: :boolean, @@ -259,6 +261,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do } }, poll: %Schema{allOf: [Poll], nullable: true, description: "The poll attached to the status"}, + quotes_count: %Schema{ + type: :integer, + description: "How many statuses quoted this status." + }, reblog: %Schema{ allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], nullable: true, @@ -385,6 +391,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do "quotes_count" => 0 }, "poll" => nil, + "quotes_count" => 0, "reblog" => nil, "reblogged" => false, "reblogs_count" => 0, diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 32874d464..218ee7e28 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do only: [try_render: 3, add_link_headers: 2] require Ecto.Query + require Pleroma.Constants alias Pleroma.Activity alias Pleroma.Bookmark @@ -41,7 +42,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do :show, :context, :show_history, - :show_source + :show_source, + :quotes ] ) @@ -629,6 +631,45 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do ) end + @doc "GET /api/v1/statuses/:id/quotes" + def quotes( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id} = params}}} = + conn, + _ + ) do + with %Activity{object: object} = activity <- Activity.get_by_id_with_object(id), + true <- Visibility.visible_for_user?(activity, user) do + params = + params + |> Map.put(:type, "Create") + |> Map.put(:blocking_user, user) + |> Map.put(:quote_url, object.data["id"]) + + recipients = + if user do + [Pleroma.Constants.as_public()] ++ [user.ap_id | User.following(user)] + else + [Pleroma.Constants.as_public()] + end + + activities = + recipients + |> ActivityPub.fetch_activities(params) + |> Enum.reverse() + + conn + |> add_link_headers(activities) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) + else + nil -> {:error, :not_found} + false -> {:error, :not_found} + end + end + defp put_application(params, %{assigns: %{token: %Token{user: %User{} = user} = token}} = _conn) do if user.disclose_client do %{client_name: client_name, website: website} = Repo.preload(token, :app).app diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 4b5ac9c3b..a6232bf86 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -447,6 +447,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do application: build_application(object.data["generator"]), language: get_language(object), emojis: build_emojis(object.data["emoji"]), + quotes_count: object.data["quotesCount"] || 0, pleroma: %{ local: activity.local, conversation_id: get_context_id(activity), diff --git a/lib/pleroma/web/pleroma_api/controllers/status_controller.ex b/lib/pleroma/web/pleroma_api/controllers/status_controller.ex index 482662fdd..c72b2f222 100644 --- a/lib/pleroma/web/pleroma_api/controllers/status_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/status_controller.ex @@ -5,16 +5,9 @@ defmodule Pleroma.Web.PleromaAPI.StatusController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] - require Ecto.Query require Pleroma.Constants - alias Pleroma.Activity - alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.Plugs.OAuthScopesPlug plug(Pleroma.Web.ApiSpec.CastAndValidate) @@ -29,38 +22,9 @@ defmodule Pleroma.Web.PleromaAPI.StatusController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaStatusOperation @doc "GET /api/v1/pleroma/statuses/:id/quotes" - def quotes(%{assigns: %{user: user}} = conn, %{id: id} = params) do - with %Activity{object: object} = activity <- Activity.get_by_id_with_object(id), - true <- Visibility.visible_for_user?(activity, user) do - params = - params - |> Map.put(:type, "Create") - |> Map.put(:blocking_user, user) - |> Map.put(:quote_url, object.data["id"]) - - recipients = - if user do - [Pleroma.Constants.as_public()] ++ [user.ap_id | User.following(user)] - else - [Pleroma.Constants.as_public()] - end - - activities = - recipients - |> ActivityPub.fetch_activities(params) - |> Enum.reverse() - - conn - |> add_link_headers(activities) - |> put_view(StatusView) - |> render("index.json", - activities: activities, - for: user, - as: :activity - ) - else - nil -> {:error, :not_found} - false -> {:error, :not_found} - end + def quotes(conn, _params) do + conn + |> put_view(Pleroma.Web.MastodonAPI.StatusView) + |> Pleroma.Web.MastodonAPI.StatusController.call(:quotes) end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index cd9cfd3ed..81c6d2201 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -742,6 +742,7 @@ defmodule Pleroma.Web.Router do post("/statuses/:id/mute", StatusController, :mute_conversation) post("/statuses/:id/unmute", StatusController, :unmute_conversation) post("/statuses/:id/translate", StatusController, :translate) + get("/statuses/:id/quotes", StatusController, :quotes) post("/push/subscription", SubscriptionController, :create) get("/push/subscription", SubscriptionController, :show) 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 25a17d5c1..c19a6ca4e 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2541,4 +2541,47 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do |> json_response_and_validate_schema(404) end end + + describe "getting quotes of a specified post" do + setup do + [current_user, user] = insert_pair(:user) + %{user: current_user, conn: conn} = oauth_access(["read:statuses"], user: current_user) + [current_user: current_user, user: user, conn: conn] + end + + test "shows quotes of a post", %{conn: conn} do + user = insert(:user) + activity = insert(:note_activity) + + {:ok, quote_post} = CommonAPI.post(user, %{status: "quoat", quote_id: activity.id}) + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/quotes") + |> json_response_and_validate_schema(:ok) + + [status] = response + + assert length(response) == 1 + assert status["id"] == quote_post.id + end + + test "returns 404 error when a post can't be seen", %{conn: conn} do + activity = insert(:direct_note_activity) + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/quotes") + + assert json_response_and_validate_schema(response, 404) == %{"error" => "Record not found"} + end + + test "returns 404 error when a post does not exist", %{conn: conn} do + response = + conn + |> get("/api/v1/statuses/idontexist/quotes") + + assert json_response_and_validate_schema(response, 404) == %{"error" => "Record not found"} + 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 e6a164d72..8c44d17a2 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -344,7 +344,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do quotes_count: 0, bookmark_folder: nil, list_id: nil - } + }, + quotes_count: 0 } assert status == expected diff --git a/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs index f942f0556..73d497d92 100644 --- a/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs @@ -9,46 +9,22 @@ defmodule Pleroma.Web.PleromaAPI.StatusControllerTest do import Pleroma.Factory - describe "getting quotes of a specified post" do - setup do - [current_user, user] = insert_pair(:user) - %{user: current_user, conn: conn} = oauth_access(["read:statuses"], user: current_user) - [current_user: current_user, user: user, conn: conn] - end + test "/quotes fallback works" do + [current_user, user] = insert_pair(:user) + %{conn: conn} = oauth_access(["read:statuses"], user: current_user) - test "shows quotes of a post", %{conn: conn} do - user = insert(:user) - activity = insert(:note_activity) + activity = insert(:note_activity) - {:ok, quote_post} = CommonAPI.post(user, %{status: "quoat", quote_id: activity.id}) + {:ok, quote_post} = CommonAPI.post(user, %{status: "quoat", quote_id: activity.id}) - response = - conn - |> get("/api/v1/pleroma/statuses/#{activity.id}/quotes") - |> json_response_and_validate_schema(:ok) + response = + conn + |> get("/api/v1/pleroma/statuses/#{activity.id}/quotes") + |> json_response_and_validate_schema(:ok) - [status] = response + [status] = response - assert length(response) == 1 - assert status["id"] == quote_post.id - end - - test "returns 404 error when a post can't be seen", %{conn: conn} do - activity = insert(:direct_note_activity) - - response = - conn - |> get("/api/v1/pleroma/statuses/#{activity.id}/quotes") - - assert json_response_and_validate_schema(response, 404) == %{"error" => "Record not found"} - end - - test "returns 404 error when a post does not exist", %{conn: conn} do - response = - conn - |> get("/api/v1/pleroma/statuses/idontexist/quotes") - - assert json_response_and_validate_schema(response, 404) == %{"error" => "Record not found"} - end + assert length(response) == 1 + assert status["id"] == quote_post.id end end From 889938d76ac28783a59aa38abfb00885808b8a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 10 Oct 2025 05:21:49 +0200 Subject: [PATCH 08/78] Scrubber: Allow `quote-inline` class in

tags used by Mastodon quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/scrubber-inline-quotes-mastodon.add | 1 + priv/scrubbers/default.ex | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 changelog.d/scrubber-inline-quotes-mastodon.add diff --git a/changelog.d/scrubber-inline-quotes-mastodon.add b/changelog.d/scrubber-inline-quotes-mastodon.add new file mode 100644 index 000000000..a8006e423 --- /dev/null +++ b/changelog.d/scrubber-inline-quotes-mastodon.add @@ -0,0 +1 @@ +Scrubber: Allow `quote-inline` class in

tags used by Mastodon quotes \ No newline at end of file diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index dad9dc1a1..ab82663be 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -83,6 +83,8 @@ defmodule Pleroma.HTML.Scrubber.Default do "quote-inline" ]) + Meta.allow_tag_with_this_attribute_values(:p, "class", ["quote-inline"]) + Meta.allow_tag_with_these_attributes(:span, ["lang"]) Meta.allow_tag_with_this_attribute_values(:code, "class", ["inline"]) From 2012e83e202b78d14f5d3135b98a503c2797dbda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 10 Oct 2025 04:44:37 +0200 Subject: [PATCH 09/78] Allow filtering users with `accepts_chat_messages` capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/filter-user-capabilities.add | 1 + lib/pleroma/user/search.ex | 14 ++++++++++++-- .../web/api_spec/operations/search_operation.ex | 6 ++++++ .../mastodon_api/controllers/search_controller.ex | 1 + ...00_add_accepts_chat_messages_index_to_users.exs | 7 +++++++ test/pleroma/user_search_test.exs | 8 ++++++++ 6 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 changelog.d/filter-user-capabilities.add create mode 100644 priv/repo/migrations/20230703215000_add_accepts_chat_messages_index_to_users.exs diff --git a/changelog.d/filter-user-capabilities.add b/changelog.d/filter-user-capabilities.add new file mode 100644 index 000000000..fe2459210 --- /dev/null +++ b/changelog.d/filter-user-capabilities.add @@ -0,0 +1 @@ +Allow filtering users with `accepts_chat_messages` capability \ No newline at end of file diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index a7fb8fb83..851745714 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -16,6 +16,7 @@ defmodule Pleroma.User.Search do following = Keyword.get(opts, :following, false) result_limit = Keyword.get(opts, :limit, @limit) offset = Keyword.get(opts, :offset, 0) + capabilities = Keyword.get(opts, :capabilities, []) for_user = Keyword.get(opts, :for_user) @@ -32,7 +33,7 @@ defmodule Pleroma.User.Search do results = query_string - |> search_query(for_user, following, top_user_ids) + |> search_query(for_user, following, top_user_ids, capabilities) |> Pagination.fetch_paginated(%{"offset" => offset, "limit" => result_limit}, :offset) results @@ -80,7 +81,7 @@ defmodule Pleroma.User.Search do end end - defp search_query(query_string, for_user, following, top_user_ids) do + defp search_query(query_string, for_user, following, top_user_ids, capabilities) do for_user |> base_query(following) |> filter_blocked_user(for_user) @@ -94,6 +95,7 @@ defmodule Pleroma.User.Search do |> subquery() |> order_by(desc: :search_rank) |> maybe_restrict_local(for_user) + |> maybe_restrict_accepting_chat_messages(capabilities) |> filter_deactivated_users() end @@ -214,6 +216,14 @@ defmodule Pleroma.User.Search do end end + defp maybe_restrict_accepting_chat_messages(query, capabilities) do + if "accepts_chat_messages" in capabilities do + from(q in query, where: q.accepts_chat_messages == true) + else + query + end + end + defp limit, do: Pleroma.Config.get([:instance, :limit_to_local_content], :unauthenticated) defp restrict_local(q), do: where(q, [u], u.local == true) diff --git a/lib/pleroma/web/api_spec/operations/search_operation.ex b/lib/pleroma/web/api_spec/operations/search_operation.ex index c2afe8e18..95d9b063a 100644 --- a/lib/pleroma/web/api_spec/operations/search_operation.ex +++ b/lib/pleroma/web/api_spec/operations/search_operation.ex @@ -46,6 +46,12 @@ defmodule Pleroma.Web.ApiSpec.SearchOperation do :query, %Schema{allOf: [BooleanLike], default: false}, "Only include accounts that the user is following" + ), + Operation.parameter( + :capabilities, + :query, + %Schema{type: :array, items: %Schema{type: :string, enum: ["accepts_chat_messages"]}}, + "Only include accounts with given capabilities" ) ], responses: %{ diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 53f1216fd..1bfd98eb3 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -91,6 +91,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do limit: min(params[:limit], @search_limit), offset: params[:offset], type: params[:type], + capabilities: params[:capabilities], author: get_author(params), embed_relationships: ControllerHelper.embed_relationships?(params), for_user: user diff --git a/priv/repo/migrations/20230703215000_add_accepts_chat_messages_index_to_users.exs b/priv/repo/migrations/20230703215000_add_accepts_chat_messages_index_to_users.exs new file mode 100644 index 000000000..85a9f27b7 --- /dev/null +++ b/priv/repo/migrations/20230703215000_add_accepts_chat_messages_index_to_users.exs @@ -0,0 +1,7 @@ +defmodule Pleroma.Repo.Migrations.AddAcceptsChatMessagesIndexToUsers do + use Ecto.Migration + + def change do + create(index(:users, [:accepts_chat_messages])) + end +end diff --git a/test/pleroma/user_search_test.exs b/test/pleroma/user_search_test.exs index 1af9a1493..75cfbd2db 100644 --- a/test/pleroma/user_search_test.exs +++ b/test/pleroma/user_search_test.exs @@ -366,5 +366,13 @@ defmodule Pleroma.UserSearchTest do assert user == result |> Map.put(:search_rank, nil) |> Map.put(:search_type, nil) end + + test "find users accepting chat messages only" do + user1 = insert(:user, nickname: "user1", accepts_chat_messages: true) + insert(:user, nickname: "user2", accepts_chat_messages: false) + + [found_user1] = User.search("user", capabilities: ["accepts_chat_messages"]) + assert found_user1.id == user1.id + end end end From 50e3cc67fc09bd9baf4dcb8f8abaa1d3b7fca374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 14 Oct 2025 20:29:06 +0200 Subject: [PATCH 10/78] Redirect /users/:nickname.rss to /users/:nickname/feed.rss instead of .atom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/rss-redirect.change | 1 + lib/pleroma/web/feed/user_controller.ex | 7 +++++-- test/pleroma/web/feed/user_controller_test.exs | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 changelog.d/rss-redirect.change diff --git a/changelog.d/rss-redirect.change b/changelog.d/rss-redirect.change new file mode 100644 index 000000000..cd8b099aa --- /dev/null +++ b/changelog.d/rss-redirect.change @@ -0,0 +1 @@ +Redirect /users/:nickname.rss to /users/:nickname/feed.rss instead of .atom \ No newline at end of file diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 304313068..8a52d98e0 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -28,9 +28,12 @@ defmodule Pleroma.Web.Feed.UserController do ActivityPubController.call(conn, :user) end - def feed_redirect(conn, %{"nickname" => nickname}) do + def feed_redirect(%{assigns: assigns} = conn, %{"nickname" => nickname}) do + format = Map.get(assigns, :format, "atom") + format = if format in ["atom", "rss"], do: format, else: "atom" + with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do - redirect(conn, external: "#{Routes.user_feed_url(conn, :feed, user.nickname)}.atom") + redirect(conn, external: "#{Routes.user_feed_url(conn, :feed, user.nickname)}.#{format}") end end diff --git a/test/pleroma/web/feed/user_controller_test.exs b/test/pleroma/web/feed/user_controller_test.exs index 0a3aaff5c..916531cd5 100644 --- a/test/pleroma/web/feed/user_controller_test.exs +++ b/test/pleroma/web/feed/user_controller_test.exs @@ -282,6 +282,21 @@ defmodule Pleroma.Web.Feed.UserControllerTest do "#{Pleroma.Web.Endpoint.url()}/users/#{user.nickname}/feed.atom" end + test "redirects to rss feed when explicitly requested", %{conn: conn} do + note_activity = insert(:note_activity) + user = User.get_cached_by_ap_id(note_activity.data["actor"]) + + conn = + conn + |> put_req_header("accept", "application/xml") + |> get("/users/#{user.nickname}.rss") + + assert conn.status == 302 + + assert redirected_to(conn) == + "#{Pleroma.Web.Endpoint.url()}/users/#{user.nickname}/feed.rss" + end + test "with non-html / non-json format, it returns error when user is not found", %{conn: conn} do response = conn From 7e34d72860decc2d32704689903d3ccb0338f88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Thu, 2 Oct 2025 13:33:50 +0200 Subject: [PATCH 11/78] Support Mozhi as translation provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- config/description.exs | 13 +++ lib/pleroma/language/translation/mozhi.ex | 109 ++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 lib/pleroma/language/translation/mozhi.ex diff --git a/config/description.exs b/config/description.exs index c61a344e8..68a2fc34e 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3588,6 +3588,19 @@ config :pleroma, :config_description, [ label: "LibreTranslate API Key", type: :string, suggestions: ["YOUR_API_KEY"] + }, + %{ + group: {:subgroup, Pleroma.Language.Translation.Mozhi}, + key: :base_url, + label: "Mozhi instance URL", + type: :string + }, + %{ + group: {:subgroup, Pleroma.Language.Translation.Mozhi}, + key: :engine, + label: "Engine used for Mozhi", + type: :string, + suggestions: ["libretranslate"] } ] } diff --git a/lib/pleroma/language/translation/mozhi.ex b/lib/pleroma/language/translation/mozhi.ex new file mode 100644 index 000000000..958f2ef57 --- /dev/null +++ b/lib/pleroma/language/translation/mozhi.ex @@ -0,0 +1,109 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.Translation.Mozhi do + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + alias Pleroma.Language.Translation.Provider + + use Provider + + @behaviour Provider + + @name "Mozhi" + + @impl Provider + def configured?, do: not_empty_string(base_url()) and not_empty_string(engine()) + + @impl Provider + def translate(content, source_language, target_language) do + endpoint = + base_url() + |> URI.merge("/api/translate") + |> URI.to_string() + + case Pleroma.HTTP.get( + endpoint <> + "?" <> + URI.encode_query(%{ + engine: engine(), + text: content, + from: source_language, + to: target_language + }), + [{"Accept", "application/json"}] + ) do + {:ok, %{status: 200} = res} -> + %{ + "translated-text" => content, + "source_language" => source_language + } = Jason.decode!(res.body) + + {:ok, + %{ + content: content, + detected_source_language: source_language, + provider: @name + }} + + _ -> + {:error, :internal_server_error} + end + end + + @impl Provider + def supported_languages(type) when type in [:source, :target] do + path = + case type do + :source -> "/api/source_languages" + :target -> "/api/target_languages" + end + + endpoint = + base_url() + |> URI.merge(path) + |> URI.to_string() + + case Pleroma.HTTP.get( + endpoint <> + "?" <> + URI.encode_query(%{ + engine: engine() + }), + [{"Accept", "application/json"}] + ) do + {:ok, %{status: 200} = res} -> + languages = + Jason.decode!(res.body) + |> Enum.map(fn %{"Id" => language} -> language end) + + {:ok, languages} + + _ -> + {:error, :internal_server_error} + end + end + + @impl Provider + def languages_matrix do + with {:ok, source_languages} <- supported_languages(:source), + {:ok, target_languages} <- supported_languages(:target) do + {:ok, + Map.new(source_languages, fn language -> {language, target_languages -- [language]} end)} + else + {:error, error} -> {:error, error} + end + end + + @impl Provider + def name, do: @name + + defp base_url do + Pleroma.Config.get([__MODULE__, :base_url]) + end + + defp engine do + Pleroma.Config.get([__MODULE__, :engine]) + end +end From d56433be6994f44895a623a5f600195a9aacb02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 15 Oct 2025 10:30:26 +0200 Subject: [PATCH 12/78] List Mozhi in suggestions for translation providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- config/description.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 68a2fc34e..2755c36f9 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3558,7 +3558,8 @@ config :pleroma, :config_description, [ type: :module, suggestions: [ Pleroma.Language.Translation.Deepl, - Pleroma.Language.Translation.Libretranslate + Pleroma.Language.Translation.Libretranslate, + Pleroma.Language.Translation.Mozhi ] }, %{ From 27223fc5bcd5ba3bc5dc6769798635f7eac55c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 15 Oct 2025 10:59:53 +0200 Subject: [PATCH 13/78] Add `write:scrobbles` and `read:scrobbles` scope for scrobbling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/scrobbles-scope.change | 1 + docs/development/API/pleroma_api.md | 1 + .../web/api_spec/operations/pleroma_scrobble_operation.ex | 4 ++-- .../web/pleroma_api/controllers/scrobble_controller.ex | 4 ++-- .../web/pleroma_api/controllers/scrobble_controller_test.exs | 4 ++-- 5 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 changelog.d/scrobbles-scope.change diff --git a/changelog.d/scrobbles-scope.change b/changelog.d/scrobbles-scope.change new file mode 100644 index 000000000..3c31eadcc --- /dev/null +++ b/changelog.d/scrobbles-scope.change @@ -0,0 +1 @@ +Add `write:scrobbles` and `read:scrobbles` scope for scrobbling diff --git a/docs/development/API/pleroma_api.md b/docs/development/API/pleroma_api.md index b17f61cbb..7946ba1f6 100644 --- a/docs/development/API/pleroma_api.md +++ b/docs/development/API/pleroma_api.md @@ -684,6 +684,7 @@ Audio scrobbling in Pleroma is **deprecated**. ### Creates a new Listen activity for an account * Method `POST` * Authentication: required +* OAuth scope: `write:scrobbles` * Params: * `title`: the title of the media playing * `album`: the album of the media playing [optional] diff --git a/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex index 6f77584a8..a08b7b580 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaScrobbleOperation do %Operation{ tags: ["Scrobbles"], summary: "Creates a new Listen activity for an account", - security: [%{"oAuth" => ["write"]}], + security: [%{"oAuth" => ["write:scrobbles"]}], operationId: "PleromaAPI.ScrobbleController.create", deprecated: true, requestBody: request_body("Parameters", create_request(), required: true), @@ -39,7 +39,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaScrobbleOperation do parameters: [ %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} | pagination_params() ], - security: [%{"oAuth" => ["read"]}], + security: [%{"oAuth" => ["read:scrobbles"]}], responses: %{ 200 => Operation.response("Array of Scrobble", "application/json", %Schema{ diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex index 5f5f7643f..207446a84 100644 --- a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -16,10 +16,10 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do plug( OAuthScopesPlug, - %{scopes: ["read"], fallback: :proceed_unauthenticated} when action == :index + %{scopes: ["read:scrobbles"], fallback: :proceed_unauthenticated} when action == :index ) - plug(OAuthScopesPlug, %{scopes: ["write"]} when action == :create) + plug(OAuthScopesPlug, %{scopes: ["write:scrobbles"]} when action == :create) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaScrobbleOperation diff --git a/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs index bcc25b83e..fd405d7d7 100644 --- a/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs @@ -9,7 +9,7 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleControllerTest do describe "POST /api/v1/pleroma/scrobble" do test "works correctly" do - %{conn: conn} = oauth_access(["write"]) + %{conn: conn} = oauth_access(["write:scrobbles"]) conn = conn @@ -51,7 +51,7 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleControllerTest do describe "GET /api/v1/pleroma/accounts/:id/scrobbles" do test "works correctly" do - %{user: user, conn: conn} = oauth_access(["read"]) + %{user: user, conn: conn} = oauth_access(["read:scrobbles"]) {:ok, _activity} = CommonAPI.listen(user, %{ From ed1cfd6f5eee450277f32019dd290f3f48cf258f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Thu, 2 Oct 2025 13:36:30 +0200 Subject: [PATCH 14/78] Support translateLocally translation provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../translation-provider-translatelocally.add | 1 + config/description.exs | 11 +- .../language/translation/translate_locally.ex | 129 ++++++++++++++++++ .../translation/translate_locally_test.exs | 59 ++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 changelog.d/translation-provider-translatelocally.add create mode 100644 lib/pleroma/language/translation/translate_locally.ex create mode 100644 test/pleroma/language/translation/translate_locally_test.exs diff --git a/changelog.d/translation-provider-translatelocally.add b/changelog.d/translation-provider-translatelocally.add new file mode 100644 index 000000000..635e80061 --- /dev/null +++ b/changelog.d/translation-provider-translatelocally.add @@ -0,0 +1 @@ +Support translateLocally translation provider diff --git a/config/description.exs b/config/description.exs index c61a344e8..4c4773dbe 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3558,7 +3558,8 @@ config :pleroma, :config_description, [ type: :module, suggestions: [ Pleroma.Language.Translation.Deepl, - Pleroma.Language.Translation.Libretranslate + Pleroma.Language.Translation.Libretranslate, + Pleroma.Language.Translation.TranslateLocally ] }, %{ @@ -3588,6 +3589,14 @@ config :pleroma, :config_description, [ label: "LibreTranslate API Key", type: :string, suggestions: ["YOUR_API_KEY"] + }, + %{ + group: {:subgroup, Pleroma.Language.Translation.TranslateLocally}, + key: :intermediate_language, + label: + "translateLocally intermediate language (used when direct source->target model is not available)", + type: :string, + suggestions: ["en"] } ] } diff --git a/lib/pleroma/language/translation/translate_locally.ex b/lib/pleroma/language/translation/translate_locally.ex new file mode 100644 index 000000000..7eaa95e7b --- /dev/null +++ b/lib/pleroma/language/translation/translate_locally.ex @@ -0,0 +1,129 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.Translation.TranslateLocally do + alias Pleroma.Language.Translation.Provider + + use Provider + + @behaviour Provider + + @name "translateLocally" + + @impl Provider + def missing_dependencies do + if Pleroma.Utils.command_available?("translateLocally") do + [] + else + ["translateLocally"] + end + end + + @impl Provider + def configured?, do: is_map(models()) + + @impl Provider + def translate(content, source_language, target_language) do + model = + models() + |> Map.get(source_language, %{}) + |> Map.get(target_language) + + models = + if model do + [model] + else + [ + models() + |> Map.get(source_language, %{}) + |> Map.get(intermediary_language()), + models() + |> Map.get(intermediary_language(), %{}) + |> Map.get(target_language) + ] + end + + translated_content = + Enum.reduce(models, content, fn model, content -> + text_path = Path.join(System.tmp_dir!(), "translateLocally-#{Ecto.UUID.generate()}") + + File.write(text_path, content) + + translated_content = + case System.cmd("translateLocally", ["-m", model, "-i", text_path, "--html"]) do + {content, _} -> content + _ -> nil + end + + File.rm(text_path) + + translated_content + end) + + {:ok, + %{ + content: translated_content, + detected_source_language: source_language, + provider: @name + }} + end + + @impl Provider + def supported_languages(:source) do + languages = + languages_matrix() + |> elem(1) + |> Map.keys() + + {:ok, languages} + end + + @impl Provider + def supported_languages(:target) do + languages = + languages_matrix() + |> elem(1) + |> Map.values() + |> List.flatten() + |> Enum.uniq() + + {:ok, languages} + end + + @impl Provider + def languages_matrix do + languages = + models() + |> Map.to_list() + |> Enum.map(fn {key, value} -> {key, Map.keys(value)} end) + |> Enum.into(%{}) + + matrix = + if intermediary_language() do + languages + |> Map.to_list() + |> Enum.map(fn {key, value} -> + with_intermediary = + (((value ++ languages[intermediary_language()]) + |> Enum.uniq()) -- + [key]) + |> Enum.sort() + + {key, with_intermediary} + end) + |> Enum.into(%{}) + else + languages + end + + {:ok, matrix} + end + + @impl Provider + def name, do: @name + + defp models, do: Pleroma.Config.get([__MODULE__, :models]) + + defp intermediary_language, do: Pleroma.Config.get([__MODULE__, :intermediary_language]) +end diff --git a/test/pleroma/language/translation/translate_locally_test.exs b/test/pleroma/language/translation/translate_locally_test.exs new file mode 100644 index 000000000..51cbd11bd --- /dev/null +++ b/test/pleroma/language/translation/translate_locally_test.exs @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.Translation.TranslateLocallyTest do + use Pleroma.DataCase + + alias Pleroma.Language.Translation.TranslateLocally + + @example_models %{ + "de" => %{ + "en" => "de-en-base" + }, + "en" => %{ + "de" => "en-de-base", + "pl" => "en-pl-tiny" + }, + "cs" => %{ + "en" => "cs-en-base" + }, + "pl" => %{ + "en" => "pl-en-tiny" + } + } + + test "it returns languages list" do + clear_config([Pleroma.Language.Translation.TranslateLocally, :models], @example_models) + + assert {:ok, languages} = TranslateLocally.supported_languages(:source) + assert ["cs", "de", "en", "pl"] = languages |> Enum.sort() + end + + describe "it returns languages matrix" do + test "without intermediary language" do + clear_config([Pleroma.Language.Translation.TranslateLocally, :models], @example_models) + + assert {:ok, + %{ + "cs" => ["en"], + "de" => ["en"], + "en" => ["de", "pl"], + "pl" => ["en"] + }} = TranslateLocally.languages_matrix() + end + + test "with intermediary language" do + clear_config([Pleroma.Language.Translation.TranslateLocally, :models], @example_models) + clear_config([Pleroma.Language.Translation.TranslateLocally, :intermediary_language], "en") + + assert {:ok, + %{ + "cs" => ["de", "en", "pl"], + "de" => ["en", "pl"], + "en" => ["de", "pl"], + "pl" => ["de", "en"] + }} = TranslateLocally.languages_matrix() + end + end +end From bb1e643996cb3d7455411a6d4da25533c3d4b219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 15 Oct 2025 11:01:18 +0200 Subject: [PATCH 15/78] Update changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/translation-provider-mozhi.add | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/translation-provider-mozhi.add diff --git a/changelog.d/translation-provider-mozhi.add b/changelog.d/translation-provider-mozhi.add new file mode 100644 index 000000000..c3cf5940a --- /dev/null +++ b/changelog.d/translation-provider-mozhi.add @@ -0,0 +1 @@ +Support Mozhi translation provider From 1610d39f3645ad664dfde06940ba891082ded752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 21 Oct 2025 21:41:33 +0200 Subject: [PATCH 16/78] Revert "User.get_or_fetch_public_key_for_ap_id/1 is no longer required." This reverts commit c0a50b7c3e340cd621827922200daa0f29dc6e15. --- lib/pleroma/user.ex | 9 +++++++++ test/pleroma/user_test.exs | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 468e124b5..3d4815aca 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -2307,6 +2307,15 @@ defmodule Pleroma.User do def public_key(_), do: {:error, "key not found"} + def get_or_fetch_public_key_for_ap_id(ap_id) do + with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id), + {:ok, public_key} <- public_key(user) do + {:ok, public_key} + else + _ -> :error + end + end + def get_public_key_for_ap_id(ap_id) do with %User{} = user <- get_cached_by_ap_id(ap_id), {:ok, public_key} <- public_key(user) do diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 0b4dc9197..b2533e9f1 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -1881,6 +1881,11 @@ defmodule Pleroma.UserTest do end end + test "get_or_fetch_public_key_for_ap_id fetches a user that's not in the db" do + assert {:ok, _key} = + User.get_or_fetch_public_key_for_ap_id("http://mastodon.example.org/users/admin") + end + test "get_public_key_for_ap_id returns correctly for user that's not in the db" do assert :error = User.get_public_key_for_ap_id("http://mastodon.example.org/users/admin") end From b38fedf342e0392d5cff2b64569f0c24981b57bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 21 Oct 2025 21:46:03 +0200 Subject: [PATCH 17/78] Fix fetching public keys with authorized fetch enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/authorized_fetch.fix | 1 + lib/pleroma/signature.ex | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/authorized_fetch.fix diff --git a/changelog.d/authorized_fetch.fix b/changelog.d/authorized_fetch.fix new file mode 100644 index 000000000..1db8e88c9 --- /dev/null +++ b/changelog.d/authorized_fetch.fix @@ -0,0 +1 @@ +Fix fetching public keys with authorized fetch enabled \ No newline at end of file diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index 47d9d46f6..fca61799b 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -54,7 +54,7 @@ defmodule Pleroma.Signature do def fetch_public_key(conn) do with {:ok, actor_id} <- get_actor_id(conn), - {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do + {:ok, public_key} <- User.get_or_fetch_public_key_for_ap_id(actor_id) do {:ok, public_key} else e -> From 4f31cadbccdb2bf214159a2a408253cabf2f39a5 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 22 Oct 2025 13:39:10 -0700 Subject: [PATCH 18/78] Enable expiration of CICD job artifacts We have accumulated nearly 200GB of old artifacts that we do not need. --- .gitlab-ci.yml | 4 ++++ changelog.d/ci-artifacts.skip | 0 2 files changed, 4 insertions(+) create mode 100644 changelog.d/ci-artifacts.skip diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 47b66ae69..549a7853a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,9 @@ image: git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.15.8-otp-26 +default: + artifacts: + expire_in: 1 week + variables: &global_variables # Only used for the release ELIXIR_VER: 1.17.3 diff --git a/changelog.d/ci-artifacts.skip b/changelog.d/ci-artifacts.skip new file mode 100644 index 000000000..e69de29bb From a07305ca34c783c3a54210e93238682187729451 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 23 Oct 2025 10:58:11 -0700 Subject: [PATCH 19/78] GitLab support for default artifacts setting is broken https://gitlab.com/gitlab-org/gitlab/-/issues/404563 --- .gitlab-ci.yml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 549a7853a..33123737f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,9 +1,5 @@ image: git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.15.8-otp-26 -default: - artifacts: - expire_in: 1 week - variables: &global_variables # Only used for the release ELIXIR_VER: 1.17.3 @@ -23,6 +19,10 @@ workflow: - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS when: never +# Default artifacts configuration +.default_artifacts: &default_artifacts + expire_in: 30 days + cache: &global_cache_policy key: $CI_JOB_IMAGE-$CI_COMMIT_SHORT_SHA paths: @@ -60,6 +60,7 @@ check-changelog: before_script: '' after_script: '' cache: {} + artifacts: *default_artifacts script: - apk add git - sh ./tools/check-changelog @@ -75,6 +76,7 @@ check-changelog: .using-ci-base: tags: - amd64 + artifacts: *default_artifacts build-1.15.8-otp-26: extends: @@ -105,6 +107,7 @@ spec-build: artifacts: paths: - spec.json + expire_in: 42 years script: - mix pleroma.openapi_spec spec.json @@ -157,6 +160,7 @@ unit-testing-1.15.8-otp-26: - su testuser -c "HOME=/home/testuser mix pleroma.test_runner --cover --preload-modules" coverage: '/^Line total: ([^ ]*%)$/' artifacts: + expire_in: 30 days reports: coverage_report: coverage_format: cobertura @@ -175,6 +179,7 @@ unit-testing-1.18.3-otp-27: formatting-1.15: extends: .build_changes_policy + artifacts: *default_artifacts image: &formatting_elixir elixir:1.15-alpine stage: lint cache: *testing_cache_policy @@ -189,6 +194,7 @@ formatting-1.15: cycles-1.15: extends: .build_changes_policy + artifacts: *default_artifacts image: *formatting_elixir stage: lint cache: {} @@ -212,7 +218,7 @@ dialyzer: - .using-ci-base stage: lint allow_failure: true - when: manual + when: manual cache: *testing_cache_policy tags: - feld @@ -221,6 +227,7 @@ dialyzer: docs-deploy: stage: deploy + artifacts: *default_artifacts cache: *testing_cache_policy image: alpine:latest only: @@ -245,6 +252,7 @@ review_app: except: - master - develop + artifacts: *default_artifacts script: - echo "$CI_ENVIRONMENT_SLUG" - mkdir -p ~/.ssh @@ -264,6 +272,7 @@ spec-deploy: artifacts: paths: - spec.json + expire_in: 30 days only: - develop@pleroma/pleroma image: alpine:latest @@ -276,6 +285,7 @@ spec-deploy: stop_review_app: image: alpine:3.9 stage: deploy + artifacts: *default_artifacts before_script: - apk update && apk add openssh-client git when: manual From a0225ddc7931f28e1d34af613e0532cbfc8bbc3f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 23 Oct 2025 11:44:11 -0700 Subject: [PATCH 20/78] CI: Allow running pipelines from web or directly for a tag --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 33123737f..3f1656a08 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,6 +16,8 @@ workflow: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "develop" - if: $CI_COMMIT_BRANCH == "stable" + - if: $CI_PIPELINE_SOURCE == "web" + - if: $CI_COMMIT_TAG - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS when: never From d09ec25454e7634b2bac9c00a347a197a489d7bb Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 23 Oct 2025 12:12:19 -0700 Subject: [PATCH 21/78] CI: use triggers for docs and api-docs deployments This is a "bridge job" which is more efficient. We do not need a token or API call then to make the CI run in the target repos. --- .gitlab-ci.yml | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3f1656a08..3d8fc1d2b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -229,16 +229,13 @@ dialyzer: docs-deploy: stage: deploy - artifacts: *default_artifacts - cache: *testing_cache_policy - image: alpine:latest + trigger: + project: pleroma/docs + branch: develop + strategy: depend only: - stable@pleroma/pleroma - develop@pleroma/pleroma - before_script: - - apk add curl - script: - - curl --fail-with-body -X POST -F"token=$DOCS_PIPELINE_TRIGGER" -F'ref=master' -F"variables[BRANCH]=$CI_COMMIT_REF_NAME" https://git.pleroma.social/api/v4/projects/673/trigger/pipeline review_app: image: alpine:3.9 stage: deploy @@ -271,17 +268,12 @@ review_app: spec-deploy: stage: deploy - artifacts: - paths: - - spec.json - expire_in: 30 days + trigger: + project: pleroma/api-docs + branch: develop + strategy: depend only: - develop@pleroma/pleroma - image: alpine:latest - before_script: - - apk add curl - script: - - curl --fail-with-body -X POST -F"token=$API_DOCS_PIPELINE_TRIGGER" -F'ref=master' -F"variables[BRANCH]=$CI_COMMIT_REF_NAME" -F"variables[JOB_REF]=$CI_JOB_ID" https://git.pleroma.social/api/v4/projects/1130/trigger/pipeline stop_review_app: From b6da3f490b1f5b1eb38cbd5ab9a117186cdb0daf Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 23 Oct 2025 13:41:26 -0700 Subject: [PATCH 22/78] Fix branch names for pleroma/docs and pleroma/api-docs triggers --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3d8fc1d2b..f46aafcea 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -231,7 +231,7 @@ docs-deploy: stage: deploy trigger: project: pleroma/docs - branch: develop + branch: master strategy: depend only: - stable@pleroma/pleroma @@ -270,7 +270,7 @@ spec-deploy: stage: deploy trigger: project: pleroma/api-docs - branch: develop + branch: master strategy: depend only: - develop@pleroma/pleroma From 6f7e521488e0c8dba8a9e8e10577f241fa7a010f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 23 Oct 2025 16:41:52 -0700 Subject: [PATCH 23/78] CI: pass the variable CI_PIPELINE_ID through to the api-docs build job --- .gitlab-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f46aafcea..ed51169e0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -274,7 +274,8 @@ spec-deploy: strategy: depend only: - develop@pleroma/pleroma - + variables: + PIPELINE_ID: $CI_PIPELINE_ID stop_review_app: image: alpine:3.9 From d15f98bdecbca504576feace7137487855f411fc Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 23 Oct 2025 21:10:13 -0700 Subject: [PATCH 24/78] CI: Use the dotenv report method to capture the spec-build internal job id and pass it through to the spec-deploy job --- .gitlab-ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ed51169e0..941514e18 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -109,9 +109,12 @@ spec-build: artifacts: paths: - spec.json + reports: + dotenv: build.env expire_in: 42 years script: - mix pleroma.openapi_spec spec.json + - echo "SPEC_BUILD_JOB_ID=$CI_JOB_ID" >> build.env benchmark: extends: @@ -275,7 +278,7 @@ spec-deploy: only: - develop@pleroma/pleroma variables: - PIPELINE_ID: $CI_PIPELINE_ID + SPEC_BUILD_JOB_ID: $SPEC_BUILD_JOB_ID stop_review_app: image: alpine:3.9 From 32a940b86bd8f62a12fac191c84f0152667555e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sun, 26 Oct 2025 23:32:01 +0100 Subject: [PATCH 25/78] Allow setting custom user-agent for fetching rich media content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/rich-media-user-agent.add | 1 + config/description.exs | 5 +++++ lib/pleroma/web/rich_media/helpers.ex | 7 ++++++- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 changelog.d/rich-media-user-agent.add diff --git a/changelog.d/rich-media-user-agent.add b/changelog.d/rich-media-user-agent.add new file mode 100644 index 000000000..5c1e2b134 --- /dev/null +++ b/changelog.d/rich-media-user-agent.add @@ -0,0 +1 @@ +Allow setting custom user-agent for fetching rich media content diff --git a/config/description.exs b/config/description.exs index c61a344e8..a34cf7d94 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2131,6 +2131,11 @@ config :pleroma, :config_description, [ description: "Amount of milliseconds after which the HTTP request is forcibly terminated.", suggestions: [5_000] + }, + %{ + key: :user_agent, + type: :string, + description: "Custom User-Agent header to be used when fetching rich media content." } ] }, diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index d4be97957..963076510 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -126,6 +126,11 @@ defmodule Pleroma.Web.RichMedia.Helpers do end defp req_headers do - [{"user-agent", Pleroma.Application.user_agent() <> "; Bot"}] + user_agent = Config.get([:rich_media, :user_agent], :default) + + case user_agent do + :default -> [{"user-agent", Pleroma.Application.user_agent() <> "; Bot"}] + custom -> [{"user-agent", custom}] + end end end From ca098a2bead91d00b127bae69b07fae8c52d17ff Mon Sep 17 00:00:00 2001 From: HJ <30-hj@users.noreply.git.pleroma.social> Date: Wed, 29 Oct 2025 16:47:59 +0000 Subject: [PATCH 26/78] Allow FediIndex --- priv/static/robots.txt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/priv/static/robots.txt b/priv/static/robots.txt index 25781b7d7..4559309f4 100644 --- a/priv/static/robots.txt +++ b/priv/static/robots.txt @@ -1,2 +1,8 @@ -User-Agent: * -Disallow: +User-agent: * +Disallow: / + +User-agent: FediIndex +Allow: /nodeinfo/2.0 +Allow: /nodeinfo/2.1 +Allow: /nodeinfo/2.2 +Disallow: / \ No newline at end of file From a275ffacaf6f28fc01b2596d161bf16da4fab01c Mon Sep 17 00:00:00 2001 From: HJ <30-hj@users.noreply.git.pleroma.social> Date: Wed, 29 Oct 2025 16:49:37 +0000 Subject: [PATCH 27/78] changelog --- changelog.d/fediindex.change | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/fediindex.change diff --git a/changelog.d/fediindex.change b/changelog.d/fediindex.change new file mode 100644 index 000000000..b9bef2762 --- /dev/null +++ b/changelog.d/fediindex.change @@ -0,0 +1 @@ +Allow FediIndex crawler bot by default \ No newline at end of file From 724cdc44fc1c859c7ec804c3cbd3ca28001ae934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sat, 1 Nov 2025 11:25:55 +0100 Subject: [PATCH 28/78] Fix typo in Pleroma name in docs --- changelog.d/plaroma.skip | 1 + docs/installation/optional/media_graphics_packages.md | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changelog.d/plaroma.skip diff --git a/changelog.d/plaroma.skip b/changelog.d/plaroma.skip new file mode 100644 index 000000000..184ca07e0 --- /dev/null +++ b/changelog.d/plaroma.skip @@ -0,0 +1 @@ +i don't think it's called plaroma \ No newline at end of file diff --git a/docs/installation/optional/media_graphics_packages.md b/docs/installation/optional/media_graphics_packages.md index ad01d47d1..89125d1c6 100644 --- a/docs/installation/optional/media_graphics_packages.md +++ b/docs/installation/optional/media_graphics_packages.md @@ -16,7 +16,7 @@ Note: the packages are not required with the current default settings of Pleroma It is required for the following Pleroma features: -* `Pleroma.Upload.Filters.Mogrify`, `Pleroma.Upload.Filters.Mogrifun` upload filters (related config: `Plaroma.Upload/filters` in `config/config.exs`) +* `Pleroma.Upload.Filters.Mogrify`, `Pleroma.Upload.Filters.Mogrifun` upload filters (related config: `Pleroma.Upload/filters` in `config/config.exs`) * Media preview proxy for still images (related config: `media_preview_proxy/enabled` in `config/config.exs`) ## `ffmpeg` @@ -33,5 +33,5 @@ It is required for the following Pleroma features: It is required for the following Pleroma features: -* `Pleroma.Upload.Filters.Exiftool.StripLocation` upload filter (related config: `Plaroma.Upload/filters` in `config/config.exs`) -* `Pleroma.Upload.Filters.Exiftool.ReadDescription` upload filter (related config: `Plaroma.Upload/filters` in `config/config.exs`) +* `Pleroma.Upload.Filters.Exiftool.StripLocation` upload filter (related config: `Pleroma.Upload/filters` in `config/config.exs`) +* `Pleroma.Upload.Filters.Exiftool.ReadDescription` upload filter (related config: `Pleroma.Upload/filters` in `config/config.exs`) From b975dce9ba8d417f7886d68586de1fad5621f7ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sat, 1 Nov 2025 11:43:08 +0100 Subject: [PATCH 29/78] Add `timelines_access` to InstanceView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/instance-view-timeline-access.add | 1 + .../web/mastodon_api/views/instance_view.ex | 26 ++++++++++++++++++- .../controllers/instance_controller_test.exs | 24 +++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 changelog.d/instance-view-timeline-access.add diff --git a/changelog.d/instance-view-timeline-access.add b/changelog.d/instance-view-timeline-access.add new file mode 100644 index 000000000..eb414e786 --- /dev/null +++ b/changelog.d/instance-view-timeline-access.add @@ -0,0 +1 @@ +Add `timelines_access` to InstanceView diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 1b6f26af7..b21383659 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -257,10 +257,34 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do vapid: %{ public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) }, - translation: %{enabled: Pleroma.Language.Translation.configured?()} + translation: %{enabled: Pleroma.Language.Translation.configured?()}, + timelines_access: %{ + live_feeds: timelines_access(), + hashtag_feeds: timelines_access(), + # not implemented in Pleroma + trending_link_feeds: %{ + local: "disabled", + remote: "disabled" + } + } }) end + defp timelines_access do + %{ + local: timeline_access(:local), + remote: timeline_access(:federated) + } + end + + defp timeline_access(kind) do + if Config.restrict_unauthenticated_access?(:timelines, kind) do + "authenticated" + else + "public" + end + end + defp pleroma_configuration(instance) do base_urls = %{} diff --git a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs index 8a0fe5259..10c0b6ea7 100644 --- a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs @@ -194,4 +194,28 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do refute Map.has_key?(result["pleroma"]["metadata"]["base_urls"], "media_proxy") refute Map.has_key?(result["pleroma"]["metadata"]["base_urls"], "upload") end + + test "display timeline access restrictions", %{conn: conn} do + clear_config([:restrict_unauthenticated, :timelines, :local], true) + clear_config([:restrict_unauthenticated, :timelines, :federated], false) + + conn = get(conn, "/api/v2/instance") + + assert result = json_response_and_validate_schema(conn, 200) + + assert result["configuration"]["timelines_access"] == %{ + "live_feeds" => %{ + "local" => "authenticated", + "remote" => "public" + }, + "hashtag_feeds" => %{ + "local" => "authenticated", + "remote" => "public" + }, + "trending_link_feeds" => %{ + "local" => "disabled", + "remote" => "disabled" + } + } + end end From aef26a615e0a629e551fe2c491baccd2b10aa882 Mon Sep 17 00:00:00 2001 From: Mint Date: Fri, 7 Nov 2025 19:44:34 +0300 Subject: [PATCH 30/78] Fix changelog checker --- changelog.d/changelog-checker.fix | 1 + tools/check-changelog | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 changelog.d/changelog-checker.fix diff --git a/changelog.d/changelog-checker.fix b/changelog.d/changelog-checker.fix new file mode 100644 index 000000000..e910a649f --- /dev/null +++ b/changelog.d/changelog-checker.fix @@ -0,0 +1 @@ +Fix CI changelog checker diff --git a/tools/check-changelog b/tools/check-changelog index d053ed577..5952aefcc 100644 --- a/tools/check-changelog +++ b/tools/check-changelog @@ -1,5 +1,8 @@ #!/bin/sh +echo "adding ownership exception" +git config --global --add safe.directory $(pwd) + echo "looking for change log" git remote add upstream https://git.pleroma.social/pleroma/pleroma.git From 9da1875c36caa5872e2e241a5f322f5747d4d5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sat, 22 Nov 2025 16:51:21 +0100 Subject: [PATCH 31/78] Send push notifications for statuses from subscribed accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/status-push-notification.fix | 1 + lib/pleroma/web/push/subscription.ex | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/status-push-notification.fix diff --git a/changelog.d/status-push-notification.fix b/changelog.d/status-push-notification.fix new file mode 100644 index 000000000..ed0bbff33 --- /dev/null +++ b/changelog.d/status-push-notification.fix @@ -0,0 +1 @@ +Send push notifications for statuses from subscribed accounts diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 6fc45bd61..c6fe69e84 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -26,7 +26,7 @@ defmodule Pleroma.Web.Push.Subscription do end # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength - @supported_alert_types ~w[follow favourite mention reblog poll pleroma:chat_mention pleroma:emoji_reaction]a + @supported_alert_types ~w[follow favourite mention status reblog poll pleroma:chat_mention pleroma:emoji_reaction]a defp alerts(%{data: %{alerts: alerts}}) do alerts = Map.take(alerts, @supported_alert_types) From dc85b2799063ab5f04a64a0b10a1a91842550d32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Mon, 24 Nov 2025 22:17:05 +0100 Subject: [PATCH 32/78] Minor cleanup and comment fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/notification-cleanup.skip | 0 lib/pleroma/notification.ex | 4 +-- lib/pleroma/user.ex | 4 +-- lib/pleroma/web/common_api/utils.ex | 43 +++++++++++++-------------- 4 files changed, 24 insertions(+), 27 deletions(-) create mode 100644 changelog.d/notification-cleanup.skip diff --git a/changelog.d/notification-cleanup.skip b/changelog.d/notification-cleanup.skip new file mode 100644 index 000000000..e69de29bb diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 80844ed71..000bc0dc7 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -526,9 +526,7 @@ defmodule Pleroma.Notification do %Activity{data: %{"type" => "Create"}} = activity, local_only ) do - notification_enabled_ap_ids = - [] - |> Utils.maybe_notify_subscribers(activity) + notification_enabled_ap_ids = Utils.get_notified_subscribers(activity) potential_receivers = User.get_users_from_set(notification_enabled_ap_ids, local_only: local_only) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 540d0af6a..f8e32af8a 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -233,8 +233,8 @@ defmodule Pleroma.User do for {_relationship_type, [{_outgoing_relation, outgoing_relation_target}, _]} <- @user_relationships_config do # `def blocked_users_relation/2`, `def muted_users_relation/2`, - # `def reblog_muted_users_relation/2`, `def notification_muted_users/2`, - # `def subscriber_users/2`, `def endorsed_users_relation/2` + # `def reblog_muted_users_relation/2`, `def notification_muted_users_relation/2`, + # `def subscriber_users_relation/2`, `def endorsed_users_relation/2` def unquote(:"#{outgoing_relation_target}_relation")(user, restrict_deactivated? \\ false) do target_users_query = assoc(user, unquote(outgoing_relation_target)) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 52c08f00f..91bf9c502 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -402,28 +402,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do def maybe_notify_mentioned_recipients(recipients, _), do: recipients - def maybe_notify_subscribers( - recipients, - %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity - ) do - # Do not notify subscribers if author is making a reply - with %Object{data: object} <- Object.normalize(activity, fetch: false), - nil <- object["inReplyTo"], - %User{} = user <- User.get_cached_by_ap_id(actor) do - subscriber_ids = - user - |> User.subscriber_users() - |> Enum.filter(&Visibility.visible_for_user?(activity, &1)) - |> Enum.map(& &1.ap_id) - - recipients ++ subscriber_ids - else - _e -> recipients - end - end - - def maybe_notify_subscribers(recipients, _), do: recipients - def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do user @@ -437,6 +415,27 @@ defmodule Pleroma.Web.CommonAPI.Utils do def maybe_notify_followers(recipients, _), do: recipients + def get_notified_subscribers( + %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity + ) do + # Do not notify subscribers if author is making a reply + with %Object{data: object} <- Object.normalize(activity, fetch: false), + nil <- object["inReplyTo"], + %User{} = user <- User.get_cached_by_ap_id(actor) do + subscriber_ids = + user + |> User.subscriber_users() + |> Enum.filter(&Visibility.visible_for_user?(activity, &1)) + |> Enum.map(& &1.ap_id) + + subscriber_ids + else + _e -> [] + end + end + + def get_notified_subscribers(_), do: [] + def maybe_extract_mentions(%{"tag" => tag}) do tag |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end) From 5f4c94805791e8990a9d37dbc7ccf865ab899547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 28 Nov 2025 14:53:22 +0100 Subject: [PATCH 33/78] fix typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- test/pleroma/notification_test.exs | 2 +- test/pleroma/web/streamer_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/pleroma/notification_test.exs b/test/pleroma/notification_test.exs index 2ad950e84..a2be2ae49 100644 --- a/test/pleroma/notification_test.exs +++ b/test/pleroma/notification_test.exs @@ -488,7 +488,7 @@ defmodule Pleroma.NotificationTest do end @tag needs_streamer: true - test "it send updated marker to the 'user' and the 'user:notification' stream" do + test "it sends updated marker to the 'user' and the 'user:notification' stream" do %{user: user, token: oauth_token} = oauth_access(["read"]) other_user = insert(:user) diff --git a/test/pleroma/web/streamer_test.exs b/test/pleroma/web/streamer_test.exs index 85978e824..096ca2d2a 100644 --- a/test/pleroma/web/streamer_test.exs +++ b/test/pleroma/web/streamer_test.exs @@ -883,7 +883,7 @@ defmodule Pleroma.Web.StreamerTest do assert Streamer.filtered_by_user?(user1, notif) end - test "it send non-reblog notification for reblog-muted actors", %{ + test "it sends non-reblog notification for reblog-muted actors", %{ user: user1, token: user1_token } do From e458bd953ac7e0cb8b260ba6bd6ff197f6837c62 Mon Sep 17 00:00:00 2001 From: mkljczk Date: Sat, 4 Jan 2025 10:09:39 +0100 Subject: [PATCH 34/78] Add /api/v1/pleroma/outgoing_follow_requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: mkljczk Signed-off-by: nicole mikołajczyk --- changelog.d/outgoing-follow-requests.add | 1 + lib/pleroma/following_relationship.ex | 10 ++++++ lib/pleroma/user.ex | 1 + .../pleroma_follow_request_operation.ex | 31 +++++++++++++++++++ .../controllers/follow_request_controller.ex | 27 ++++++++++++++++ lib/pleroma/web/router.ex | 2 ++ .../follow_request_controller_test.exs | 27 ++++++++++++++++ 7 files changed, 99 insertions(+) create mode 100644 changelog.d/outgoing-follow-requests.add create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_follow_request_operation.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex create mode 100644 test/pleroma/web/pleroma_api/controllers/follow_request_controller_test.exs diff --git a/changelog.d/outgoing-follow-requests.add b/changelog.d/outgoing-follow-requests.add new file mode 100644 index 000000000..a898bcf6e --- /dev/null +++ b/changelog.d/outgoing-follow-requests.add @@ -0,0 +1 @@ +Add /api/v1/pleroma/outgoing_follow_requests diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 495488dfd..653feb32f 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -157,6 +157,16 @@ defmodule Pleroma.FollowingRelationship do |> Repo.all() end + def get_outgoing_follow_requests(%User{id: id}) do + __MODULE__ + |> join(:inner, [r], f in assoc(r, :following)) + |> where([r], r.state == ^:follow_pending) + |> where([r], r.follower_id == ^id) + |> where([r, f], f.is_active == true) + |> select([r, f], f) + |> Repo.all() + end + def following?(%User{id: follower_id}, %User{id: followed_id}) do __MODULE__ |> where(follower_id: ^follower_id, following_id: ^followed_id, state: ^:follow_accept) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 7a36ece77..5586ac6b1 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -280,6 +280,7 @@ defmodule Pleroma.User do defdelegate following?(follower, followed), to: FollowingRelationship defdelegate following_ap_ids(user), to: FollowingRelationship defdelegate get_follow_requests(user), to: FollowingRelationship + defdelegate get_outgoing_follow_requests(user), to: FollowingRelationship defdelegate search(query, opts \\ []), to: User.Search @doc """ diff --git a/lib/pleroma/web/api_spec/operations/pleroma_follow_request_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_follow_request_operation.ex new file mode 100644 index 000000000..b5a413490 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_follow_request_operation.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaFollowRequestOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def outgoing_operation do + %Operation{ + tags: ["Follow requests"], + summary: "Retrieve outgoing follow requests", + security: [%{"oAuth" => ["read:follows", "follow"]}], + operationId: "PleromaFollowRequestController.outgoing", + responses: %{ + 200 => + Operation.response("Array of Account", "application/json", %Schema{ + type: :array, + items: Account, + example: [Account.schema().example] + }) + } + } + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex b/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex new file mode 100644 index 000000000..656d477da --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.FollowRequestController do + use Pleroma.Web, :controller + + alias Pleroma.User + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(OAuthScopesPlug, %{scopes: ["follow", "read:follows"]}) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaFollowRequestOperation + + @doc "GET /api/v1/pleroma/outgoing_follow_requests" + def outgoing(%{assigns: %{user: follower}} = conn, _params) do + follow_requests = User.get_outgoing_follow_requests(follower) + + conn + |> put_view(Pleroma.Web.MastodonAPI.FollowRequestView) + |> render("index.json", for: follower, users: follow_requests, as: :user) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 0423ca9e2..560e82cef 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -602,6 +602,8 @@ defmodule Pleroma.Web.Router do post("/bookmark_folders", BookmarkFolderController, :create) patch("/bookmark_folders/:id", BookmarkFolderController, :update) delete("/bookmark_folders/:id", BookmarkFolderController, :delete) + + get("/outgoing_follow_requests", FollowRequestController, :outgoing) end scope [] do diff --git a/test/pleroma/web/pleroma_api/controllers/follow_request_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/follow_request_controller_test.exs new file mode 100644 index 000000000..46109e35e --- /dev/null +++ b/test/pleroma/web/pleroma_api/controllers/follow_request_controller_test.exs @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.FollowRequestControllerTest do + use Pleroma.Web.ConnCase, async: true + + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "/api/v1/pleroma/outgoing_follow_requests works" do + %{conn: conn, user: user} = oauth_access(["read:follows"]) + + other_user1 = insert(:user) + other_user2 = insert(:user, is_locked: true) + _other_user3 = insert(:user) + + {:ok, _, _, _} = CommonAPI.follow(other_user1, user) + {:ok, _, _, _} = CommonAPI.follow(other_user2, user) + + conn = get(conn, "/api/v1/pleroma/outgoing_follow_requests") + + assert [relationship] = json_response_and_validate_schema(conn, 200) + assert to_string(other_user2.id) == relationship["id"] + end +end From 3a5e8e5e07deee1dfced1f32298d07a2bf3cfcf7 Mon Sep 17 00:00:00 2001 From: FloatingGhost Date: Sun, 12 Mar 2023 23:59:10 +0000 Subject: [PATCH 35/78] ensure we send the right files for preferred fe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../web/fallback/redirect_controller.ex | 12 ++--- lib/pleroma/web/plugs/frontend_static.ex | 45 ++++++++++++++++--- lib/pleroma/web/plugs/instance_static.ex | 4 +- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/fallback/redirect_controller.ex b/lib/pleroma/web/fallback/redirect_controller.ex index 6637848a9..60fc15b9e 100644 --- a/lib/pleroma/web/fallback/redirect_controller.ex +++ b/lib/pleroma/web/fallback/redirect_controller.ex @@ -30,7 +30,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do end def redirector(conn, _params, code \\ 200) do - {:ok, index_content} = File.read(index_file_path()) + {:ok, index_content} = File.read(index_file_path(conn)) response = index_content @@ -51,7 +51,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do end def redirector_with_meta(conn, params) do - {:ok, index_content} = File.read(index_file_path()) + {:ok, index_content} = File.read(index_file_path(conn)) tags = build_tags(conn, params) preloads = preload_data(conn, params) @@ -69,7 +69,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do end def redirector_with_preload(conn, params) do - {:ok, index_content} = File.read(index_file_path()) + {:ok, index_content} = File.read(index_file_path(conn)) preloads = preload_data(conn, params) response = @@ -91,8 +91,10 @@ defmodule Pleroma.Web.Fallback.RedirectController do |> text("") end - defp index_file_path do - Pleroma.Web.Plugs.InstanceStatic.file_path("index.html") + defp index_file_path(conn) do + frontend_type = Pleroma.Web.Plugs.FrontendStatic.preferred_or_fallback(conn, :primary) + + Pleroma.Web.Plugs.InstanceStatic.file_path("index.html", frontend_type) end defp build_tags(conn, params) do diff --git a/lib/pleroma/web/plugs/frontend_static.ex b/lib/pleroma/web/plugs/frontend_static.ex index 6ab8e4667..179f2bd47 100644 --- a/lib/pleroma/web/plugs/frontend_static.ex +++ b/lib/pleroma/web/plugs/frontend_static.ex @@ -5,17 +5,23 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do require Pleroma.Constants + @frontend_cookie_name "preferred_frontend" + @moduledoc """ This is a shim to call `Plug.Static` but with runtime `from` configuration`. It dispatches to the different frontends. """ @behaviour Plug - def file_path(path, frontend_type \\ :primary) do - if configuration = Pleroma.Config.get([:frontends, frontend_type]) do - instance_static_path = Pleroma.Config.get([:instance, :static_dir], "instance/static") + defp instance_static_path do + Pleroma.Config.get([:instance, :static_dir], "instance/static") + end + def file_path(path, frontend_type \\ :primary) + + def file_path(path, frontend_type) when is_atom(frontend_type) do + if configuration = Pleroma.Config.get([:frontends, frontend_type]) do Path.join([ - instance_static_path, + instance_static_path(), "frontends", configuration["name"], configuration["ref"], @@ -26,6 +32,15 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do end end + def file_path(path, frontend_type) when is_binary(frontend_type) do + Path.join([ + instance_static_path(), + "frontends", + frontend_type, + path + ]) + end + def init(opts) do opts |> Keyword.put(:from, "__unconfigured_frontend_static_plug") @@ -36,7 +51,8 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do def call(conn, opts) do with false <- api_route?(conn.path_info), false <- invalid_path?(conn.path_info), - frontend_type <- Map.get(opts, :frontend_type, :primary), + fallback_frontend_type <- Map.get(opts, :frontend_type, :primary), + frontend_type <- preferred_or_fallback(conn, fallback_frontend_type), path when not is_nil(path) <- file_path("", frontend_type) do call_static(conn, opts, path) else @@ -45,6 +61,24 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do end end + def preferred_frontend(conn) do + %{req_cookies: cookies} = + conn + |> Plug.Conn.fetch_cookies() + + Map.get(cookies, @frontend_cookie_name) + end + + def preferred_or_fallback(conn, fallback) do + case preferred_frontend(conn) do + nil -> + fallback + + frontend -> + frontend + end + end + defp invalid_path?(list) do invalid_path?(list, :binary.compile_pattern(["/", "\\", ":", "\0"])) end @@ -62,6 +96,7 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do defp call_static(conn, opts, from) do opts = Map.put(opts, :from, from) + IO.inspect(opts, label: "opts") Plug.Static.call(conn, opts) end end diff --git a/lib/pleroma/web/plugs/instance_static.ex b/lib/pleroma/web/plugs/instance_static.ex index f82b9a098..d2a674d39 100644 --- a/lib/pleroma/web/plugs/instance_static.ex +++ b/lib/pleroma/web/plugs/instance_static.ex @@ -13,11 +13,11 @@ defmodule Pleroma.Web.Plugs.InstanceStatic do """ @behaviour Plug - def file_path(path) do + def file_path(path, frontend_type \\ :primary) do instance_path = Path.join(Pleroma.Config.get([:instance, :static_dir], "instance/static/"), path) - frontend_path = Pleroma.Web.Plugs.FrontendStatic.file_path(path, :primary) + frontend_path = Pleroma.Web.Plugs.FrontendStatic.file_path(path, frontend_type) (File.exists?(instance_path) && instance_path) || (frontend_path && File.exists?(frontend_path) && frontend_path) || From 004f9fa69b406ffff211cbcefd2871302462a39c Mon Sep 17 00:00:00 2001 From: FloatingGhost Date: Tue, 28 Mar 2023 12:44:52 +0100 Subject: [PATCH 36/78] add selection UI --- config/description.exs | 6 ++++++ .../frontend_switcher_controller.ex | 20 +++++++++++++++++++ .../frontend_switcher_view.ex | 3 +++ lib/pleroma/web/plugs/frontend_static.ex | 15 ++++++++++++-- lib/pleroma/web/router.ex | 9 ++++----- .../frontend_switcher/switch.html.eex | 7 +++++++ 6 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 lib/pleroma/web/frontend_switcher/frontend_switcher_controller.ex create mode 100644 lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex create mode 100644 lib/pleroma/web/templates/frontend_switcher/switch.html.eex diff --git a/config/description.exs b/config/description.exs index 2755c36f9..e5e7160d4 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3328,6 +3328,12 @@ config :pleroma, :config_description, [ description: "A map containing available frontends and parameters for their installation.", children: frontend_options + }, + %{ + key: :pickable, + type: {:list, :string}, + description: + "A list containing all frontends users can pick as their preference, format is :name/:ref, e.g pleroma-fe/stable." } ] }, diff --git a/lib/pleroma/web/frontend_switcher/frontend_switcher_controller.ex b/lib/pleroma/web/frontend_switcher/frontend_switcher_controller.ex new file mode 100644 index 000000000..4f3efb3aa --- /dev/null +++ b/lib/pleroma/web/frontend_switcher/frontend_switcher_controller.ex @@ -0,0 +1,20 @@ +defmodule Pleroma.Web.FrontendSwitcher.FrontendSwitcherController do + use Pleroma.Web, :controller + alias Pleroma.Config + + @doc "GET /frontend_switcher" + def switch(conn, _params) do + pickable = Config.get([:frontends, :pickable], []) + + conn + |> put_view(Pleroma.Web.FrontendSwitcher.FrontendSwitcherView) + |> render("switch.html", choices: pickable) + end + + @doc "POST /frontend_switcher" + def do_switch(conn, params) do + conn + |> put_resp_cookie("preferred_frontend", params["frontend"]) + |> html("") + end +end diff --git a/lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex b/lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex new file mode 100644 index 000000000..e57e4e10b --- /dev/null +++ b/lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex @@ -0,0 +1,3 @@ +defmodule Pleroma.Web.FrontendSwitcher.FrontendSwitcherView do + use Pleroma.Web, :view +end diff --git a/lib/pleroma/web/plugs/frontend_static.ex b/lib/pleroma/web/plugs/frontend_static.ex index 179f2bd47..3708e9e4a 100644 --- a/lib/pleroma/web/plugs/frontend_static.ex +++ b/lib/pleroma/web/plugs/frontend_static.ex @@ -49,6 +49,7 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do end def call(conn, opts) do + IO.inspect("OPTS: #{inspect(opts)}") with false <- api_route?(conn.path_info), false <- invalid_path?(conn.path_info), fallback_frontend_type <- Map.get(opts, :frontend_type, :primary), @@ -69,16 +70,26 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do Map.get(cookies, @frontend_cookie_name) end - def preferred_or_fallback(conn, fallback) do + # Only override primary frontend + def preferred_or_fallback(conn, :primary) do case preferred_frontend(conn) do nil -> - fallback + :primary frontend -> frontend end end +<<<<<<< HEAD +======= + def preferred_or_fallback(conn, fallback), do: fallback + + defp enabled?(if_opt) when is_function(if_opt), do: if_opt.() + defp enabled?(true), do: true + defp enabled?(_), do: false + +>>>>>>> de64c6c54a (add selection UI) defp invalid_path?(list) do invalid_path?(list, :binary.compile_pattern(["/", "\\", ":", "\0"])) end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index a8636cfba..7658f8509 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -637,11 +637,6 @@ defmodule Pleroma.Web.Router do end scope "/api/v1/pleroma", Pleroma.Web.MastodonAPI do - pipe_through(:api) - get("/accounts/:id/endorsements", AccountController, :endorsements) - end - - scope "/api/v2/pleroma", Pleroma.Web.PleromaAPI do scope [] do pipe_through(:authenticated_api) get("/chats", ChatController, :index2) @@ -901,7 +896,11 @@ defmodule Pleroma.Web.Router do scope "/", Pleroma.Web do pipe_through(:browser) + get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe) + + get("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :switch) + post("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :do_switch) end pipeline :ap_service_actor do diff --git a/lib/pleroma/web/templates/frontend_switcher/switch.html.eex b/lib/pleroma/web/templates/frontend_switcher/switch.html.eex new file mode 100644 index 000000000..0692ddfb8 --- /dev/null +++ b/lib/pleroma/web/templates/frontend_switcher/switch.html.eex @@ -0,0 +1,7 @@ +

Switch Frontend

+ +<%= form_for @conn, Routes.frontend_switcher_path(@conn, :do_switch), fn f -> %> + <%= select(f, :frontend, @choices) %> + + <%= submit do: "submit" %> +<% end %> From bb44501a9eb9c2ba95d362de47f23b0963689b43 Mon Sep 17 00:00:00 2001 From: FloatingGhost Date: Sun, 12 Mar 2023 23:24:07 +0000 Subject: [PATCH 37/78] Add frontend preference route --- .../pleroma_frontend_settings_operation.ex | 62 +++++++++++++++++++ .../frontend_settings_controller.ex | 37 +++++++++++ lib/pleroma/web/router.ex | 12 ++++ 3 files changed, 111 insertions(+) create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/frontend_settings_controller.ex diff --git a/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex new file mode 100644 index 000000000..6d1e23277 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex @@ -0,0 +1,62 @@ +defmodule Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + import Pleroma.Web.ApiSpec.Helpers + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def available_frontends_operation() do + %Operation{ + tags: ["Frontends"], + summary: "Frontend Settings Profiles", + description: "List frontend setting profiles", + operationId: "PleromaAPI.FrontendSettingsController.available_frontends", + responses: %{ + 200 => + Operation.response("Frontends", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :string + } + }) + } + } + end + + def update_preferred_frontend_operation() do + %Operation{ + tags: ["Frontends"], + summary: "Frontend Settings Profiles", + description: "List frontend setting profiles", + operationId: "PleromaAPI.FrontendSettingsController.available_frontends", + requestBody: + request_body( + "Frontend", + %Schema{ + type: :object, + required: [:frontend_name], + properties: %{ + frontend_name: %Schema{ + type: :string, + description: "Frontend name" + } + } + }, + required: true + ), + responses: %{ + 200 => + Operation.response("Frontends", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :string + } + }) + } + } + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/frontend_settings_controller.ex b/lib/pleroma/web/pleroma_api/controllers/frontend_settings_controller.ex new file mode 100644 index 000000000..41531c97e --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/frontend_settings_controller.ex @@ -0,0 +1,37 @@ +defmodule Pleroma.Web.PleromaAPI.FrontendSettingsController do + use Pleroma.Web, :controller + + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug( + OAuthScopesPlug, + %{fallback: :proceed_unauthenticated, scopes: []} + when action in [ + :available_frontends, + :update_preferred_frontend + ] + ) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "GET /api/v1/pleroma/preferred_frontend/available" + def available_frontends(conn, _params) do + available = Pleroma.Config.get([:frontends, :pickable]) + + conn + |> json(available) + end + + @doc "PUT /api/v1/pleroma/preferred_frontend" + def update_preferred_frontend( + %{body_params: %{frontend_name: preferred_frontend}} = conn, + _params + ) do + conn + |> put_resp_cookie("preferred_frontend", preferred_frontend) + |> json(%{frontend_name: preferred_frontend}) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 7658f8509..46cb3a409 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -561,6 +561,18 @@ defmodule Pleroma.Web.Router do get("/apps", AppController, :index) get("/statuses/:id/reactions/:emoji", EmojiReactionController, :index) get("/statuses/:id/reactions", EmojiReactionController, :index) + + get( + "/preferred_frontend/available", + FrontendSettingsController, + :available_frontends + ) + + put( + "/preferred_frontend", + FrontendSettingsController, + :update_preferred_frontend + ) end scope "/api/v0/pleroma", Pleroma.Web.PleromaAPI do From 8827e511704b97ee84fa61c68bdfcb5868518dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Sat, 3 Feb 2024 14:24:03 +0100 Subject: [PATCH 38/78] Fix OpenAPI spec for preferred_frontend endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec was copied from another endpoint, including the operation id, leading to scrubbing the valid parameters from the request and simply not working. Signed-off-by: nicole mikołajczyk --- .../pleroma_frontend_settings_operation.ex | 6 +++--- lib/pleroma/web/plugs/frontend_static.ex | 7 ------- .../frontend_settings_controller_test.exs | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 test/pleroma/web/pleroma_api/controllers/frontend_settings_controller_test.exs diff --git a/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex index 6d1e23277..812b40e14 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex @@ -30,9 +30,9 @@ defmodule Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation do def update_preferred_frontend_operation() do %Operation{ tags: ["Frontends"], - summary: "Frontend Settings Profiles", - description: "List frontend setting profiles", - operationId: "PleromaAPI.FrontendSettingsController.available_frontends", + summary: "Update preferred frontend setting", + description: "Store preferred frontend in cookies", + operationId: "PleromaAPI.FrontendSettingsController.update_preferred_frontend", requestBody: request_body( "Frontend", diff --git a/lib/pleroma/web/plugs/frontend_static.ex b/lib/pleroma/web/plugs/frontend_static.ex index 3708e9e4a..2808f96fc 100644 --- a/lib/pleroma/web/plugs/frontend_static.ex +++ b/lib/pleroma/web/plugs/frontend_static.ex @@ -81,15 +81,8 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do end end -<<<<<<< HEAD -======= def preferred_or_fallback(conn, fallback), do: fallback - defp enabled?(if_opt) when is_function(if_opt), do: if_opt.() - defp enabled?(true), do: true - defp enabled?(_), do: false - ->>>>>>> de64c6c54a (add selection UI) defp invalid_path?(list) do invalid_path?(list, :binary.compile_pattern(["/", "\\", ":", "\0"])) end diff --git a/test/pleroma/web/pleroma_api/controllers/frontend_settings_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/frontend_settings_controller_test.exs new file mode 100644 index 000000000..8c62a5463 --- /dev/null +++ b/test/pleroma/web/pleroma_api/controllers/frontend_settings_controller_test.exs @@ -0,0 +1,19 @@ +defmodule Pleroma.Web.PleromaAPI.FrontendSettingsControllerTest do + use Pleroma.Web.ConnCase, async: false + + import Pleroma.Factory + + describe "PUT /api/v1/pleroma/preferred_frontend" do + test "sets a cookie with selected frontend" do + %{conn: conn} = oauth_access(["read"]) + + response = + conn + |> put_req_header("content-type", "application/json") + |> put("/api/v1/pleroma/preferred_frontend", %{"frontend_name" => "pleroma-fe/stable"}) + + json_response_and_validate_schema(response, 200) + assert %{"preferred_frontend" => %{value: "pleroma-fe/stable"}} = response.resp_cookies + end + end +end From fd177a363be9fbaac11bc37893ad8a2acbe9bd02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 28 Nov 2025 15:59:07 +0100 Subject: [PATCH 39/78] cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- config/description.exs | 8 ++++---- lib/pleroma/web/plugs/frontend_static.ex | 4 +--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/config/description.exs b/config/description.exs index e5e7160d4..a8a201e33 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3330,10 +3330,10 @@ config :pleroma, :config_description, [ children: frontend_options }, %{ - key: :pickable, - type: {:list, :string}, - description: - "A list containing all frontends users can pick as their preference, format is :name/:ref, e.g pleroma-fe/stable." + key: :pickable, + type: {:list, :string}, + description: + "A list containing all frontends users can pick as their preference, format is :name/:ref, e.g pleroma-fe/stable." } ] }, diff --git a/lib/pleroma/web/plugs/frontend_static.ex b/lib/pleroma/web/plugs/frontend_static.ex index 2808f96fc..6c0186f14 100644 --- a/lib/pleroma/web/plugs/frontend_static.ex +++ b/lib/pleroma/web/plugs/frontend_static.ex @@ -49,7 +49,6 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do end def call(conn, opts) do - IO.inspect("OPTS: #{inspect(opts)}") with false <- api_route?(conn.path_info), false <- invalid_path?(conn.path_info), fallback_frontend_type <- Map.get(opts, :frontend_type, :primary), @@ -81,7 +80,7 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do end end - def preferred_or_fallback(conn, fallback), do: fallback + def preferred_or_fallback(_conn, fallback), do: fallback defp invalid_path?(list) do invalid_path?(list, :binary.compile_pattern(["/", "\\", ":", "\0"])) @@ -100,7 +99,6 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do defp call_static(conn, opts, from) do opts = Map.put(opts, :from, from) - IO.inspect(opts, label: "opts") Plug.Static.call(conn, opts) end end From 1fd94ed001c6413301b4c1cd1fb4c8f7bf5f4867 Mon Sep 17 00:00:00 2001 From: FloatingGhost Date: Fri, 14 Apr 2023 17:42:40 +0100 Subject: [PATCH 40/78] ensure only pickable frontends can be returned --- lib/pleroma/web/plugs/frontend_static.ex | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/plugs/frontend_static.ex b/lib/pleroma/web/plugs/frontend_static.ex index 6c0186f14..f1df185e3 100644 --- a/lib/pleroma/web/plugs/frontend_static.ex +++ b/lib/pleroma/web/plugs/frontend_static.ex @@ -76,7 +76,11 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do :primary frontend -> - frontend + if Enum.member?(Pleroma.Config.get([:frontends, :pickable], []), frontend) do + frontend + else + :primary + end end end From f1586f023050ff071713a63ee9c5e5eb1d135093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 28 Nov 2025 16:01:00 +0100 Subject: [PATCH 41/78] im bad at merge conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- lib/pleroma/web/router.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 46cb3a409..acf8df538 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -649,6 +649,11 @@ defmodule Pleroma.Web.Router do end scope "/api/v1/pleroma", Pleroma.Web.MastodonAPI do + pipe_through(:api) + get("/accounts/:id/endorsements", AccountController, :endorsements) + end + + scope "/api/v2/pleroma", Pleroma.Web.PleromaAPI do scope [] do pipe_through(:authenticated_api) get("/chats", ChatController, :index2) From a80776b2607bbfdab888d2939c1597b1ad31f220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 28 Nov 2025 16:10:02 +0100 Subject: [PATCH 42/78] make it work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex | 2 ++ .../frontend_switcher/{ => frontend_switcher}/switch.html.eex | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) rename lib/pleroma/web/templates/frontend_switcher/{ => frontend_switcher}/switch.html.eex (86%) diff --git a/lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex b/lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex index e57e4e10b..284477431 100644 --- a/lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex +++ b/lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex @@ -1,3 +1,5 @@ defmodule Pleroma.Web.FrontendSwitcher.FrontendSwitcherView do use Pleroma.Web, :view + + import Phoenix.HTML.Form end diff --git a/lib/pleroma/web/templates/frontend_switcher/switch.html.eex b/lib/pleroma/web/templates/frontend_switcher/frontend_switcher/switch.html.eex similarity index 86% rename from lib/pleroma/web/templates/frontend_switcher/switch.html.eex rename to lib/pleroma/web/templates/frontend_switcher/frontend_switcher/switch.html.eex index 0692ddfb8..c801c8ee8 100644 --- a/lib/pleroma/web/templates/frontend_switcher/switch.html.eex +++ b/lib/pleroma/web/templates/frontend_switcher/frontend_switcher/switch.html.eex @@ -1,4 +1,4 @@ -

Switch Frontend

+

Switch frontend

<%= form_for @conn, Routes.frontend_switcher_path(@conn, :do_switch), fn f -> %> <%= select(f, :frontend, @choices) %> From 5c139be42e46bef5ec37d974804fc21c704fcec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 28 Nov 2025 16:30:03 +0100 Subject: [PATCH 43/78] Add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../web/plugs/frontend_static_plug_test.exs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/pleroma/web/plugs/frontend_static_plug_test.exs b/test/pleroma/web/plugs/frontend_static_plug_test.exs index a7af3e74e..cbe200738 100644 --- a/test/pleroma/web/plugs/frontend_static_plug_test.exs +++ b/test/pleroma/web/plugs/frontend_static_plug_test.exs @@ -97,6 +97,7 @@ defmodule Pleroma.Web.Plugs.FrontendStaticPlugTest do "users", "tags", "mailer", + "frontend_switcher", "inbox", "relay", "internal", @@ -113,4 +114,36 @@ defmodule Pleroma.Web.Plugs.FrontendStaticPlugTest do assert expected_routes == Pleroma.Web.Router.get_api_routes() end + + describe "preferred frontend cookie handling" do + test "returns preferred frontend file", %{conn: conn} do + name = "test-fe" + ref = "develop" + + clear_config([:frontends, :pickable], ["#{name}/#{ref}"]) + path = "#{@dir}/frontends/#{name}/#{ref}" + + Pleroma.Backports.mkdir_p!(path) + File.write!("#{path}/index.html", "from frontend plug") + + index = + conn + |> put_req_cookie("preferred_frontend", "#{name}/#{ref}") + |> get("/") + + assert html_response(index, 200) == "from frontend plug" + end + + test "only returns content from pickable frontends", %{conn: conn} do + clear_config([:instance, :static_dir], "instance/static") + clear_config([:frontends, :pickable], ["pleroma-fe/develop", "pl-fe/develop"]) + + config_file = + conn + |> put_req_cookie("preferred_frontend", "../../../config") + |> get("/config.exs") + + refute response(config_file, 200) =~ "import Config" + end + end end From 78c4332218a755ae81055d6ecd1ddf4fa68e8fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 28 Nov 2025 16:32:51 +0100 Subject: [PATCH 44/78] spec, changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/preferred-frontend.add | 1 + lib/pleroma/web/api_spec.ex | 3 ++- .../operations/pleroma_frontend_settings_operation.ex | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 changelog.d/preferred-frontend.add diff --git a/changelog.d/preferred-frontend.add b/changelog.d/preferred-frontend.add new file mode 100644 index 000000000..145e9451b --- /dev/null +++ b/changelog.d/preferred-frontend.add @@ -0,0 +1 @@ +Allow users to select preferred frontend diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index 3e0ac3704..5006167ea 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -151,7 +151,8 @@ defmodule Pleroma.Web.ApiSpec do "Suggestions", "Announcements", "Remote interaction", - "Others" + "Others", + "Preferred frontends" ] } ] diff --git a/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex index 812b40e14..8b74fe02c 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex @@ -11,8 +11,8 @@ defmodule Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation do def available_frontends_operation() do %Operation{ - tags: ["Frontends"], - summary: "Frontend Settings Profiles", + tags: ["Preferred frontends"], + summary: "Frontend settings profiles", description: "List frontend setting profiles", operationId: "PleromaAPI.FrontendSettingsController.available_frontends", responses: %{ @@ -29,7 +29,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation do def update_preferred_frontend_operation() do %Operation{ - tags: ["Frontends"], + tags: ["Preferred frontends"], summary: "Update preferred frontend setting", description: "Store preferred frontend in cookies", operationId: "PleromaAPI.FrontendSettingsController.update_preferred_frontend", From cc51ee8662a4ed47f6305c4b41f62469b188a8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sat, 29 Nov 2025 15:53:10 +0100 Subject: [PATCH 45/78] analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../pleroma_frontend_settings_operation.ex | 15 +++++++++------ .../frontend_switcher_controller.ex | 2 +- .../frontend_settings_controller_test.exs | 2 -- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex index 8b74fe02c..923e4fcc9 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation do apply(__MODULE__, operation, []) end - def available_frontends_operation() do + def available_frontends_operation do %Operation{ tags: ["Preferred frontends"], summary: "Frontend settings profiles", @@ -27,7 +27,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation do } end - def update_preferred_frontend_operation() do + def update_preferred_frontend_operation do %Operation{ tags: ["Preferred frontends"], summary: "Update preferred frontend setting", @@ -50,10 +50,13 @@ defmodule Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation do ), responses: %{ 200 => - Operation.response("Frontends", "application/json", %Schema{ - type: :array, - items: %Schema{ - type: :string + Operation.response("Preferred frontend", "application/json", %Schema{ + type: :object, + properties: %{ + frontend_name: %Schema{ + type: :string, + description: "Frontend name" + } } }) } diff --git a/lib/pleroma/web/frontend_switcher/frontend_switcher_controller.ex b/lib/pleroma/web/frontend_switcher/frontend_switcher_controller.ex index 4f3efb3aa..18752c63c 100644 --- a/lib/pleroma/web/frontend_switcher/frontend_switcher_controller.ex +++ b/lib/pleroma/web/frontend_switcher/frontend_switcher_controller.ex @@ -15,6 +15,6 @@ defmodule Pleroma.Web.FrontendSwitcher.FrontendSwitcherController do def do_switch(conn, params) do conn |> put_resp_cookie("preferred_frontend", params["frontend"]) - |> html("") + |> html(~s()) end end diff --git a/test/pleroma/web/pleroma_api/controllers/frontend_settings_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/frontend_settings_controller_test.exs index 8c62a5463..1f14c102f 100644 --- a/test/pleroma/web/pleroma_api/controllers/frontend_settings_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/frontend_settings_controller_test.exs @@ -1,8 +1,6 @@ defmodule Pleroma.Web.PleromaAPI.FrontendSettingsControllerTest do use Pleroma.Web.ConnCase, async: false - import Pleroma.Factory - describe "PUT /api/v1/pleroma/preferred_frontend" do test "sets a cookie with selected frontend" do %{conn: conn} = oauth_access(["read"]) From f61fad066382eaf7c1829b89c774c97563b63581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 19 Sep 2025 15:58:06 +0200 Subject: [PATCH 46/78] Pin/unpin chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/pin-chats.fix | 1 + docs/development/API/chats.md | 47 +++++++++++++---- lib/pleroma/chat.ex | 14 ++++++ .../web/api_spec/operations/chat_operation.ex | 44 +++++++++++++++- lib/pleroma/web/api_spec/schemas/chat.ex | 6 ++- .../web/mastodon_api/views/instance_view.ex | 1 + .../controllers/chat_controller.ex | 26 +++++++++- .../web/pleroma_api/views/chat_view.ex | 3 +- lib/pleroma/web/router.ex | 2 + .../20220222203933_add_pinned_to_chats.exs | 11 ++++ .../controllers/chat_controller_test.exs | 50 +++++++++++++++++++ .../web/pleroma_api/views/chat_view_test.exs | 3 +- 12 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 changelog.d/pin-chats.fix create mode 100644 priv/repo/migrations/20220222203933_add_pinned_to_chats.exs diff --git a/changelog.d/pin-chats.fix b/changelog.d/pin-chats.fix new file mode 100644 index 000000000..e7520ceaf --- /dev/null +++ b/changelog.d/pin-chats.fix @@ -0,0 +1 @@ +Allow to pin/unpip chats diff --git a/docs/development/API/chats.md b/docs/development/API/chats.md index f50144c86..ee2169adf 100644 --- a/docs/development/API/chats.md +++ b/docs/development/API/chats.md @@ -66,9 +66,9 @@ Returned data: "username": "somenick", ... }, - "id" : "1", - "unread" : 2, - "last_message" : {...}, // The last message in that chat + "id": "1", + "unread": 2, + "last_message": {...}, // The last message in that chat "updated_at": "2020-04-21T15:11:46.000Z" } ``` @@ -93,8 +93,8 @@ Returned data: "username": "somenick", ... }, - "id" : "1", - "unread" : 0, + "id": "1", + "unread": 0, "updated_at": "2020-04-21T15:11:46.000Z" } ``` @@ -111,7 +111,7 @@ The modified chat message ### Getting a list of Chats -`GET /api/v1/pleroma/chats` +`GET /api/v2/pleroma/chats` This will return a list of chats that you have been involved in, sorted by their last update (so new chats will be at the top). @@ -119,6 +119,7 @@ last update (so new chats will be at the top). Parameters: - with_muted: Include chats from muted users (boolean). +- pinned: Include only pinned chats (boolean). Returned data: @@ -130,16 +131,16 @@ Returned data: "username": "somenick", ... }, - "id" : "1", - "unread" : 2, - "last_message" : {...}, // The last message in that chat + "id": "1", + "unread": 2, + "last_message": {...}, // The last message in that chat "updated_at": "2020-04-21T15:11:46.000Z" } ] ``` The recipient of messages that are sent to this chat is given by their AP ID. -No pagination is implemented for now. +The usual pagination options are implemented. ### Getting the messages for a Chat @@ -226,6 +227,32 @@ Deleting a chat message for given Chat id works like this: Returned data is the deleted message. +### Pinning a chat + +Pinning a chat works like this: + +`POST /api/v1/pleroma/chats/:id/pin` + +Returned data: + +```json +{ + "account": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id": "1", + "unread": 0, + "updated_at": "2020-04-21T15:11:46.000Z", + "pinned": true, +} +``` + +To unpin a pinned chat, use: + +`POST /api/v1/pleroma/chats/:id/unpin` + ### Notifications There's a new `pleroma:chat_mention` notification, which has this form. It is not given out in the notifications endpoint by default, you need to explicitly request it with `include_types[]=pleroma:chat_mention`: diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 5c4dbc1ff..926176bfa 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -25,6 +25,8 @@ defmodule Pleroma.Chat do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:recipient, :string) + field(:pinned, :boolean) + timestamps() end @@ -94,4 +96,16 @@ defmodule Pleroma.Chat do order_by: [desc: c.updated_at] ) end + + def pin(%__MODULE__{} = chat) do + chat + |> cast(%{pinned: true}, [:pinned]) + |> Repo.update() + end + + def unpin(%__MODULE__{} = chat) do + chat + |> cast(%{pinned: false}, [:pinned]) + |> Repo.update() + end end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index f56e57a41..76c2db57e 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -142,7 +142,8 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do :query, BooleanLike.schema(), "Include chats from muted users" - ) + ), + Operation.parameter(:pinned, :query, BooleanLike.schema(), "Include only pinned chats") ], responses: %{ 200 => Operation.response("The chats of the user", "application/json", chats_response()) @@ -166,7 +167,8 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do :query, BooleanLike.schema(), "Include chats from muted users" - ) + ), + Operation.parameter(:pinned, :query, BooleanLike.schema(), "Include only pinned chats") | pagination_params() ], responses: %{ @@ -257,6 +259,44 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do } end + def pin_operation do + %Operation{ + tags: ["Chats"], + summary: "Pin a chat", + operationId: "ChatController.pin", + parameters: [ + Operation.parameter(:id, :path, :string, "The id of the chat", required: true) + ], + responses: %{ + 200 => Operation.response("The existing chat", "application/json", Chat) + }, + security: [ + %{ + "oAuth" => ["write:chats"] + } + ] + } + end + + def unpin_operation do + %Operation{ + tags: ["Chats"], + summary: "Unpin a chat", + operationId: "ChatController.unpin", + parameters: [ + Operation.parameter(:id, :path, :string, "The id of the chat", required: true) + ], + responses: %{ + 200 => Operation.response("The existing chat", "application/json", Chat) + }, + security: [ + %{ + "oAuth" => ["write:chats"] + } + ] + } + end + def chats_response do %Schema{ title: "ChatsResponse", diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex index affa25a95..ce08896b5 100644 --- a/lib/pleroma/web/api_spec/schemas/chat.ex +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -17,7 +17,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do account: %Schema{type: :object}, unread: %Schema{type: :integer}, last_message: ChatMessage, - updated_at: %Schema{type: :string, format: :"date-time"} + updated_at: %Schema{type: :string, format: :"date-time"}, + pinned: %Schema{type: :boolean} }, example: %{ "account" => %{ @@ -69,7 +70,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do "id" => "1", "unread" => 2, "last_message" => ChatMessage.schema().example, - "updated_at" => "2020-04-21T15:06:45.000Z" + "updated_at" => "2020-04-21T15:06:45.000Z", + "pinned" => false } }) end diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index b21383659..57372248f 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -146,6 +146,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do "pleroma_emoji_reactions", "pleroma_custom_emoji_reactions", "pleroma_chat_messages", + "pleroma:pin_chats", if Config.get([:instance, :show_reactions]) do "exposable_reactions" end, diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 58780ace2..ac9bb2779 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -29,7 +29,9 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do :create, :mark_as_read, :mark_message_as_read, - :delete_message + :delete_message, + :pin, + :unpin ] ) @@ -199,8 +201,16 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do user_id |> Chat.for_user_query() |> where([c], c.recipient not in ^exclude_users) + |> restrict_pinned(params) end + defp restrict_pinned(query, %{pinned: pinned}) when is_boolean(pinned) do + query + |> where([c], c.pinned == ^pinned) + end + + defp restrict_pinned(query, _), do: query + def create(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with %User{ap_id: recipient} <- User.get_cached_by_id(id), {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do @@ -214,6 +224,20 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do end end + def pin(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do + with {:ok, chat} <- Chat.get_by_user_and_id(user, id), + {:ok, chat} <- Chat.pin(chat) do + render(conn, "show.json", chat: chat) + end + end + + def unpin(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do + with {:ok, chat} <- Chat.get_by_user_and_id(user, id), + {:ok, chat} <- Chat.unpin(chat) do + render(conn, "show.json", chat: chat) + end + end + defp idempotency_key(conn) do case get_req_header(conn, "idempotency-key") do [key] -> key diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index db6c13c05..d579ca9f6 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -24,7 +24,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do last_message: last_message && MessageReferenceView.render("show.json", chat_message_reference: last_message), - updated_at: Utils.to_masto_date(chat.updated_at) + updated_at: Utils.to_masto_date(chat.updated_at), + pinned: chat.pinned } end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 5e1ef7480..1dd4a32ef 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -581,6 +581,8 @@ defmodule Pleroma.Web.Router do delete("/chats/:id/messages/:message_id", ChatController, :delete_message) post("/chats/:id/read", ChatController, :mark_as_read) post("/chats/:id/messages/:message_id/read", ChatController, :mark_message_as_read) + post("/chats/:id/pin", ChatController, :pin) + post("/chats/:id/unpin", ChatController, :unpin) get("/conversations/:id/statuses", ConversationController, :statuses) get("/conversations/:id", ConversationController, :show) diff --git a/priv/repo/migrations/20220222203933_add_pinned_to_chats.exs b/priv/repo/migrations/20220222203933_add_pinned_to_chats.exs new file mode 100644 index 000000000..2dc461d77 --- /dev/null +++ b/priv/repo/migrations/20220222203933_add_pinned_to_chats.exs @@ -0,0 +1,11 @@ +defmodule Pleroma.Repo.Migrations.AddPinnedToChats do + use Ecto.Migration + + def change do + alter table(:chats) do + add(:pinned, :boolean, default: false, null: false) + end + + create(index(:chats, [:pinned])) + end +end diff --git a/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs index 0d3452559..0dc0e7014 100644 --- a/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs @@ -337,6 +337,41 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do end end + describe "POST /api/v1/pleroma/chats/:id/pin" do + setup do: oauth_access(["write:chats"]) + + test "it pins a chat", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + result = + conn + |> post("/api/v1/pleroma/chats/#{chat.id}/pin") + |> json_response_and_validate_schema(200) + + assert %{"pinned" => true} = result + end + end + + describe "POST /api/v1/pleroma/chats/:id/unpin" do + setup do: oauth_access(["write:chats"]) + + test "it unpins a chat", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.pin(chat) + + result = + conn + |> post("/api/v1/pleroma/chats/#{chat.id}/unpin") + |> json_response_and_validate_schema(200) + + assert %{"pinned" => false} = result + end + end + for tested_endpoint <- ["/api/v1/pleroma/chats", "/api/v2/pleroma/chats"] do describe "GET #{tested_endpoint}" do setup do: oauth_access(["read:chats"]) @@ -407,6 +442,21 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do assert length(result) == 1 end + test "it only returns pinned chats", %{conn: conn, user: user} do + recipient1 = insert(:user) + recipient2 = insert(:user) + + {:ok, %{id: id} = chat} = Chat.get_or_create(user.id, recipient1.ap_id) + {:ok, _} = Chat.get_or_create(user.id, recipient2.ap_id) + + Chat.pin(chat) + + [%{"id" => ^id, "pinned" => true}] = + conn + |> get("#{unquote(tested_endpoint)}?pinned=true") + |> json_response_and_validate_schema(200) + end + if tested_endpoint == "/api/v1/pleroma/chats" do test "it returns all chats", %{conn: conn, user: user} do Enum.each(1..30, fn _ -> diff --git a/test/pleroma/web/pleroma_api/views/chat_view_test.exs b/test/pleroma/web/pleroma_api/views/chat_view_test.exs index 405f1b6b7..d7efe8dfe 100644 --- a/test/pleroma/web/pleroma_api/views/chat_view_test.exs +++ b/test/pleroma/web/pleroma_api/views/chat_view_test.exs @@ -30,7 +30,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do AccountView.render("show.json", user: recipient, skip_visibility_check: true), unread: 0, last_message: nil, - updated_at: Utils.to_masto_date(chat.updated_at) + updated_at: Utils.to_masto_date(chat.updated_at), + pinned: false } {:ok, chat_message_creation} = CommonAPI.post_chat_message(user, recipient, "hello") From d2f6cc1445392f8d49e2922ce3a769b9b314fcfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 29 Aug 2025 04:39:39 +0200 Subject: [PATCH 47/78] Use separate schemas for muted/blocked accounts lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/blocked-muted-swagger.change | 1 + .../api_spec/operations/account_operation.ex | 46 ++++++++++++++++++- lib/pleroma/web/api_spec/schemas/account.ex | 2 - 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 changelog.d/blocked-muted-swagger.change diff --git a/changelog.d/blocked-muted-swagger.change b/changelog.d/blocked-muted-swagger.change new file mode 100644 index 000000000..12bba8612 --- /dev/null +++ b/changelog.d/blocked-muted-swagger.change @@ -0,0 +1 @@ +Use separate schemas for muted/blocked accounts lists \ No newline at end of file diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 620fd171b..b7fa12ba8 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -483,7 +483,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do security: [%{"oAuth" => ["follow", "read:mutes"]}], parameters: [with_relationships_param() | pagination_params()], responses: %{ - 200 => Operation.response("Accounts", "application/json", array_of_accounts()) + 200 => Operation.response("Accounts", "application/json", array_of_muted_accounts()) } } end @@ -497,7 +497,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do security: [%{"oAuth" => ["read:blocks"]}], parameters: [with_relationships_param() | pagination_params()], responses: %{ - 200 => Operation.response("Accounts", "application/json", array_of_accounts()) + 200 => Operation.response("Accounts", "application/json", array_of_blocked_accounts()) } } end @@ -896,6 +896,48 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do } end + def array_of_muted_accounts do + %Schema{ + title: "ArrayOfMutedAccounts", + type: :array, + items: %Schema{ + title: "MutedAccount", + description: "Response schema for a muted account", + allOf: [ + Account, + %Schema{ + type: :object, + properties: %{ + mute_expires_at: %Schema{type: :string, format: "date-time", nullable: true} + } + } + ] + }, + example: [Account.schema().example] + } + end + + def array_of_blocked_accounts do + %Schema{ + title: "ArrayOfBlockedAccounts", + type: :array, + items: %Schema{ + title: "BlockedAccount", + description: "Response schema for a blocked account", + allOf: [ + Account, + %Schema{ + type: :object, + properties: %{ + block_expires_at: %Schema{type: :string, format: "date-time", nullable: true} + } + } + ] + }, + example: [Account.schema().example] + } + end + defp array_of_relationships do %Schema{ title: "ArrayOfRelationships", diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 19827e996..7d0b83afe 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -33,8 +33,6 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do header: %Schema{type: :string, format: :uri}, id: FlakeID, locked: %Schema{type: :boolean}, - mute_expires_at: %Schema{type: :string, format: "date-time", nullable: true}, - block_expires_at: %Schema{type: :string, format: "date-time", nullable: true}, note: %Schema{type: :string, format: :html}, statuses_count: %Schema{type: :integer}, url: %Schema{type: :string, format: :uri}, From be0146afb3c2d6414b9c7028d489c63ff4c1063a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sat, 29 Nov 2025 17:10:21 +0100 Subject: [PATCH 48/78] Improve example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../web/api_spec/operations/account_operation.ex | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index b7fa12ba8..6fdd21ead 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -913,7 +913,10 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do } ] }, - example: [Account.schema().example] + example: [ + Account.schema().example + |> Map.put("mute_expires_at", "2025-11-29T16:23:13Z") + ] } end @@ -934,7 +937,10 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do } ] }, - example: [Account.schema().example] + example: [ + Account.schema().example + |> Map.put("block_expires_at", "2025-11-29T16:23:13Z") + ] } end From ef41378fa2042a7a7f1d571a41a8665fc4d5a91f Mon Sep 17 00:00:00 2001 From: Atsuko Karagi Date: Mon, 19 Dec 2022 20:32:16 +0000 Subject: [PATCH 49/78] Respect restrict_unauthenticated in /api/v1/accounts/lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../lookup-restrict-unauthenticated.fix | 1 + .../api_spec/operations/account_operation.ex | 1 + .../controllers/account_controller.ex | 13 ++++-- .../controllers/account_controller_test.exs | 44 +++++++++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 changelog.d/lookup-restrict-unauthenticated.fix diff --git a/changelog.d/lookup-restrict-unauthenticated.fix b/changelog.d/lookup-restrict-unauthenticated.fix new file mode 100644 index 000000000..a062b9361 --- /dev/null +++ b/changelog.d/lookup-restrict-unauthenticated.fix @@ -0,0 +1 @@ +Respect restrict_unauthenticated in /api/v1/accounts/lookup diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 620fd171b..5935baafe 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -517,6 +517,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do ], responses: %{ 200 => Operation.response("Account", "application/json", Account), + 401 => Operation.response("Error", "application/json", ApiError), 404 => Operation.response("Error", "application/json", ApiError) } } diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index c2f8e9d07..6dc731ed4 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -31,14 +31,14 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) - plug(:skip_auth when action in [:create, :lookup]) + plug(:skip_auth when action in [:create]) plug(:skip_public_check when action in [:show, :statuses]) plug( OAuthScopesPlug, %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} - when action in [:show, :followers, :following, :endorsements] + when action in [:show, :followers, :following, :lookup, :endorsements] ) plug( @@ -635,8 +635,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end @doc "GET /api/v1/accounts/lookup" - def lookup(%{private: %{open_api_spex: %{params: %{acct: nickname}}}} = conn, _params) do - with %User{} = user <- User.get_by_nickname(nickname) do + def lookup( + %{assigns: %{user: for_user}, private: %{open_api_spex: %{params: %{acct: nickname}}}} = + conn, + _params + ) do + with %User{} = user <- User.get_by_nickname(nickname), + :visible <- User.visible_for(user, for_user) do render(conn, "show.json", user: user, skip_visibility_check: true diff --git a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs index 6c277d766..02da781dd 100644 --- a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs @@ -2104,6 +2104,50 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do |> json_response_and_validate_schema(404) end + test "account lookup with restrict unauthenticated profiles for local" do + clear_config([:restrict_unauthenticated, :profiles, :local], true) + + user = insert(:user, local: true) + reading_user = insert(:user) + + conn = + build_conn() + |> get("/api/v1/accounts/lookup?acct=#{user.nickname}") + + assert json_response_and_validate_schema(conn, 401) + + conn = + build_conn() + |> assign(:user, reading_user) + |> assign(:token, insert(:oauth_token, user: reading_user, scopes: ["read:accounts"])) + |> get("/api/v1/accounts/lookup?acct=#{user.nickname}") + + assert %{"id" => id} = json_response_and_validate_schema(conn, 200) + assert id == user.id + end + + test "account lookup with restrict unauthenticated profiles for remote" do + clear_config([:restrict_unauthenticated, :profiles, :remote], true) + + user = insert(:user, nickname: "user@example.com", local: false) + reading_user = insert(:user) + + conn = + build_conn() + |> get("/api/v1/accounts/lookup?acct=#{user.nickname}") + + assert json_response_and_validate_schema(conn, 401) + + conn = + build_conn() + |> assign(:user, reading_user) + |> assign(:token, insert(:oauth_token, user: reading_user, scopes: ["read:accounts"])) + |> get("/api/v1/accounts/lookup?acct=#{user.nickname}") + + assert %{"id" => id} = json_response_and_validate_schema(conn, 200) + assert id == user.id + end + test "create a note on a user" do %{conn: conn} = oauth_access(["write:accounts", "read:follows"]) other_user = insert(:user) From 5cb141a54eff22504e34fdc52b13f346b34656f9 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 29 Nov 2025 17:12:32 +0000 Subject: [PATCH 50/78] MRF InlineQuotePolicy: Don't inline quoted post URL in Mastodon quotes --- changelog.d/mrf-inlinequotes-mastodon.fix | 1 + .../activity_pub/mrf/inline_quote_policy.ex | 2 + .../quote_post/mastodon_quote_post.json | 93 +++++++++++++++++++ .../mrf/inline_quote_policy_test.exs | 18 ++++ 4 files changed, 114 insertions(+) create mode 100644 changelog.d/mrf-inlinequotes-mastodon.fix create mode 100644 test/fixtures/quote_post/mastodon_quote_post.json diff --git a/changelog.d/mrf-inlinequotes-mastodon.fix b/changelog.d/mrf-inlinequotes-mastodon.fix new file mode 100644 index 000000000..638b3fde3 --- /dev/null +++ b/changelog.d/mrf-inlinequotes-mastodon.fix @@ -0,0 +1 @@ +MRF InlineQuotePolicy: Don't inline quoted post URL in Mastodon quote posts 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 469d06ef6..51dcd3918 100644 --- a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex @@ -18,6 +18,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy do content =~ quote_url -> true # Does the content already have a .quote-inline span? content =~ "" -> true + # Does the content already have a .quote-inline p? (Mastodon) + content =~ "

" -> true # No inline quote found true -> false end diff --git a/test/fixtures/quote_post/mastodon_quote_post.json b/test/fixtures/quote_post/mastodon_quote_post.json new file mode 100644 index 000000000..527a61b91 --- /dev/null +++ b/test/fixtures/quote_post/mastodon_quote_post.json @@ -0,0 +1,93 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount", + "quote": "https://w3id.org/fep/044f#quote", + "quoteUri": "http://fedibird.com/ns#quoteUri", + "_misskey_quote": "https://misskey-hub.net/ns#_misskey_quote", + "quoteAuthorization": { + "@id": "https://w3id.org/fep/044f#quoteAuthorization", + "@type": "@id" + }, + "gts": "https://gotosocial.org/ns#", + "interactionPolicy": { + "@id": "gts:interactionPolicy", + "@type": "@id" + }, + "canQuote": { + "@id": "gts:canQuote", + "@type": "@id" + }, + "automaticApproval": { + "@id": "gts:automaticApproval", + "@type": "@id" + }, + "manualApproval": { + "@id": "gts:manualApproval", + "@type": "@id" + } + } + ], + "id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2025-10-09T17:54:47Z", + "url": "https://mastodon.social/@gwynnion/115345489087257171", + "attributedTo": "https://mastodon.social/users/gwynnion", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://mastodon.social/users/gwynnion/followers" + ], + "sensitive": false, + "atomUri": "https://mastodon.social/users/gwynnion/statuses/115345489087257171", + "inReplyToAtomUri": null, + "conversation": "https://mastodon.social/contexts/109836797527169643-115345489087257171", + "context": "https://mastodon.social/contexts/109836797527169643-115345489087257171", + "content": "

RE: https://mastodon.social/@404mediaco/115344945575874225

Every age verification system is just a scheme for companies and hackers to steal your identity.

", + "contentMap": { + "en": "

RE: https://mastodon.social/@404mediaco/115344945575874225

Every age verification system is just a scheme for companies and hackers to steal your identity.

" + }, + "quote": "https://mastodon.social/users/404mediaco/statuses/115344945575874225", + "_misskey_quote": "https://mastodon.social/users/404mediaco/statuses/115344945575874225", + "quoteUri": "https://mastodon.social/users/404mediaco/statuses/115344945575874225", + "quoteAuthorization": "https://mastodon.social/users/404mediaco/quote_authorizations/115345489087269783", + "interactionPolicy": { + "canQuote": { + "automaticApproval": [ + "https://www.w3.org/ns/activitystreams#Public" + ] + } + }, + "attachment": [], + "tag": [], + "replies": { + "id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/replies?only_other_accounts=true&page=true", + "partOf": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/replies", + "items": [] + } + }, + "likes": { + "id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/likes", + "type": "Collection", + "totalItems": 26 + }, + "shares": { + "id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/shares", + "type": "Collection", + "totalItems": 28 + } +} diff --git a/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs b/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs index d5762766f..c03787abb 100644 --- a/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs @@ -109,4 +109,22 @@ defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicyTest do {:ok, filtered} = InlineQuotePolicy.filter(activity) assert filtered == activity end + + # Mastodon uses p tags instead of span in their quote posts + # URLs in quoteUri and post content are already mismatched + test "skips objects which already have an .inline-quote p" do + object = File.read!("test/fixtures/quote_post/mastodon_quote_post.json") |> Jason.decode!() + + # Normally the ObjectValidator will fix this before it reaches MRF + object = Map.put(object, "quoteUrl", object["quoteUri"]) + + activity = %{ + "type" => "Create", + "actor" => "https://mastodon.social/users/gwynnion", + "object" => object + } + + {:ok, filtered} = InlineQuotePolicy.filter(activity) + assert filtered == activity + end end From 21f486c8725ddf625e39c7592eff8bd7b23785bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sun, 30 Nov 2025 00:05:24 +0100 Subject: [PATCH 51/78] Order favourites and reblogs list from newest to oldest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/order-favourites-reblogs.change | 1 + .../controllers/status_controller.ex | 2 ++ .../controllers/status_controller_test.exs | 21 ++++++++++++++----- 3 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 changelog.d/order-favourites-reblogs.change diff --git a/changelog.d/order-favourites-reblogs.change b/changelog.d/order-favourites-reblogs.change new file mode 100644 index 000000000..67c235d62 --- /dev/null +++ b/changelog.d/order-favourites-reblogs.change @@ -0,0 +1 @@ +Order favourites and reblogs list from newest to oldest diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 32874d464..e2b7af388 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -488,6 +488,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do users = User |> Ecto.Query.where([u], u.ap_id in ^likes) + |> Ecto.Query.order_by([u], fragment("array_position(?, ?)", ^likes, u.ap_id)) |> Repo.all() |> Enum.filter(&(not User.blocks?(user, &1))) @@ -523,6 +524,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do users = User |> Ecto.Query.where([u], u.ap_id in ^announces) + |> Ecto.Query.order_by([u], fragment("array_position(?, ?)", ^announces, u.ap_id)) |> Repo.all() |> Enum.filter(&(not User.blocks?(user, &1))) 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 25a17d5c1..f9b81e0de 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -1867,18 +1867,29 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do %{activity: activity} end - test "returns users who have favorited the status", %{conn: conn, activity: activity} do - other_user = insert(:user) - {:ok, _} = CommonAPI.favorite(activity.id, other_user) + test "returns users who have favorited the status ordered from newest to oldest", %{ + conn: conn, + activity: activity + } do + [other_user_1, other_user_2] = insert_pair(:user) + [other_user_3, other_user_4] = insert_pair(:user) + + {:ok, _} = CommonAPI.favorite(activity.id, other_user_1) + {:ok, _} = CommonAPI.favorite(activity.id, other_user_3) + {:ok, _} = CommonAPI.favorite(activity.id, other_user_2) + {:ok, _} = CommonAPI.favorite(activity.id, other_user_4) response = conn |> get("/api/v1/statuses/#{activity.id}/favourited_by") |> json_response_and_validate_schema(:ok) - [%{"id" => id}] = response + [%{"id" => id1}, %{"id" => id2}, %{"id" => id3}, %{"id" => id4}] = response - assert id == other_user.id + assert id1 == other_user_4.id + assert id2 == other_user_2.id + assert id3 == other_user_3.id + assert id4 == other_user_1.id end test "returns empty array when status has not been favorited yet", %{ From c1aad0f141acc2e5eaee58ff1deb06541e0fbb94 Mon Sep 17 00:00:00 2001 From: Oneric Date: Tue, 19 Nov 2024 19:25:31 +0100 Subject: [PATCH 52/78] Fix NodeInfo content-type Fixes: https://akkoma.dev/AkkomaGang/akkoma/issues/852 --- lib/pleroma/web/nodeinfo/nodeinfo_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index 85c2393ff..c4e48e83a 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -36,7 +36,7 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do conn |> put_resp_header( "content-type", - "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" + "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/#{version}#\"; charset=utf-8" ) |> json(node_info) end From 32ab9d628e2265b4070ff80c7ebd2e43479234c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 2 Dec 2025 14:39:51 +0100 Subject: [PATCH 53/78] Add changelog entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/nodeinfo-content-type.fix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/nodeinfo-content-type.fix diff --git a/changelog.d/nodeinfo-content-type.fix b/changelog.d/nodeinfo-content-type.fix new file mode 100644 index 000000000..255fab475 --- /dev/null +++ b/changelog.d/nodeinfo-content-type.fix @@ -0,0 +1 @@ +Fix NodeInfo content-type From df1a3b5a700a1f6192e2c5649fae4de5062c751b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 2 Dec 2025 19:36:50 +0100 Subject: [PATCH 54/78] changelog-checker: Change changelog entry type --- changelog.d/{changelog-checker.fix => changelog-checker.skip} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{changelog-checker.fix => changelog-checker.skip} (100%) diff --git a/changelog.d/changelog-checker.fix b/changelog.d/changelog-checker.skip similarity index 100% rename from changelog.d/changelog-checker.fix rename to changelog.d/changelog-checker.skip From 0f3b1808fd3dc835e0fe993ceb16ec4d6ea12c03 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 14 Aug 2025 22:03:23 +0200 Subject: [PATCH 55/78] Check what chars to encode in the path segment of URIs, add list to Constants https://datatracker.ietf.org/doc/html/rfc3986 --- lib/pleroma/constants.ex | 7 +++++++ lib/pleroma/http.ex | 12 +++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 92ca11494..f02607273 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -132,6 +132,13 @@ defmodule Pleroma.Constants do do: ~r/^[^[:cntrl:] ()<>@,;:\\"\/\[\]?=]+\/[^[:cntrl:] ()<>@,;:\\"\/\[\]?=]+(; .*)?$/ ) + # List of allowed chars in the path segment of a URI + # unreserved, sub-delims, ":", "@" and "/" allowed as the separator in path + # https://datatracker.ietf.org/doc/html/rfc3986 + const(uri_path_allowed_reserved_chars, + do: ~c"!$&'()*+,;=/:@" + ) + const(upload_object_types, do: ["Document", "Image"]) const(activity_json_canonical_mime_type, diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index 9a0868d33..c6bf20127 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -14,6 +14,7 @@ defmodule Pleroma.HTTP do alias Tesla.Env require Logger + require Pleroma.Constants @type t :: __MODULE__ @type method() :: :get | :post | :put | :delete | :head @@ -145,10 +146,19 @@ defmodule Pleroma.HTTP do defp encode_path(nil), do: nil + # URI.encode/2 deliberately does not encode all chars that are forbidden + # in the path component of a URI. It only encodes chars that are forbidden + # in the whole URI. A predicate in the 2nd argument is used to fix that here. + # URI.encode/2 uses the predicate function to determine whether each byte + # (in an integer representation) should be encoded or not. defp encode_path(path) when is_binary(path) do path |> URI.decode() - |> URI.encode() + |> URI.encode(fn byte -> + URI.char_unreserved?(byte) || Enum.any?( + Pleroma.Constants.uri_path_allowed_reserved_chars, fn char -> + char == byte end) + end) end defp encode_query(nil), do: nil From 619f247e38eb70746426d6440a373c4d682c776b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 23 Aug 2025 23:55:36 +0200 Subject: [PATCH 56/78] Add more URL-encoding tests --- test/pleroma/http_test.exs | 21 ++++++++++++++ test/pleroma/reverse_proxy_test.exs | 43 +++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/test/pleroma/http_test.exs b/test/pleroma/http_test.exs index 61347015d..513802dfb 100644 --- a/test/pleroma/http_test.exs +++ b/test/pleroma/http_test.exs @@ -28,6 +28,15 @@ defmodule Pleroma.HTTPTest do %{method: :get, url: "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz"} -> %Tesla.Env{status: 200, body: "emoji data"} + + %{ + method: :get, + url: "https://example.com/media/foo/bar%20!$&'()*+,;=/:%20@a%20%5Bbaz%5D.mp4" + } -> + %Tesla.Env{status: 200, body: "video data"} + + %{method: :get, url: "https://example.com/media/unicode%20%F0%9F%99%82%20.gif"} -> + %Tesla.Env{status: 200, body: "unicode data"} end) :ok @@ -85,5 +94,17 @@ defmodule Pleroma.HTTPTest do {:ok, result} = HTTP.get(properly_encoded_url) assert result.status == 200 + + url_with_reserved_chars = "https://example.com/media/foo/bar !$&'()*+,;=/: @a [baz].mp4" + + {:ok, result} = HTTP.get(url_with_reserved_chars) + + assert result.status == 200 + + url_with_unicode = "https://example.com/media/unicode 🙂 .gif" + + {:ok, result} = HTTP.get(url_with_unicode) + + assert result.status == 200 end end diff --git a/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs index 034ab28a5..62ad2f38a 100644 --- a/test/pleroma/reverse_proxy_test.exs +++ b/test/pleroma/reverse_proxy_test.exs @@ -402,12 +402,27 @@ defmodule Pleroma.ReverseProxyTest do describe "Hackney URL encoding:" do setup do ClientMock - |> expect(:request, fn :get, - "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz", - _headers, - _body, - _opts -> - {:ok, 200, [{"content-type", "image/png"}], "It works!"} + |> expect(:request, fn + :get, + "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz", + _headers, + _body, + _opts -> + {:ok, 200, [{"content-type", "image/png"}], "It works!"} + + :get, + "https://example.com/media/foo/bar%20!$&'()*+,;=/:%20@a%20%5Bbaz%5D.mp4", + _headers, + _body, + _opts -> + {:ok, 200, [{"content-type", "video/mp4"}], "Allowed reserved chars."} + + :get, + "https://example.com/media/unicode%20%F0%9F%99%82%20.gif", + _headers, + _body, + _opts -> + {:ok, 200, [{"content-type", "image/gif"}], "Unicode emoji in path"} end) |> stub(:stream_body, fn _ -> :done end) |> stub(:close, fn _ -> :ok end) @@ -430,5 +445,21 @@ defmodule Pleroma.ReverseProxyTest do assert result.status == 200 end + + test "properly encodes URLs with allowed reserved characters", %{conn: conn} do + url_with_reserved_chars = "https://example.com/media/foo/bar !$&'()*+,;=/: @a [baz].mp4" + + result = ReverseProxy.call(conn, url_with_reserved_chars) + + assert result.status == 200 + end + + test "properly encodes URLs with unicode in path", %{conn: conn} do + url_with_unicode = "https://example.com/media/unicode 🙂 .gif" + + result = ReverseProxy.call(conn, url_with_unicode) + + assert result.status == 200 + end end end From 0a8423fdf726e6c1d9d6bf781e14d610bb917ed9 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 29 Aug 2025 14:47:19 +0200 Subject: [PATCH 57/78] Add ability to bypass url decode/parse in Pleroma.HTTP, fix encode in Pleroma.Upload --- lib/pleroma/http.ex | 43 ++++++++++++++++++++++++++---------- lib/pleroma/upload.ex | 11 +++++++-- test/pleroma/upload_test.exs | 15 +++++++++++++ 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index c6bf20127..6f4abb30a 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -133,32 +133,51 @@ defmodule Pleroma.HTTP do defp default_middleware, do: [Tesla.Middleware.FollowRedirects, Pleroma.Tesla.Middleware.EncodeUrl] - def encode_url(url) when is_binary(url) do - URI.parse(url) - |> then(fn parsed -> - path = encode_path(parsed.path) - query = encode_query(parsed.query) + # We don't always want to decode the path first, like is the case in + # Pleroma.Upload.url_from_spec/3. + def encode_url(url, opts \\ []) when is_binary(url) and is_list(opts) do + bypass_parse = Keyword.get(opts, :bypass_parse, false) + bypass_decode = Keyword.get(opts, :bypass_decode, false) - %{parsed | path: path, query: query} - end) - |> URI.to_string() + cond do + bypass_parse -> + encode_path(url, bypass_decode) + + true -> + URI.parse(url) + |> then(fn parsed -> + path = encode_path(parsed.path, bypass_decode) + query = encode_query(parsed.query) + + %{parsed | path: path, query: query} + end) + |> URI.to_string() + end end - defp encode_path(nil), do: nil + defp encode_path(nil, _bypass_decode), do: nil # URI.encode/2 deliberately does not encode all chars that are forbidden # in the path component of a URI. It only encodes chars that are forbidden # in the whole URI. A predicate in the 2nd argument is used to fix that here. # URI.encode/2 uses the predicate function to determine whether each byte # (in an integer representation) should be encoded or not. - defp encode_path(path) when is_binary(path) do + defp encode_path(path, bypass_decode) when is_binary(path) do + path = + cond do + bypass_decode -> + path + + true -> + URI.decode(path) + end + path - |> URI.decode() |> URI.encode(fn byte -> URI.char_unreserved?(byte) || Enum.any?( Pleroma.Constants.uri_path_allowed_reserved_chars, fn char -> char == byte end) - end) + end) end defp encode_query(nil), do: nil diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index b0aef2592..8f75a1d57 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -34,6 +34,7 @@ defmodule Pleroma.Upload do """ alias Ecto.UUID + alias Pleroma.HTTP alias Pleroma.Maps alias Pleroma.Web.ActivityPub.Utils require Logger @@ -230,11 +231,17 @@ defmodule Pleroma.Upload do tmp_path end + # Encoding the whole path here is fine since the path is in a + # UUID/ form. + # The file at this point isn't %-encoded, so the path shouldn't + # be decoded first like Pleroma.HTTP.encode_url/1 does. defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do + encode_opts = [bypass_decode: true, bypass_parse: true] + path = - URI.encode(path, &char_unescaped?/1) <> + HTTP.encode_url(path, encode_opts) <> if Pleroma.Config.get([__MODULE__, :link_name], false) do - "?name=#{URI.encode(name, &char_unescaped?/1)}" + "?name=#{URI.encode_query(name)}" else "" end diff --git a/test/pleroma/upload_test.exs b/test/pleroma/upload_test.exs index 5fd62fa43..a99d01f2a 100644 --- a/test/pleroma/upload_test.exs +++ b/test/pleroma/upload_test.exs @@ -242,6 +242,21 @@ defmodule Pleroma.UploadTest do assert Path.basename(attachment_url["href"]) == "%3A%3F%23%5B%5D%40%21%24%26%5C%27%28%29%2A%2B%2C%3B%3D.jpg" end + + test "double %-encodes filename" do + File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") + + file = %Plug.Upload{ + content_type: "image/jpeg", + path: Path.absname("test/fixtures/image_tmp.jpg"), + filename: "file with %20.jpg" + } + + {:ok, data} = Upload.store(file) + [attachment_url | _] = data["url"] + + assert Path.basename(attachment_url["href"]) == "file%20with%20%2520.jpg" + end end describe "Setting a custom base_url for uploaded media" do From 80db6f1328212a0b00e26f548f02aa5d6a3c1715 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 29 Aug 2025 15:04:02 +0200 Subject: [PATCH 58/78] Fix character escaping test for Pleroma.Upload --- lib/pleroma/http.ex | 12 ++++++++---- test/pleroma/http_test.exs | 20 ++++++++++++++++++++ test/pleroma/reverse_proxy_test.exs | 22 +++++++++------------- test/pleroma/upload_test.exs | 6 +++--- 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index 6f4abb30a..eea8416c6 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -174,10 +174,14 @@ defmodule Pleroma.HTTP do path |> URI.encode(fn byte -> - URI.char_unreserved?(byte) || Enum.any?( - Pleroma.Constants.uri_path_allowed_reserved_chars, fn char -> - char == byte end) - end) + URI.char_unreserved?(byte) || + Enum.any?( + Pleroma.Constants.uri_path_allowed_reserved_chars(), + fn char -> + char == byte + end + ) + end) end defp encode_query(nil), do: nil diff --git a/test/pleroma/http_test.exs b/test/pleroma/http_test.exs index 513802dfb..2af0d21c7 100644 --- a/test/pleroma/http_test.exs +++ b/test/pleroma/http_test.exs @@ -107,4 +107,24 @@ defmodule Pleroma.HTTPTest do assert result.status == 200 end + + test "decodes URL first by default" do + clear_config(:test_url_encoding, true) + + normal_url = "https://example.com/media/file%20with%20space.jpg?name=a+space.jpg" + + result = HTTP.encode_url(normal_url) + + assert result == "https://example.com/media/file%20with%20space.jpg?name=a+space.jpg" + end + + test "doesn't decode URL first when specified" do + clear_config(:test_url_encoding, true) + + normal_url = "https://example.com/media/file%20with%20space.jpg" + + result = HTTP.encode_url(normal_url, bypass_decode: true) + + assert result == "https://example.com/media/file%2520with%2520space.jpg" + end end diff --git a/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs index 62ad2f38a..3595568cf 100644 --- a/test/pleroma/reverse_proxy_test.exs +++ b/test/pleroma/reverse_proxy_test.exs @@ -404,24 +404,20 @@ defmodule Pleroma.ReverseProxyTest do ClientMock |> expect(:request, fn :get, - "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz", - _headers, - _body, - _opts -> + "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz", + _headers, + _body, + _opts -> {:ok, 200, [{"content-type", "image/png"}], "It works!"} :get, - "https://example.com/media/foo/bar%20!$&'()*+,;=/:%20@a%20%5Bbaz%5D.mp4", - _headers, - _body, - _opts -> + "https://example.com/media/foo/bar%20!$&'()*+,;=/:%20@a%20%5Bbaz%5D.mp4", + _headers, + _body, + _opts -> {:ok, 200, [{"content-type", "video/mp4"}], "Allowed reserved chars."} - :get, - "https://example.com/media/unicode%20%F0%9F%99%82%20.gif", - _headers, - _body, - _opts -> + :get, "https://example.com/media/unicode%20%F0%9F%99%82%20.gif", _headers, _body, _opts -> {:ok, 200, [{"content-type", "image/gif"}], "Unicode emoji in path"} end) |> stub(:stream_body, fn _ -> :done end) diff --git a/test/pleroma/upload_test.exs b/test/pleroma/upload_test.exs index a99d01f2a..91c7f49ea 100644 --- a/test/pleroma/upload_test.exs +++ b/test/pleroma/upload_test.exs @@ -227,20 +227,20 @@ defmodule Pleroma.UploadTest do assert Path.basename(attachment_url["href"]) == "an%E2%80%A6%20image.jpg" end - test "escapes reserved uri characters" do + test "escapes disallowed reserved characters in uri path" do File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") file = %Plug.Upload{ content_type: "image/jpeg", path: Path.absname("test/fixtures/image_tmp.jpg"), - filename: ":?#[]@!$&\\'()*+,;=.jpg" + filename: ":?#[]@!$&'()*+,;=.jpg" } {:ok, data} = Upload.store(file) [attachment_url | _] = data["url"] assert Path.basename(attachment_url["href"]) == - "%3A%3F%23%5B%5D%40%21%24%26%5C%27%28%29%2A%2B%2C%3B%3D.jpg" + ":%3F%23%5B%5D@!$&'()*+,;=.jpg" end test "double %-encodes filename" do From 99a1c0890a5aee2a8051791b39b599d8e930dcbc Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 29 Aug 2025 20:53:16 +0200 Subject: [PATCH 59/78] URI.encode_query needs an enum, add test for this case --- lib/pleroma/upload.ex | 3 ++- test/pleroma/upload_test.exs | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 8f75a1d57..de8949848 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -241,7 +241,8 @@ defmodule Pleroma.Upload do path = HTTP.encode_url(path, encode_opts) <> if Pleroma.Config.get([__MODULE__, :link_name], false) do - "?name=#{URI.encode_query(name)}" + enum = %{name: name} + "?#{URI.encode_query(enum)}" else "" end diff --git a/test/pleroma/upload_test.exs b/test/pleroma/upload_test.exs index 91c7f49ea..9a51c612b 100644 --- a/test/pleroma/upload_test.exs +++ b/test/pleroma/upload_test.exs @@ -282,4 +282,23 @@ defmodule Pleroma.UploadTest do refute String.starts_with?(url, base_url <> "/media/") end end + + describe "Setting a link_name for uploaded media" do + setup do: clear_config([Pleroma.Upload, :link_name], true) + + test "encodes name parameter in query" do + File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") + + file = %Plug.Upload{ + content_type: "image/jpeg", + path: Path.absname("test/fixtures/image_tmp.jpg"), + filename: "test file.jpg" + } + + {:ok, data} = Upload.store(file) + [attachment_url | _] = data["url"] + + assert Path.basename(attachment_url["href"]) == "test%20file.jpg?name=test+file.jpg" + end + end end From 9445ab9096b7a69200e3c1f8bdb3befdc019a6e9 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 8 Sep 2025 22:05:49 +0200 Subject: [PATCH 60/78] ReverseProxy: Log request after potentional %-encoding --- lib/pleroma/reverse_proxy.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index cd58f29e4..6c8163f2a 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -155,11 +155,12 @@ defmodule Pleroma.ReverseProxy do end defp request(method, url, headers, opts) do - Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}") method = method |> String.downcase() |> String.to_existing_atom() url = maybe_encode_url(url) + Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}") + case client().request(method, url, headers, "", opts) do {:ok, code, headers, client} when code in @valid_resp_codes -> {:ok, code, downcase_headers(headers), client} From 004ea90b29fec5e7b7b53b40f6acb6455a2340db Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 23 Sep 2025 22:55:54 +0200 Subject: [PATCH 61/78] MediaProxy: Fix 424 caused by inconsistent %-encoding from remote instances Notably this would fail to redirect to original proxied file when preview generation criteria haven't been met. --- lib/pleroma/web/media_proxy.ex | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex index 29882542c..a29debbfc 100644 --- a/lib/pleroma/web/media_proxy.ex +++ b/lib/pleroma/web/media_proxy.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.MediaProxy do alias Pleroma.Config alias Pleroma.Helpers.UriHelper + alias Pleroma.HTTP alias Pleroma.Upload alias Pleroma.Web.Endpoint alias Pleroma.Web.MediaProxy.Invalidation @@ -99,13 +100,20 @@ defmodule Pleroma.Web.MediaProxy do {base64, sig64} end + # The URL coming into MediaProxy from the outside might have wrong %-encoding + # (like older Pleroma versions) + # This would cause an inconsistency with the encoded URL here and the requested + # URL fixed with Pleroma.Tesla.Middleware.EncodeUrl. + # End result is a failing HEAD request in Pleroma.Web.MediaProxy.MediaProxyController.handle_preview/2 def encode_url(url) do + url = HTTP.encode_url(url) {base64, sig64} = base64_sig64(url) build_url(sig64, base64, filename(url)) end def encode_preview_url(url, preview_params \\ []) do + url = HTTP.encode_url(url) {base64, sig64} = base64_sig64(url) build_preview_url(sig64, base64, filename(url), preview_params) From d413f9bf7078cf93c3efcd0ae195539dd3c7ede0 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 27 Sep 2025 12:19:47 +0200 Subject: [PATCH 62/78] MediaProxy: fix Pleroma.HTTP.encode_url not being available in test env --- .../mrf/media_proxy_warming_policy_test.exs | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs index 0da3afa3b..13b4d24b8 100644 --- a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs @@ -58,10 +58,13 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do {:ok, %Tesla.Env{status: 200, body: ""}} end) - with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do - MediaProxyWarmingPolicy.filter(@message) + with_mock HTTP, + get: fn _, _, _ -> {:ok, []} end, + encode_url: fn url -> :meck.passthrough([url]) end, + encode_url: fn url, opts -> :meck.passthrough([url, opts]) end do + MediaProxyWarmingPolicy.filter(@message) - assert called(HTTP.get(:_, :_, :_)) + assert called(HTTP.get(:_, :_, :_)) end end @@ -85,10 +88,13 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do {:ok, %Tesla.Env{status: 200, body: ""}} end) - with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do - MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history) + with_mock HTTP, + get: fn _, _, _ -> {:ok, []} end, + encode_url: fn url -> :meck.passthrough([url]) end, + encode_url: fn url, opts -> :meck.passthrough([url, opts]) end do + MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history) - assert called(HTTP.get(:_, :_, :_)) + assert called(HTTP.get(:_, :_, :_)) end end @@ -97,8 +103,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do {:ok, %Tesla.Env{status: 200, body: ""}} end) - with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do - MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history |> Map.put("type", "Update")) + with_mock HTTP, + get: fn _, _, _ -> {:ok, []} end, + encode_url: fn url -> :meck.passthrough([url]) end, + encode_url: fn url, opts -> :meck.passthrough([url, opts]) end do + MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history |> Map.put("type", "Update")) assert called(HTTP.get(:_, :_, :_)) end From 1b438fd167368623ad9a0fbec92552e520f77bf5 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 27 Sep 2025 14:30:22 +0200 Subject: [PATCH 63/78] MediaProxy: fix query params test Elixir and Erlang both add a traling = when encoding queries --- test/pleroma/web/media_proxy_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pleroma/web/media_proxy_test.exs b/test/pleroma/web/media_proxy_test.exs index 718892665..7a845b7a7 100644 --- a/test/pleroma/web/media_proxy_test.exs +++ b/test/pleroma/web/media_proxy_test.exs @@ -73,7 +73,7 @@ defmodule Pleroma.Web.MediaProxyTest do end test "encodes and decodes URL and ignores query params for the path" do - url = "https://pleroma.soykaf.com/static/logo.png?93939393939&bunny=true" + url = "https://pleroma.soykaf.com/static/logo.png?93939393939=&bunny=true" encoded = MediaProxy.url(url) assert String.ends_with?(encoded, "/logo.png") assert decode_result(encoded) == url From a0f73d0e2f1afca990f6f1f2b39c986acdee8408 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 8 Oct 2025 23:34:43 +0200 Subject: [PATCH 64/78] Reimplement URI.encode_query/2 to support quirks, add Guardian quirk This solves the issue with Guardian rich media cards not loading, thanks to them using "," and ":" in queries which get improperly encoded. Guardian also needs specific ordering of the query keys, this also fixes that. --- lib/pleroma/http.ex | 49 ++++++++++++++++++++++++++-- test/pleroma/http_test.exs | 67 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index eea8416c6..561b015be 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -147,6 +147,7 @@ defmodule Pleroma.HTTP do URI.parse(url) |> then(fn parsed -> path = encode_path(parsed.path, bypass_decode) + |> maybe_apply_path_encoding_quirks() query = encode_query(parsed.query) %{parsed | path: path, query: query} @@ -186,9 +187,53 @@ defmodule Pleroma.HTTP do defp encode_query(nil), do: nil + # Order of kv pairs in query is not preserved when using URI.decode_query. + # URI.query_decoder/2 returns a stream which so far appears to not change order. + # Immediately switch to a list to prevent breakage for sites that expect + # the order of query keys to be always the same. defp encode_query(query) when is_binary(query) do query - |> URI.decode_query() - |> URI.encode_query() + |> URI.query_decoder() + |> Enum.to_list() + |> do_encode_query() + end + + defp maybe_apply_path_encoding_quirks(path), do: path + + # Always uses www_form encoding + defp do_encode_query(enumerable) do + Enum.map_join(enumerable, "&", &maybe_apply_query_quirk(&1)) + end + + defp maybe_apply_query_quirk({key, value}) do + case key do + "precrop" -> + query_encode_kv_pair({key, value}, ~c":,") + + key -> + query_encode_kv_pair({key, value}) + end + end + + defp query_encode_kv_pair({key, value}, rules \\ []) when is_list(rules) do + cond do + length(rules) > 0 -> + # URI.encode_query/2 does not appear to follow spec and encodes all parts of our URI path Constant. + # This appears to work outside of edge-cases like The Guardian Rich Media Cards, + # keeping behavior same as with URI.encode_query/2 unless otherwise specified via rules. + URI.encode_www_form(Kernel.to_string(key)) <> "=" <> + URI.encode(value, fn byte -> + URI.char_unreserved?(byte) || + Enum.any?( + rules, + fn char -> + char == byte + end) + end) + |> String.replace("%20", "+") + + true -> + URI.encode_www_form(Kernel.to_string(key)) <> "=" <> URI.encode_www_form(Kernel.to_string(value)) + end end end diff --git a/test/pleroma/http_test.exs b/test/pleroma/http_test.exs index 2af0d21c7..fe6afea04 100644 --- a/test/pleroma/http_test.exs +++ b/test/pleroma/http_test.exs @@ -37,6 +37,15 @@ defmodule Pleroma.HTTPTest do %{method: :get, url: "https://example.com/media/unicode%20%F0%9F%99%82%20.gif"} -> %Tesla.Env{status: 200, body: "unicode data"} + + %{method: :get, url: "https://i.guim.co.uk/img/media/1069ef13c447908272c4de94174cec2b6352cb2f/0_91_2000_1201/master/2000.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&precrop=40:21,offset-x50,offset-y0&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctb3BpbmlvbnMtYWdlLTIwMTkucG5n&enable=upscale&s=cba21427a73512fdc9863c486c03fdd8"} -> + %Tesla.Env{status: 200, body: "Guardian image quirk"} + + %{method: :get, url: "https://example.com/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz"} -> + %Tesla.Env{status: 200, body: "Space in query with Guardian quirk"} + + %{method: :get, url: "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY%2F20130721%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host"} -> + %Tesla.Env{status: 200, body: "AWS S3 data"} end) :ok @@ -127,4 +136,62 @@ defmodule Pleroma.HTTPTest do assert result == "https://example.com/media/file%2520with%2520space.jpg" end + + test "properly applies Guardian image query quirk" do + clear_config(:test_url_encoding, true) + + url = "https://i.guim.co.uk/img/media/1069ef13c447908272c4de94174cec2b6352cb2f/0_91_2000_1201/master/2000.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&precrop=40:21,offset-x50,offset-y0&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctb3BpbmlvbnMtYWdlLTIwMTkucG5n&enable=upscale&s=cba21427a73512fdc9863c486c03fdd8" + + result = HTTP.encode_url(url) + + assert result == url + + {:ok, result_get} = HTTP.get(result) + + assert result_get.status == 200 + end + + test "properly encodes spaces as \"pluses\" in query when using quirks" do + clear_config(:test_url_encoding, true) + + url = "https://example.com/emoji/Pack 1/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar baz" + + properly_encoded_url = "https://example.com/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz" + + result = HTTP.encode_url(url) + + assert result == properly_encoded_url + + {:ok, result_get} = HTTP.get(result) + + assert result_get.status == 200 + end + + test "properly encode AWS S3 queries" do + clear_config(:test_url_encoding, true) + + url = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY%2F20130721%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host" + unencoded_url = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY/20130721/us-east-1/s3/aws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host" + + result = HTTP.encode_url(url) + result_unencoded = HTTP.encode_url(unencoded_url) + + assert result == url + assert result == result_unencoded + + {:ok, result_get} = HTTP.get(result) + + assert result_get.status == 200 + + end + + test "preserves query key order" do + clear_config(:test_url_encoding, true) + + url = "https://example.com/foo?hjkl=qwertz&xyz=abc&bar=baz" + + result = HTTP.encode_url(url) + + assert result == url + end end From cfd2c08ef6f090a669dbb646b12d554f816b4c35 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 10 Oct 2025 22:37:16 +0200 Subject: [PATCH 65/78] lint --- lib/pleroma/http.ex | 33 +++++++++-------- test/pleroma/http_test.exs | 35 ++++++++++++++----- .../mrf/media_proxy_warming_policy_test.exs | 10 +++--- 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index 561b015be..5ac7f96f9 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -146,8 +146,10 @@ defmodule Pleroma.HTTP do true -> URI.parse(url) |> then(fn parsed -> - path = encode_path(parsed.path, bypass_decode) - |> maybe_apply_path_encoding_quirks() + path = + encode_path(parsed.path, bypass_decode) + |> maybe_apply_path_encoding_quirks() + query = encode_query(parsed.query) %{parsed | path: path, query: query} @@ -197,7 +199,7 @@ defmodule Pleroma.HTTP do |> Enum.to_list() |> do_encode_query() end - + defp maybe_apply_path_encoding_quirks(path), do: path # Always uses www_form encoding @@ -221,19 +223,22 @@ defmodule Pleroma.HTTP do # URI.encode_query/2 does not appear to follow spec and encodes all parts of our URI path Constant. # This appears to work outside of edge-cases like The Guardian Rich Media Cards, # keeping behavior same as with URI.encode_query/2 unless otherwise specified via rules. - URI.encode_www_form(Kernel.to_string(key)) <> "=" <> - URI.encode(value, fn byte -> - URI.char_unreserved?(byte) || - Enum.any?( - rules, - fn char -> - char == byte - end) - end) - |> String.replace("%20", "+") + (URI.encode_www_form(Kernel.to_string(key)) <> + "=" <> + URI.encode(value, fn byte -> + URI.char_unreserved?(byte) || + Enum.any?( + rules, + fn char -> + char == byte + end + ) + end)) + |> String.replace("%20", "+") true -> - URI.encode_www_form(Kernel.to_string(key)) <> "=" <> URI.encode_www_form(Kernel.to_string(value)) + URI.encode_www_form(Kernel.to_string(key)) <> + "=" <> URI.encode_www_form(Kernel.to_string(value)) end end end diff --git a/test/pleroma/http_test.exs b/test/pleroma/http_test.exs index fe6afea04..9bdb6ec35 100644 --- a/test/pleroma/http_test.exs +++ b/test/pleroma/http_test.exs @@ -38,13 +38,25 @@ defmodule Pleroma.HTTPTest do %{method: :get, url: "https://example.com/media/unicode%20%F0%9F%99%82%20.gif"} -> %Tesla.Env{status: 200, body: "unicode data"} - %{method: :get, url: "https://i.guim.co.uk/img/media/1069ef13c447908272c4de94174cec2b6352cb2f/0_91_2000_1201/master/2000.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&precrop=40:21,offset-x50,offset-y0&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctb3BpbmlvbnMtYWdlLTIwMTkucG5n&enable=upscale&s=cba21427a73512fdc9863c486c03fdd8"} -> + %{ + method: :get, + url: + "https://i.guim.co.uk/img/media/1069ef13c447908272c4de94174cec2b6352cb2f/0_91_2000_1201/master/2000.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&precrop=40:21,offset-x50,offset-y0&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctb3BpbmlvbnMtYWdlLTIwMTkucG5n&enable=upscale&s=cba21427a73512fdc9863c486c03fdd8" + } -> %Tesla.Env{status: 200, body: "Guardian image quirk"} - %{method: :get, url: "https://example.com/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz"} -> + %{ + method: :get, + url: + "https://example.com/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz" + } -> %Tesla.Env{status: 200, body: "Space in query with Guardian quirk"} - %{method: :get, url: "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY%2F20130721%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host"} -> + %{ + method: :get, + url: + "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY%2F20130721%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host" + } -> %Tesla.Env{status: 200, body: "AWS S3 data"} end) @@ -140,7 +152,8 @@ defmodule Pleroma.HTTPTest do test "properly applies Guardian image query quirk" do clear_config(:test_url_encoding, true) - url = "https://i.guim.co.uk/img/media/1069ef13c447908272c4de94174cec2b6352cb2f/0_91_2000_1201/master/2000.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&precrop=40:21,offset-x50,offset-y0&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctb3BpbmlvbnMtYWdlLTIwMTkucG5n&enable=upscale&s=cba21427a73512fdc9863c486c03fdd8" + url = + "https://i.guim.co.uk/img/media/1069ef13c447908272c4de94174cec2b6352cb2f/0_91_2000_1201/master/2000.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&precrop=40:21,offset-x50,offset-y0&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctb3BpbmlvbnMtYWdlLTIwMTkucG5n&enable=upscale&s=cba21427a73512fdc9863c486c03fdd8" result = HTTP.encode_url(url) @@ -154,9 +167,11 @@ defmodule Pleroma.HTTPTest do test "properly encodes spaces as \"pluses\" in query when using quirks" do clear_config(:test_url_encoding, true) - url = "https://example.com/emoji/Pack 1/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar baz" + url = + "https://example.com/emoji/Pack 1/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar baz" - properly_encoded_url = "https://example.com/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz" + properly_encoded_url = + "https://example.com/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz" result = HTTP.encode_url(url) @@ -170,8 +185,11 @@ defmodule Pleroma.HTTPTest do test "properly encode AWS S3 queries" do clear_config(:test_url_encoding, true) - url = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY%2F20130721%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host" - unencoded_url = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY/20130721/us-east-1/s3/aws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host" + url = + "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY%2F20130721%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host" + + unencoded_url = + "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY/20130721/us-east-1/s3/aws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host" result = HTTP.encode_url(url) result_unencoded = HTTP.encode_url(unencoded_url) @@ -182,7 +200,6 @@ defmodule Pleroma.HTTPTest do {:ok, result_get} = HTTP.get(result) assert result_get.status == 200 - end test "preserves query key order" do diff --git a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs index 13b4d24b8..b589068c7 100644 --- a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs @@ -62,9 +62,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do get: fn _, _, _ -> {:ok, []} end, encode_url: fn url -> :meck.passthrough([url]) end, encode_url: fn url, opts -> :meck.passthrough([url, opts]) end do - MediaProxyWarmingPolicy.filter(@message) + MediaProxyWarmingPolicy.filter(@message) - assert called(HTTP.get(:_, :_, :_)) + assert called(HTTP.get(:_, :_, :_)) end end @@ -92,9 +92,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do get: fn _, _, _ -> {:ok, []} end, encode_url: fn url -> :meck.passthrough([url]) end, encode_url: fn url, opts -> :meck.passthrough([url, opts]) end do - MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history) + MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history) - assert called(HTTP.get(:_, :_, :_)) + assert called(HTTP.get(:_, :_, :_)) end end @@ -107,7 +107,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do get: fn _, _, _ -> {:ok, []} end, encode_url: fn url -> :meck.passthrough([url]) end, encode_url: fn url, opts -> :meck.passthrough([url, opts]) end do - MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history |> Map.put("type", "Update")) + MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history |> Map.put("type", "Update")) assert called(HTTP.get(:_, :_, :_)) end From f36851acbd20904220384ccc8128ccbe55b62282 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 10 Oct 2025 22:41:24 +0200 Subject: [PATCH 66/78] credo lint --- lib/pleroma/http.ex | 7 ++++--- lib/pleroma/web/media_proxy.ex | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index 5ac7f96f9..0d9f28420 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -220,9 +220,10 @@ defmodule Pleroma.HTTP do defp query_encode_kv_pair({key, value}, rules \\ []) when is_list(rules) do cond do length(rules) > 0 -> - # URI.encode_query/2 does not appear to follow spec and encodes all parts of our URI path Constant. - # This appears to work outside of edge-cases like The Guardian Rich Media Cards, - # keeping behavior same as with URI.encode_query/2 unless otherwise specified via rules. + # URI.encode_query/2 does not appear to follow spec and encodes all part + # of our URI path Constant. This appears to work outside of edge-cases + # like The Guardian Rich Media Cards, keeping behavior same as with + # URI.encode_query/2 unless otherwise specified via rules. (URI.encode_www_form(Kernel.to_string(key)) <> "=" <> URI.encode(value, fn byte -> diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex index a29debbfc..22f37eea8 100644 --- a/lib/pleroma/web/media_proxy.ex +++ b/lib/pleroma/web/media_proxy.ex @@ -101,10 +101,11 @@ defmodule Pleroma.Web.MediaProxy do end # The URL coming into MediaProxy from the outside might have wrong %-encoding - # (like older Pleroma versions) + # (like older Pleroma versions). # This would cause an inconsistency with the encoded URL here and the requested # URL fixed with Pleroma.Tesla.Middleware.EncodeUrl. - # End result is a failing HEAD request in Pleroma.Web.MediaProxy.MediaProxyController.handle_preview/2 + # End result is a failing HEAD request in + # Pleroma.Web.MediaProxy.MediaProxyController.handle_preview/2 def encode_url(url) do url = HTTP.encode_url(url) {base64, sig64} = base64_sig64(url) From 6487c93c476aef40c78ad678e0e8352e747cd9a5 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 10 Oct 2025 23:11:10 +0200 Subject: [PATCH 67/78] credo lint 2 --- lib/pleroma/http.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index 0d9f28420..7d321de62 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -222,7 +222,7 @@ defmodule Pleroma.HTTP do length(rules) > 0 -> # URI.encode_query/2 does not appear to follow spec and encodes all part # of our URI path Constant. This appears to work outside of edge-cases - # like The Guardian Rich Media Cards, keeping behavior same as with + # like The Guardian Rich Media Cards, keeping behavior same as with # URI.encode_query/2 unless otherwise specified via rules. (URI.encode_www_form(Kernel.to_string(key)) <> "=" <> From f290b159875b68a4ee03ac9f9ced80242ee7085a Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 20 Oct 2025 22:10:28 +0200 Subject: [PATCH 68/78] Move custom URI encoding functions to Pleroma.Utils.URIEncoding --- lib/pleroma/http.ex | 111 ---------------- lib/pleroma/reverse_proxy.ex | 8 +- lib/pleroma/tesla/middleware/encode_url.ex | 2 +- lib/pleroma/upload.ex | 6 +- lib/pleroma/utils/uri_encoding.ex | 121 ++++++++++++++++++ lib/pleroma/web/media_proxy.ex | 6 +- test/pleroma/http_test.exs | 17 ++- .../mrf/media_proxy_warming_policy_test.exs | 15 +-- 8 files changed, 146 insertions(+), 140 deletions(-) create mode 100644 lib/pleroma/utils/uri_encoding.ex diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index 7d321de62..bdeb2171e 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -14,7 +14,6 @@ defmodule Pleroma.HTTP do alias Tesla.Env require Logger - require Pleroma.Constants @type t :: __MODULE__ @type method() :: :get | :post | :put | :delete | :head @@ -132,114 +131,4 @@ defmodule Pleroma.HTTP do defp default_middleware, do: [Tesla.Middleware.FollowRedirects, Pleroma.Tesla.Middleware.EncodeUrl] - - # We don't always want to decode the path first, like is the case in - # Pleroma.Upload.url_from_spec/3. - def encode_url(url, opts \\ []) when is_binary(url) and is_list(opts) do - bypass_parse = Keyword.get(opts, :bypass_parse, false) - bypass_decode = Keyword.get(opts, :bypass_decode, false) - - cond do - bypass_parse -> - encode_path(url, bypass_decode) - - true -> - URI.parse(url) - |> then(fn parsed -> - path = - encode_path(parsed.path, bypass_decode) - |> maybe_apply_path_encoding_quirks() - - query = encode_query(parsed.query) - - %{parsed | path: path, query: query} - end) - |> URI.to_string() - end - end - - defp encode_path(nil, _bypass_decode), do: nil - - # URI.encode/2 deliberately does not encode all chars that are forbidden - # in the path component of a URI. It only encodes chars that are forbidden - # in the whole URI. A predicate in the 2nd argument is used to fix that here. - # URI.encode/2 uses the predicate function to determine whether each byte - # (in an integer representation) should be encoded or not. - defp encode_path(path, bypass_decode) when is_binary(path) do - path = - cond do - bypass_decode -> - path - - true -> - URI.decode(path) - end - - path - |> URI.encode(fn byte -> - URI.char_unreserved?(byte) || - Enum.any?( - Pleroma.Constants.uri_path_allowed_reserved_chars(), - fn char -> - char == byte - end - ) - end) - end - - defp encode_query(nil), do: nil - - # Order of kv pairs in query is not preserved when using URI.decode_query. - # URI.query_decoder/2 returns a stream which so far appears to not change order. - # Immediately switch to a list to prevent breakage for sites that expect - # the order of query keys to be always the same. - defp encode_query(query) when is_binary(query) do - query - |> URI.query_decoder() - |> Enum.to_list() - |> do_encode_query() - end - - defp maybe_apply_path_encoding_quirks(path), do: path - - # Always uses www_form encoding - defp do_encode_query(enumerable) do - Enum.map_join(enumerable, "&", &maybe_apply_query_quirk(&1)) - end - - defp maybe_apply_query_quirk({key, value}) do - case key do - "precrop" -> - query_encode_kv_pair({key, value}, ~c":,") - - key -> - query_encode_kv_pair({key, value}) - end - end - - defp query_encode_kv_pair({key, value}, rules \\ []) when is_list(rules) do - cond do - length(rules) > 0 -> - # URI.encode_query/2 does not appear to follow spec and encodes all part - # of our URI path Constant. This appears to work outside of edge-cases - # like The Guardian Rich Media Cards, keeping behavior same as with - # URI.encode_query/2 unless otherwise specified via rules. - (URI.encode_www_form(Kernel.to_string(key)) <> - "=" <> - URI.encode(value, fn byte -> - URI.char_unreserved?(byte) || - Enum.any?( - rules, - fn char -> - char == byte - end - ) - end)) - |> String.replace("%20", "+") - - true -> - URI.encode_www_form(Kernel.to_string(key)) <> - "=" <> URI.encode_www_form(Kernel.to_string(value)) - end - end end diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index 6c8163f2a..bb55a4984 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -3,6 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxy do + alias Pleroma.Utils.URIEncoding + @range_headers ~w(range if-range) @keep_req_headers ~w(accept accept-encoding cache-control if-modified-since) ++ ~w(if-unmodified-since if-none-match) ++ @range_headers @@ -460,9 +462,9 @@ defmodule Pleroma.ReverseProxy do # Also do it for test environment defp maybe_encode_url(url) do case Application.get_env(:tesla, :adapter) do - Tesla.Adapter.Hackney -> Pleroma.HTTP.encode_url(url) - {Tesla.Adapter.Finch, _} -> Pleroma.HTTP.encode_url(url) - Tesla.Mock -> Pleroma.HTTP.encode_url(url) + Tesla.Adapter.Hackney -> URIEncoding.encode_url(url) + {Tesla.Adapter.Finch, _} -> URIEncoding.encode_url(url) + Tesla.Mock -> URIEncoding.encode_url(url) _ -> url end end diff --git a/lib/pleroma/tesla/middleware/encode_url.ex b/lib/pleroma/tesla/middleware/encode_url.ex index 32c559d3b..677577c2f 100644 --- a/lib/pleroma/tesla/middleware/encode_url.ex +++ b/lib/pleroma/tesla/middleware/encode_url.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Tesla.Middleware.EncodeUrl do @impl Tesla.Middleware def call(%Tesla.Env{url: url} = env, next, _) do - url = Pleroma.HTTP.encode_url(url) + url = Pleroma.Utils.URIEncoding.encode_url(url) env = %{env | url: url} diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index de8949848..06d8005bc 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -34,8 +34,8 @@ defmodule Pleroma.Upload do """ alias Ecto.UUID - alias Pleroma.HTTP alias Pleroma.Maps + alias Pleroma.Utils.URIEncoding alias Pleroma.Web.ActivityPub.Utils require Logger @@ -234,12 +234,12 @@ defmodule Pleroma.Upload do # Encoding the whole path here is fine since the path is in a # UUID/ form. # The file at this point isn't %-encoded, so the path shouldn't - # be decoded first like Pleroma.HTTP.encode_url/1 does. + # be decoded first like Pleroma.Utils.URIEncoding.encode_url/1 does. defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do encode_opts = [bypass_decode: true, bypass_parse: true] path = - HTTP.encode_url(path, encode_opts) <> + URIEncoding.encode_url(path, encode_opts) <> if Pleroma.Config.get([__MODULE__, :link_name], false) do enum = %{name: name} "?#{URI.encode_query(enum)}" diff --git a/lib/pleroma/utils/uri_encoding.ex b/lib/pleroma/utils/uri_encoding.ex new file mode 100644 index 000000000..0a61671c3 --- /dev/null +++ b/lib/pleroma/utils/uri_encoding.ex @@ -0,0 +1,121 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2025 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Utils.URIEncoding do + @moduledoc """ + Utility functions for dealing with URI encoding of paths and queries + with support for query-encoding quirks. + """ + require Pleroma.Constants + + # We don't always want to decode the path first, like is the case in + # Pleroma.Upload.url_from_spec/3. + def encode_url(url, opts \\ []) when is_binary(url) and is_list(opts) do + bypass_parse = Keyword.get(opts, :bypass_parse, false) + bypass_decode = Keyword.get(opts, :bypass_decode, false) + + cond do + bypass_parse -> + encode_path(url, bypass_decode) + + true -> + URI.parse(url) + |> then(fn parsed -> + path = + encode_path(parsed.path, bypass_decode) + |> maybe_apply_path_encoding_quirks() + + query = encode_query(parsed.query) + + %{parsed | path: path, query: query} + end) + |> URI.to_string() + end + end + + defp encode_path(nil, _bypass_decode), do: nil + + # URI.encode/2 deliberately does not encode all chars that are forbidden + # in the path component of a URI. It only encodes chars that are forbidden + # in the whole URI. A predicate in the 2nd argument is used to fix that here. + # URI.encode/2 uses the predicate function to determine whether each byte + # (in an integer representation) should be encoded or not. + defp encode_path(path, bypass_decode) when is_binary(path) do + path = + cond do + bypass_decode -> + path + + true -> + URI.decode(path) + end + + path + |> URI.encode(fn byte -> + URI.char_unreserved?(byte) || + Enum.any?( + Pleroma.Constants.uri_path_allowed_reserved_chars(), + fn char -> + char == byte + end + ) + end) + end + + defp encode_query(nil), do: nil + + # Order of kv pairs in query is not preserved when using URI.decode_query. + # URI.query_decoder/2 returns a stream which so far appears to not change order. + # Immediately switch to a list to prevent breakage for sites that expect + # the order of query keys to be always the same. + defp encode_query(query) when is_binary(query) do + query + |> URI.query_decoder() + |> Enum.to_list() + |> do_encode_query() + end + + defp maybe_apply_path_encoding_quirks(path), do: path + + # Always uses www_form encoding + defp do_encode_query(enumerable) do + Enum.map_join(enumerable, "&", &maybe_apply_query_quirk(&1)) + end + + defp maybe_apply_query_quirk({key, value}) do + case key do + "precrop" -> + query_encode_kv_pair({key, value}, ~c":,") + + key -> + query_encode_kv_pair({key, value}) + end + end + + defp query_encode_kv_pair({key, value}, rules \\ []) when is_list(rules) do + cond do + length(rules) > 0 -> + # URI.encode_query/2 does not appear to follow spec and encodes all parts + # of our URI path Constant. This appears to work outside of edge-cases + # like The Guardian Rich Media Cards, keeping behavior same as with + # URI.encode_query/2 unless otherwise specified via rules. + (URI.encode_www_form(Kernel.to_string(key)) <> + "=" <> + URI.encode(value, fn byte -> + URI.char_unreserved?(byte) || + Enum.any?( + rules, + fn char -> + char == byte + end + ) + end)) + |> String.replace("%20", "+") + + true -> + URI.encode_www_form(Kernel.to_string(key)) <> + "=" <> URI.encode_www_form(Kernel.to_string(value)) + end + end +end diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex index 22f37eea8..f9376b508 100644 --- a/lib/pleroma/web/media_proxy.ex +++ b/lib/pleroma/web/media_proxy.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.MediaProxy do alias Pleroma.Config alias Pleroma.Helpers.UriHelper - alias Pleroma.HTTP alias Pleroma.Upload + alias Pleroma.Utils.URIEncoding alias Pleroma.Web.Endpoint alias Pleroma.Web.MediaProxy.Invalidation @@ -107,14 +107,14 @@ defmodule Pleroma.Web.MediaProxy do # End result is a failing HEAD request in # Pleroma.Web.MediaProxy.MediaProxyController.handle_preview/2 def encode_url(url) do - url = HTTP.encode_url(url) + url = URIEncoding.encode_url(url) {base64, sig64} = base64_sig64(url) build_url(sig64, base64, filename(url)) end def encode_preview_url(url, preview_params \\ []) do - url = HTTP.encode_url(url) + url = URIEncoding.encode_url(url) {base64, sig64} = base64_sig64(url) build_preview_url(sig64, base64, filename(url), preview_params) diff --git a/test/pleroma/http_test.exs b/test/pleroma/http_test.exs index 9bdb6ec35..12b040833 100644 --- a/test/pleroma/http_test.exs +++ b/test/pleroma/http_test.exs @@ -5,8 +5,11 @@ defmodule Pleroma.HTTPTest do use ExUnit.Case, async: true use Pleroma.Tests.Helpers + import Tesla.Mock + alias Pleroma.HTTP + alias Pleroma.Utils.URIEncoding setup do mock(fn @@ -134,7 +137,7 @@ defmodule Pleroma.HTTPTest do normal_url = "https://example.com/media/file%20with%20space.jpg?name=a+space.jpg" - result = HTTP.encode_url(normal_url) + result = URIEncoding.encode_url(normal_url) assert result == "https://example.com/media/file%20with%20space.jpg?name=a+space.jpg" end @@ -144,7 +147,7 @@ defmodule Pleroma.HTTPTest do normal_url = "https://example.com/media/file%20with%20space.jpg" - result = HTTP.encode_url(normal_url, bypass_decode: true) + result = URIEncoding.encode_url(normal_url, bypass_decode: true) assert result == "https://example.com/media/file%2520with%2520space.jpg" end @@ -155,7 +158,7 @@ defmodule Pleroma.HTTPTest do url = "https://i.guim.co.uk/img/media/1069ef13c447908272c4de94174cec2b6352cb2f/0_91_2000_1201/master/2000.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&precrop=40:21,offset-x50,offset-y0&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctb3BpbmlvbnMtYWdlLTIwMTkucG5n&enable=upscale&s=cba21427a73512fdc9863c486c03fdd8" - result = HTTP.encode_url(url) + result = URIEncoding.encode_url(url) assert result == url @@ -173,7 +176,7 @@ defmodule Pleroma.HTTPTest do properly_encoded_url = "https://example.com/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz" - result = HTTP.encode_url(url) + result = URIEncoding.encode_url(url) assert result == properly_encoded_url @@ -191,8 +194,8 @@ defmodule Pleroma.HTTPTest do unencoded_url = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY/20130721/us-east-1/s3/aws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host" - result = HTTP.encode_url(url) - result_unencoded = HTTP.encode_url(unencoded_url) + result = URIEncoding.encode_url(url) + result_unencoded = URIEncoding.encode_url(unencoded_url) assert result == url assert result == result_unencoded @@ -207,7 +210,7 @@ defmodule Pleroma.HTTPTest do url = "https://example.com/foo?hjkl=qwertz&xyz=abc&bar=baz" - result = HTTP.encode_url(url) + result = URIEncoding.encode_url(url) assert result == url end diff --git a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs index b589068c7..0da3afa3b 100644 --- a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs @@ -58,10 +58,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do {:ok, %Tesla.Env{status: 200, body: ""}} end) - with_mock HTTP, - get: fn _, _, _ -> {:ok, []} end, - encode_url: fn url -> :meck.passthrough([url]) end, - encode_url: fn url, opts -> :meck.passthrough([url, opts]) end do + with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do MediaProxyWarmingPolicy.filter(@message) assert called(HTTP.get(:_, :_, :_)) @@ -88,10 +85,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do {:ok, %Tesla.Env{status: 200, body: ""}} end) - with_mock HTTP, - get: fn _, _, _ -> {:ok, []} end, - encode_url: fn url -> :meck.passthrough([url]) end, - encode_url: fn url, opts -> :meck.passthrough([url, opts]) end do + with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history) assert called(HTTP.get(:_, :_, :_)) @@ -103,10 +97,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do {:ok, %Tesla.Env{status: 200, body: ""}} end) - with_mock HTTP, - get: fn _, _, _ -> {:ok, []} end, - encode_url: fn url -> :meck.passthrough([url]) end, - encode_url: fn url, opts -> :meck.passthrough([url, opts]) end do + with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history |> Map.put("type", "Update")) assert called(HTTP.get(:_, :_, :_)) From c31454fac1cf15916a406f9e7cc5c3deaf7514a5 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 20 Oct 2025 22:44:42 +0200 Subject: [PATCH 69/78] Fix unicode URL encoding test --- test/pleroma/web/media_proxy_test.exs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/pleroma/web/media_proxy_test.exs b/test/pleroma/web/media_proxy_test.exs index 7a845b7a7..ee9c01a08 100644 --- a/test/pleroma/web/media_proxy_test.exs +++ b/test/pleroma/web/media_proxy_test.exs @@ -182,11 +182,13 @@ defmodule Pleroma.Web.MediaProxyTest do assert decode_result(encoded) == url end - test "preserve unicode characters" do + # Improperly encoded URLs should not happen even when input was wrong. + test "does not preserve unicode characters" do url = "https://ko.wikipedia.org/wiki/위키백과:대문" + encoded_url = "https://ko.wikipedia.org/wiki/%EC%9C%84%ED%82%A4%EB%B0%B1%EA%B3%BC:%EB%8C%80%EB%AC%B8" encoded = MediaProxy.url(url) - assert decode_result(encoded) == url + assert decode_result(encoded) == encoded_url end end From bfe8372ad2de4d827eac89458f93620be5b05542 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 20 Oct 2025 22:46:01 +0200 Subject: [PATCH 70/78] Remove "preserve ASCII encoding" test in MediaProxy issue 580: Should not happen again, tested in HTTPTest issue 1055: Fixed with quirk support in query encoding, tested in HTTPTest --- test/pleroma/web/media_proxy_test.exs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/test/pleroma/web/media_proxy_test.exs b/test/pleroma/web/media_proxy_test.exs index ee9c01a08..f227b9ac1 100644 --- a/test/pleroma/web/media_proxy_test.exs +++ b/test/pleroma/web/media_proxy_test.exs @@ -159,18 +159,6 @@ defmodule Pleroma.Web.MediaProxyTest do assert String.starts_with?(encoded, base_url) end - # Some sites expect ASCII encoded characters in the URL to be preserved even if - # unnecessary. - # Issues: https://git.pleroma.social/pleroma/pleroma/issues/580 - # https://git.pleroma.social/pleroma/pleroma/issues/1055 - test "preserve ASCII encoding" do - url = - "https://pleroma.com/%20/%21/%22/%23/%24/%25/%26/%27/%28/%29/%2A/%2B/%2C/%2D/%2E/%2F/%30/%31/%32/%33/%34/%35/%36/%37/%38/%39/%3A/%3B/%3C/%3D/%3E/%3F/%40/%41/%42/%43/%44/%45/%46/%47/%48/%49/%4A/%4B/%4C/%4D/%4E/%4F/%50/%51/%52/%53/%54/%55/%56/%57/%58/%59/%5A/%5B/%5C/%5D/%5E/%5F/%60/%61/%62/%63/%64/%65/%66/%67/%68/%69/%6A/%6B/%6C/%6D/%6E/%6F/%70/%71/%72/%73/%74/%75/%76/%77/%78/%79/%7A/%7B/%7C/%7D/%7E/%7F/%80/%81/%82/%83/%84/%85/%86/%87/%88/%89/%8A/%8B/%8C/%8D/%8E/%8F/%90/%91/%92/%93/%94/%95/%96/%97/%98/%99/%9A/%9B/%9C/%9D/%9E/%9F/%C2%A0/%A1/%A2/%A3/%A4/%A5/%A6/%A7/%A8/%A9/%AA/%AB/%AC/%C2%AD/%AE/%AF/%B0/%B1/%B2/%B3/%B4/%B5/%B6/%B7/%B8/%B9/%BA/%BB/%BC/%BD/%BE/%BF/%C0/%C1/%C2/%C3/%C4/%C5/%C6/%C7/%C8/%C9/%CA/%CB/%CC/%CD/%CE/%CF/%D0/%D1/%D2/%D3/%D4/%D5/%D6/%D7/%D8/%D9/%DA/%DB/%DC/%DD/%DE/%DF/%E0/%E1/%E2/%E3/%E4/%E5/%E6/%E7/%E8/%E9/%EA/%EB/%EC/%ED/%EE/%EF/%F0/%F1/%F2/%F3/%F4/%F5/%F6/%F7/%F8/%F9/%FA/%FB/%FC/%FD/%FE/%FF" - - encoded = MediaProxy.url(url) - assert decode_result(encoded) == url - end - # This includes unsafe/reserved characters which are not interpreted as part of the URL # and would otherwise have to be ASCII encoded. It is our role to ensure the proxied URL # is unmodified, so we are testing these characters anyway. From 0935823be98822d06a35c454bf58ab996e6f700b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 20 Oct 2025 23:30:38 +0200 Subject: [PATCH 71/78] Add test for mangling incorrect URL in MediaProxy link generation --- test/pleroma/web/media_proxy_test.exs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/pleroma/web/media_proxy_test.exs b/test/pleroma/web/media_proxy_test.exs index f227b9ac1..86d30c04c 100644 --- a/test/pleroma/web/media_proxy_test.exs +++ b/test/pleroma/web/media_proxy_test.exs @@ -173,11 +173,28 @@ defmodule Pleroma.Web.MediaProxyTest do # Improperly encoded URLs should not happen even when input was wrong. test "does not preserve unicode characters" do url = "https://ko.wikipedia.org/wiki/위키백과:대문" - encoded_url = "https://ko.wikipedia.org/wiki/%EC%9C%84%ED%82%A4%EB%B0%B1%EA%B3%BC:%EB%8C%80%EB%AC%B8" + + encoded_url = + "https://ko.wikipedia.org/wiki/%EC%9C%84%ED%82%A4%EB%B0%B1%EA%B3%BC:%EB%8C%80%EB%AC%B8" encoded = MediaProxy.url(url) assert decode_result(encoded) == encoded_url end + + # If we preserve wrongly encoded URLs in MediaProxy, it will get fixed + # when we GET these URLs and will result in 424 when MediaProxy previews are enabled. + test "does not preserve incorrect URLs when making MediaProxy link" do + incorrect_original_url = "https://example.com/media/cofe%20%28with%20milk%29.png" + corrected_original_url = "https://example.com/media/cofe%20(with%20milk).png" + + unpreserved_encoded_original_url = + "http://localhost:4001/proxy/Sv6tt6xjA72_i4d8gXbuMAOXQSs/aHR0cHM6Ly9leGFtcGxlLmNvbS9tZWRpYS9jb2ZlJTIwKHdpdGglMjBtaWxrKS5wbmc/cofe%20(with%20milk).png" + + encoded = MediaProxy.url(incorrect_original_url) + + assert encoded == unpreserved_encoded_original_url + assert decode_result(encoded) == corrected_original_url + end end describe "when disabled" do From bcdd78fba513146a297fa5ece73f059f95f29561 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 20 Oct 2025 23:34:19 +0200 Subject: [PATCH 72/78] Add changelog --- changelog.d/url-encoding-pt2.fix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/url-encoding-pt2.fix diff --git a/changelog.d/url-encoding-pt2.fix b/changelog.d/url-encoding-pt2.fix new file mode 100644 index 000000000..bc6857e02 --- /dev/null +++ b/changelog.d/url-encoding-pt2.fix @@ -0,0 +1 @@ +Fix sometimes incorrect URI percent encoding From 07ba3bb829f8f501b3e98dd48c72e85fe543bc1e Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 20 Oct 2025 23:36:38 +0200 Subject: [PATCH 73/78] Remove "support" for path encoding quirks Currently there isn't any known quirk that would be needed and this is just dead code that does nothing. --- lib/pleroma/utils/uri_encoding.ex | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/pleroma/utils/uri_encoding.ex b/lib/pleroma/utils/uri_encoding.ex index 0a61671c3..f8a7ba49c 100644 --- a/lib/pleroma/utils/uri_encoding.ex +++ b/lib/pleroma/utils/uri_encoding.ex @@ -22,9 +22,7 @@ defmodule Pleroma.Utils.URIEncoding do true -> URI.parse(url) |> then(fn parsed -> - path = - encode_path(parsed.path, bypass_decode) - |> maybe_apply_path_encoding_quirks() + path = encode_path(parsed.path, bypass_decode) query = encode_query(parsed.query) @@ -76,8 +74,6 @@ defmodule Pleroma.Utils.URIEncoding do |> do_encode_query() end - defp maybe_apply_path_encoding_quirks(path), do: path - # Always uses www_form encoding defp do_encode_query(enumerable) do Enum.map_join(enumerable, "&", &maybe_apply_query_quirk(&1)) From 0f7ad318d354c82a5727a62da377084edeeb37d9 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 21 Oct 2025 00:27:12 +0200 Subject: [PATCH 74/78] Add encode_url @spec and docs, and a check whether opts are booleans --- lib/pleroma/utils/uri_encoding.ex | 42 +++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/utils/uri_encoding.ex b/lib/pleroma/utils/uri_encoding.ex index f8a7ba49c..e37ade0b6 100644 --- a/lib/pleroma/utils/uri_encoding.ex +++ b/lib/pleroma/utils/uri_encoding.ex @@ -11,24 +11,44 @@ defmodule Pleroma.Utils.URIEncoding do # We don't always want to decode the path first, like is the case in # Pleroma.Upload.url_from_spec/3. + @doc """ + Wraps URI encoding/decoding functions from Elixir's standard library to fix usually unintended side-effects. + + Supports two URL processing options in the optional 2nd argument with the default being `false`: + + * `bypass_parse` - Bypasses `URI.parse` stage, useful when it's not desirable to parse to URL first + before encoding it. Supports only encoding as the Path segment of a URI. + * `bypass_decode` - Bypasses `URI.decode` stage for the Path segment of a URI. Used when a URL + has to be double %-encoded for internal reasons. + + Options must be specified as a Keyword with tuples with booleans, otherwise + `{:error, :invalid_opts}` is returned. Example: + `encode_url(url, [bypass_parse: true, bypass_decode: true])` + """ + @spec encode_url(String.t(), Keyword.t()) :: String.t() | {:error, :invalid_opts} def encode_url(url, opts \\ []) when is_binary(url) and is_list(opts) do bypass_parse = Keyword.get(opts, :bypass_parse, false) bypass_decode = Keyword.get(opts, :bypass_decode, false) - cond do - bypass_parse -> - encode_path(url, bypass_decode) + with true <- is_boolean(bypass_parse), + true <- is_boolean(bypass_decode) do + cond do + bypass_parse -> + encode_path(url, bypass_decode) - true -> - URI.parse(url) - |> then(fn parsed -> - path = encode_path(parsed.path, bypass_decode) + true -> + URI.parse(url) + |> then(fn parsed -> + path = encode_path(parsed.path, bypass_decode) - query = encode_query(parsed.query) + query = encode_query(parsed.query) - %{parsed | path: path, query: query} - end) - |> URI.to_string() + %{parsed | path: path, query: query} + end) + |> URI.to_string() + end + else + _ -> {:error, :invalid_opts} end end From 73b337245bcc2b3f3809bcc476021f64943bdbcf Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 21 Oct 2025 00:50:08 +0200 Subject: [PATCH 75/78] Make URI encoding query quirks host-aware --- lib/pleroma/utils/uri_encoding.ex | 23 ++++++++++++++--------- test/pleroma/http_test.exs | 6 +++--- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/utils/uri_encoding.ex b/lib/pleroma/utils/uri_encoding.ex index e37ade0b6..dc6b387fa 100644 --- a/lib/pleroma/utils/uri_encoding.ex +++ b/lib/pleroma/utils/uri_encoding.ex @@ -41,7 +41,7 @@ defmodule Pleroma.Utils.URIEncoding do |> then(fn parsed -> path = encode_path(parsed.path, bypass_decode) - query = encode_query(parsed.query) + query = encode_query(parsed.query, parsed.host) %{parsed | path: path, query: query} end) @@ -81,25 +81,27 @@ defmodule Pleroma.Utils.URIEncoding do end) end - defp encode_query(nil), do: nil - # Order of kv pairs in query is not preserved when using URI.decode_query. # URI.query_decoder/2 returns a stream which so far appears to not change order. # Immediately switch to a list to prevent breakage for sites that expect # the order of query keys to be always the same. - defp encode_query(query) when is_binary(query) do + defp encode_query(query, host) when is_binary(query) do query |> URI.query_decoder() |> Enum.to_list() - |> do_encode_query() + |> do_encode_query(host) end - # Always uses www_form encoding - defp do_encode_query(enumerable) do - Enum.map_join(enumerable, "&", &maybe_apply_query_quirk(&1)) + defp encode_query(nil, _), do: nil + + # Always uses www_form encoding. + # Taken from Elixir's URI module. + defp do_encode_query(enumerable, host) do + Enum.map_join(enumerable, "&", &maybe_apply_query_quirk(&1, host)) end - defp maybe_apply_query_quirk({key, value}) do + # https://git.pleroma.social/pleroma/pleroma/-/issues/1055 + defp maybe_apply_query_quirk({key, value}, "i.guim.co.uk" = _host) do case key do "precrop" -> query_encode_kv_pair({key, value}, ~c":,") @@ -109,6 +111,9 @@ defmodule Pleroma.Utils.URIEncoding do end end + defp maybe_apply_query_quirk({key, value}, _), do: query_encode_kv_pair({key, value}) + + # Taken from Elixir's URI module and modified to support quirks. defp query_encode_kv_pair({key, value}, rules \\ []) when is_list(rules) do cond do length(rules) > 0 -> diff --git a/test/pleroma/http_test.exs b/test/pleroma/http_test.exs index 12b040833..7b6847cf9 100644 --- a/test/pleroma/http_test.exs +++ b/test/pleroma/http_test.exs @@ -51,7 +51,7 @@ defmodule Pleroma.HTTPTest do %{ method: :get, url: - "https://example.com/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz" + "https://i.guim.co.uk/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz" } -> %Tesla.Env{status: 200, body: "Space in query with Guardian quirk"} @@ -171,10 +171,10 @@ defmodule Pleroma.HTTPTest do clear_config(:test_url_encoding, true) url = - "https://example.com/emoji/Pack 1/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar baz" + "https://i.guim.co.uk/emoji/Pack 1/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar baz" properly_encoded_url = - "https://example.com/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz" + "https://i.guim.co.uk/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz" result = URIEncoding.encode_url(url) From 7d8a18896742f65f2d5d4aeb4383ab7273900b05 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 27 Oct 2025 18:49:34 +0100 Subject: [PATCH 76/78] Disable Hackney URL encoding function Hackney interferes with out URI encoding and implements older RFC 2396 instead of RFC 3986 which we and Elixir implement. As an example "'" and "!" will get encoded by it and cause problems with our MediaProxy making unexpected 302 redirects. If an admin supplies a different function via *.secret.exs, we don't override it. https://github.com/benoitc/hackney/issues/399 --- lib/pleroma/http/adapter_helper/hackney.ex | 5 +++++ lib/pleroma/reverse_proxy/client/hackney.ex | 5 +++++ test/pleroma/reverse_proxy_test.exs | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex index f3be1f3d0..f3451cf9c 100644 --- a/lib/pleroma/http/adapter_helper/hackney.ex +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -16,7 +16,12 @@ defmodule Pleroma.HTTP.AdapterHelper.Hackney do config_opts = Pleroma.Config.get([:http, :adapter], []) + url_encoding = + Keyword.new() + |> Keyword.put(:path_encode_fun, fn path -> path end) + @defaults + |> Keyword.merge(url_encoding) |> Keyword.merge(config_opts) |> Keyword.merge(connection_opts) |> add_scheme_opts(uri) diff --git a/lib/pleroma/reverse_proxy/client/hackney.ex b/lib/pleroma/reverse_proxy/client/hackney.ex index d3e986912..0aa5f5715 100644 --- a/lib/pleroma/reverse_proxy/client/hackney.ex +++ b/lib/pleroma/reverse_proxy/client/hackney.ex @@ -7,6 +7,11 @@ defmodule Pleroma.ReverseProxy.Client.Hackney do @impl true def request(method, url, headers, body, opts \\ []) do + opts = + Keyword.put_new(opts, :path_encode_fun, fn path -> + path + end) + :hackney.request(method, url, headers, body, opts) end diff --git a/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs index 3595568cf..8dbe9c6bf 100644 --- a/test/pleroma/reverse_proxy_test.exs +++ b/test/pleroma/reverse_proxy_test.exs @@ -396,7 +396,7 @@ defmodule Pleroma.ReverseProxyTest do end end - # Hackey is used for Reverse Proxy when Hackney or Finch is the Tesla Adapter + # Hackney is used for Reverse Proxy when Hackney or Finch is the Tesla Adapter # Gun is able to proxy through Tesla, so it does not need testing as the # test cases in the Pleroma.HTTPTest module are sufficient describe "Hackney URL encoding:" do From 4985902b029bf0ea6afd7951d3050c5b6d5e13c4 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sun, 14 Dec 2025 22:33:59 +0100 Subject: [PATCH 77/78] Add Actor images normalization from array of urls to string --- changelog.d/normalize-actor-image-hrefs.fix | 1 + lib/pleroma/web/activity_pub/activity_pub.ex | 9 +++- test/fixtures/users_mock/href_as_array.json | 41 +++++++++++++++++++ .../web/activity_pub/activity_pub_test.exs | 34 +++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 changelog.d/normalize-actor-image-hrefs.fix create mode 100644 test/fixtures/users_mock/href_as_array.json diff --git a/changelog.d/normalize-actor-image-hrefs.fix b/changelog.d/normalize-actor-image-hrefs.fix new file mode 100644 index 000000000..33d222391 --- /dev/null +++ b/changelog.d/normalize-actor-image-hrefs.fix @@ -0,0 +1 @@ +Add Actor images normalization from array of urls to string diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 3e5239a28..edf2e1fa7 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1569,7 +1569,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp get_actor_url(_url), do: nil - defp normalize_image(%{"url" => url} = data) do + defp normalize_image(%{"url" => url} = data) when is_binary(url) do %{ "type" => "Image", "url" => [%{"href" => url}] @@ -1577,6 +1577,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> maybe_put_description(data) end + defp normalize_image(%{"url" => urls}) when is_list(urls) do + url = urls |> List.first() + + %{"url" => url} + |> normalize_image() + end + defp normalize_image(urls) when is_list(urls), do: urls |> List.first() |> normalize_image() defp normalize_image(_), do: nil diff --git a/test/fixtures/users_mock/href_as_array.json b/test/fixtures/users_mock/href_as_array.json new file mode 100644 index 000000000..624612bce --- /dev/null +++ b/test/fixtures/users_mock/href_as_array.json @@ -0,0 +1,41 @@ +{ + "alsoKnownAs": [], + "attachment": [], + "capabilities": {}, + "discoverable": true, + "endpoints": {}, + "featured": "https://queef.in/cute_cat/collections/featured", + "followers": "https://queef.in/cute_cat/followers", + "following": "https://queef.in/cute_cat/following", + "icon": { + "type": "Image", + "url": [ + "https://queef.in/storage/profile.webp", + "https://example.com/image" + ] + }, + "id": "https://queef.in/cute_cat", + "image": { + "type": "Image", + "url": [ + "https://queef.in/storage/banner.gif", + "https://example.com/image" + ] + }, + "inbox": "https://queef.in/cute_cat/inbox", + "manuallyApprovesFollowers": false, + "name": "cute_cat", + "outbox": "https://queef.in/cute_cat/outbox", + "preferredUsername": "cute_cat", + "publicKey": { + "id": "https://queef.in/cute_cat#main-key", + "owner": "https://queef.in/cute_cat" + }, + "published": "2025-08-18T01:16:10.000Z", + "summary": "A cute cat", + "tag": [], + "type": "Person", + "url": "https://queef.in/cute_cat", + "vcard:bday": null, + "webfinger": "acct:cute_cat@queef.in" +} diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 7fc4f47cd..c10735d45 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -465,6 +465,40 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do end end + test "works with avatar/banner href as list" do + user_id = "https://queef.in/cute_cat" + + user_data = + "test/fixtures/users_mock/href_as_array.json" + |> File.read!() + |> Jason.decode!() + |> Map.delete("featured") + |> Jason.encode!() + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^user_id + } -> + %Tesla.Env{ + status: 200, + body: user_data, + headers: [{"content-type", "application/activity+json"}] + } + end) + + {:ok, user} = ActivityPub.make_user_from_ap_id(user_id) + + assert length(user.avatar["url"]) == 1 + assert length(user.banner["url"]) == 1 + + assert user.avatar["url"] |> List.first() |> Map.fetch!("href") == + "https://queef.in/storage/profile.webp" + + assert user.banner["url"] |> List.first() |> Map.fetch!("href") == + "https://queef.in/storage/banner.gif" + end + test "it fetches the appropriate tag-restricted posts" do user = insert(:user) From c05d2d02c2c930d03e90e6b7e725bd3529e743ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 16 Dec 2025 00:04:33 +0100 Subject: [PATCH 78/78] Use :list_behaviour_implementations for LanguageDetector and Translation providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/description.skip | 1 + config/description.exs | 11 ++--------- 2 files changed, 3 insertions(+), 9 deletions(-) create mode 100644 changelog.d/description.skip diff --git a/changelog.d/description.skip b/changelog.d/description.skip new file mode 100644 index 000000000..bbcfb2e13 --- /dev/null +++ b/changelog.d/description.skip @@ -0,0 +1 @@ +Use :list_behaviour_implementations for LanguageDetector and Translation providers diff --git a/config/description.exs b/config/description.exs index e8a065f72..2566cfba6 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3539,9 +3539,7 @@ config :pleroma, :config_description, [ %{ key: :provider, type: :module, - suggestions: [ - Pleroma.Language.LanguageDetector.Fasttext - ] + suggestions: {:list_behaviour_implementations, Pleroma.Language.LanguageDetector.Provider} }, %{ group: {:subgroup, Pleroma.Language.LanguageDetector.Fasttext}, @@ -3561,12 +3559,7 @@ config :pleroma, :config_description, [ %{ key: :provider, type: :module, - suggestions: [ - Pleroma.Language.Translation.Deepl, - Pleroma.Language.Translation.Libretranslate, - Pleroma.Language.Translation.Mozhi, - Pleroma.Language.Translation.TranslateLocally - ] + suggestions: {:list_behaviour_implementations, Pleroma.Language.Translation.Provider} }, %{ group: {:subgroup, Pleroma.Language.Translation.Deepl},