From 01d94a01358684223c5ed9e4348aee448ae2bf44 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Dec 2025 05:08:52 +0000 Subject: [PATCH 01/42] Revert "Merge branch 'revert-cdd6df06' into 'develop'" This reverts merge request !4411 --- changelog.d/hackney.change | 1 + mix.exs | 2 +- mix.lock | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changelog.d/hackney.change diff --git a/changelog.d/hackney.change b/changelog.d/hackney.change new file mode 100644 index 000000000..3158cfc77 --- /dev/null +++ b/changelog.d/hackney.change @@ -0,0 +1 @@ +Update Hackney, the default HTTP client, to the latest release which supports Happy Eyeballs for improved IPv6 federation diff --git a/mix.exs b/mix.exs index 9e7ce7d4e..ac998e9ee 100644 --- a/mix.exs +++ b/mix.exs @@ -210,7 +210,7 @@ defmodule Pleroma.Mixfile do {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.5", only: :test}, {:covertool, "~> 2.0", only: :test}, - {:hackney, "~> 1.18.0", override: true}, + {:hackney, "~> 1.25.0", override: true}, {:mox, "~> 1.0", only: :test}, {:websockex, "~> 0.4.3", only: :test}, {:benchee, "~> 1.0", only: :benchmark}, diff --git a/mix.lock b/mix.lock index 3599f62b4..b8c9e240d 100644 --- a/mix.lock +++ b/mix.lock @@ -13,7 +13,7 @@ "captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "e7b7cc34cc16b383461b966484c297e4ec9aeef6", [ref: "e7b7cc34cc16b383461b966484c297e4ec9aeef6"]}, "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, - "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "concurrent_limiter": {:hex, :concurrent_limiter, "0.1.1", "43ae1dc23edda1ab03dd66febc739c4ff710d047bb4d735754909f9a474ae01c", [:mix], [{:telemetry, "~> 0.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "53968ff238c0fbb4d7ed76ddb1af0be6f3b2f77909f6796e249e737c505a16eb"}, @@ -59,7 +59,7 @@ "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"}, - "hackney": {:hex, :hackney, "1.18.2", "d7ff544ddae5e1cb49e9cf7fa4e356d7f41b283989a1c304bfc47a8cc1cf966f", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "af94d5c9f97857db257090a4a10e5426ecb6f4918aa5cc666798566ae14b65fd"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "http_signatures": {:hex, :http_signatures, "0.1.2", "ed1cc7043abcf5bb4f30d68fb7bad9d618ec1a45c4ff6c023664e78b67d9c406", [:mix], [], "hexpm", "f08aa9ac121829dae109d608d83c84b940ef2f183ae50f2dd1e9a8bc619d8be7"}, From b85507c764e03076844d67d4480609faecc6fa88 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 16 Jan 2026 21:16:04 +0400 Subject: [PATCH 02/42] http(hackney): disable adapter redirects by default Hackney 1.25.x has redirect handling issues behind CONNECT proxies and with pools. Disable hackney-level redirects and rely on Tesla.Middleware.FollowRedirects instead. Also default to with_body: true so redirects can be followed reliably. --- lib/pleroma/http/adapter_helper/hackney.ex | 5 +++-- test/pleroma/http/adapter_helper/hackney_test.exs | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex index f3451cf9c..2b1502001 100644 --- a/lib/pleroma/http/adapter_helper/hackney.ex +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -6,8 +6,9 @@ defmodule Pleroma.HTTP.AdapterHelper.Hackney do @behaviour Pleroma.HTTP.AdapterHelper @defaults [ - follow_redirect: true, - force_redirect: true + follow_redirect: false, + force_redirect: false, + with_body: true ] @spec options(keyword(), URI.t()) :: keyword() diff --git a/test/pleroma/http/adapter_helper/hackney_test.exs b/test/pleroma/http/adapter_helper/hackney_test.exs index 57ce4728c..343bdb800 100644 --- a/test/pleroma/http/adapter_helper/hackney_test.exs +++ b/test/pleroma/http/adapter_helper/hackney_test.exs @@ -16,6 +16,14 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do describe "options/2" do setup do: clear_config([:http, :adapter], a: 1, b: 2) + test "uses redirect-safe defaults", %{uri: uri} do + opts = Hackney.options([], uri) + + assert opts[:follow_redirect] == false + assert opts[:force_redirect] == false + assert opts[:with_body] == true + end + test "add proxy and opts from config", %{uri: uri} do opts = Hackney.options([proxy: "localhost:8123"], uri) From 500340fc8214ff91a4f4e165b84670f998392664 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 16 Jan 2026 21:16:26 +0400 Subject: [PATCH 03/42] test(http): cover pooled redirect with hackney Reproduces the Hackney 1.25 pooled redirect cleanup issue which can surface as :req_not_found when the adapter returns a Ref and the body is later fetched. --- .../http/hackney_redirect_regression_test.exs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 test/pleroma/http/hackney_redirect_regression_test.exs diff --git a/test/pleroma/http/hackney_redirect_regression_test.exs b/test/pleroma/http/hackney_redirect_regression_test.exs new file mode 100644 index 000000000..61389aa7e --- /dev/null +++ b/test/pleroma/http/hackney_redirect_regression_test.exs @@ -0,0 +1,151 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.HackneyRedirectRegressionTest do + use ExUnit.Case, async: false + + alias Pleroma.HTTP.AdapterHelper.Hackney, as: HackneyAdapterHelper + + setup do + {:ok, _} = Application.ensure_all_started(:hackney) + + {:ok, server} = start_server() + on_exit(fn -> stop_server(server) end) + + {:ok, server: server} + end + + test "pooled redirects work with follow_redirect disabled", %{server: server} do + url = "#{server.base_url}/redirect" + uri = URI.parse(url) + + adapter_opts = + HackneyAdapterHelper.options( + [pool: :media, follow_redirect: false, no_proxy_env: true], + uri + ) + + client = Tesla.client([Tesla.Middleware.FollowRedirects], Tesla.Adapter.Hackney) + + assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = + Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts]) + end + + defp start_server do + {:ok, listener} = + :gen_tcp.listen(0, [ + :binary, + active: false, + packet: :raw, + reuseaddr: true, + ip: {127, 0, 0, 1} + ]) + + {:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(listener) + + {:ok, acceptor} = + Task.start_link(fn -> + accept_loop(listener) + end) + + {:ok, %{listener: listener, acceptor: acceptor, base_url: "http://127.0.0.1:#{port}"}} + end + + defp stop_server(%{listener: listener, acceptor: acceptor}) do + :ok = :gen_tcp.close(listener) + + if Process.alive?(acceptor) do + Process.exit(acceptor, :normal) + end + end + + defp accept_loop(listener) do + case :gen_tcp.accept(listener) do + {:ok, socket} -> + serve(socket) + accept_loop(listener) + + {:error, :closed} -> + :ok + + {:error, _reason} -> + :ok + end + end + + defp serve(socket) do + with {:ok, data} <- recv_headers(socket), + {:ok, path} <- parse_path(data) do + case path do + "/redirect" -> + send_response(socket, 302, "Found", [{"Location", "/final"}], "") + + "/final" -> + send_response(socket, 200, "OK", [], "ok") + + _ -> + send_response(socket, 404, "Not Found", [], "not found") + end + else + _ -> :ok + end + + :gen_tcp.close(socket) + end + + defp recv_headers(socket, acc \\ <<>>) do + case :gen_tcp.recv(socket, 0, 1_000) do + {:ok, data} -> + acc = acc <> data + + if :binary.match(acc, "\r\n\r\n") != :nomatch do + {:ok, acc} + else + if byte_size(acc) > 8_192 do + {:error, :too_large} + else + recv_headers(socket, acc) + end + end + + {:error, _} = error -> + error + end + end + + defp parse_path(data) do + case String.split(data, "\r\n", parts: 2) do + [request_line | _] -> + case String.split(request_line, " ") do + [_method, path, _protocol] -> {:ok, path} + _ -> {:error, :invalid_request} + end + + _ -> + {:error, :invalid_request} + end + end + + defp send_response(socket, status, reason, headers, body) do + base_headers = + [ + {"Content-Length", Integer.to_string(byte_size(body))}, + {"Connection", "close"} + ] ++ headers + + iodata = + [ + "HTTP/1.1 ", + Integer.to_string(status), + " ", + reason, + "\r\n", + Enum.map(base_headers, fn {k, v} -> [k, ": ", v, "\r\n"] end), + "\r\n", + body + ] + + :gen_tcp.send(socket, iodata) + end +end From f2ad6100f20b07a4f620cfa4edbccc054a85af7d Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 16 Jan 2026 21:16:39 +0400 Subject: [PATCH 04/42] test(http): reproduce hackney follow_redirect crash via CONNECT proxy Hackney 1.25 crashes when follow_redirect is enabled behind an HTTPS CONNECT proxy and the Location header is relative (hackney_http_connect transport). This test demonstrates the failure and verifies Tesla-level redirects work when hackney redirects are disabled. --- test/fixtures/server.pem | 18 + ...ackney_follow_redirect_regression_test.exs | 325 ++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 test/fixtures/server.pem create mode 100644 test/pleroma/http/hackney_follow_redirect_regression_test.exs diff --git a/test/fixtures/server.pem b/test/fixtures/server.pem new file mode 100644 index 000000000..8bd3d6d05 --- /dev/null +++ b/test/fixtures/server.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIICpDCCAYwCCQC0vCQAnSoGdzANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls +b2NhbGhvc3QwHhcNMjYwMTE2MTY1ODE5WhcNMzYwMTE0MTY1ODE5WjAUMRIwEAYD +VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq +dZ4O2upZqwIo1eK5KrW1IIsjkfsFK8hE7Llh+4axcesiUKot0ib1CUhRSYiL1DLO +CIYQOw8IKQDVSC4JWAX9SsnX4W8dwexMQuSQG7/IKX2auC1bNNySFvoqM6Gq3GL9 +MqBFonZGXDPZu8fmxsI/2p9+2GK13F+HXgoLlXSCoO3XELJaBmjv29tgxxWRxCiH +m4u0briSxgUEx+CctpKPvGDmLaoIOIhjtuoG6OjkeWUOp6jDcteazO23VxPyF5cS +NbRJgm8AckrTQ6wbWSnhyqF8rPEsIc0ZAlUdDEs5fL3sjugc566FvE+GOkZIEyDD +tgWbc4Ne+Kp/nnt6oVxpAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADv+J1DTok8V +MKVKo0hsRnHTeJQ2+EIgOspuYlEzez3PysOZH6diAQxO2lzuo9LKxP3hnmw17XO/ +P2oCzYyb9/P58VY/gr4UDIfuhgcE0cVfdsRhVId/I2FW6VP2f5q1TGbDUxSsVIlG +6hufn1aLBu90LtEbDkHqbnD05yYPwdqzWg4TrOXbX+jBhQrXJJdB3W7KTgozjRQw +F7+/2IyXoxXuxcwQBQlYhUbvGlsFqFpP/6cz2al5i5pNUkiNaSYwlRmuwa7zoTft +tHf57dhfXIpXET2BaJM6DSjDOOG/QleRXkvkTI5J21q+Bo+XnOzo19p4cZKJpTFC +SNgrftyNh3k= +-----END CERTIFICATE----- + diff --git a/test/pleroma/http/hackney_follow_redirect_regression_test.exs b/test/pleroma/http/hackney_follow_redirect_regression_test.exs new file mode 100644 index 000000000..ea631024c --- /dev/null +++ b/test/pleroma/http/hackney_follow_redirect_regression_test.exs @@ -0,0 +1,325 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do + use ExUnit.Case, async: false + + setup do + {:ok, _} = Application.ensure_all_started(:hackney) + + {:ok, tls_server} = start_tls_redirect_server() + {:ok, proxy} = start_connect_proxy() + + on_exit(fn -> + stop_connect_proxy(proxy) + stop_tls_redirect_server(tls_server) + end) + + {:ok, tls_server: tls_server, proxy: proxy} + end + + test "hackney follow_redirect crashes behind CONNECT proxy on relative redirects", %{ + tls_server: tls_server, + proxy: proxy + } do + url = "#{tls_server.base_url}/redirect" + + opts = [ + pool: :media, + proxy: proxy.proxy_url, + insecure: true, + connect_timeout: 1_000, + recv_timeout: 1_000, + follow_redirect: true, + force_redirect: true + ] + + {pid, ref} = spawn_monitor(fn -> :hackney.request(:get, url, [], <<>>, opts) end) + + assert_receive {:DOWN, ^ref, :process, ^pid, reason}, 5_000 + + assert match?({%FunctionClauseError{}, _}, reason) or match?(%FunctionClauseError{}, reason) or + match?({:function_clause, _}, reason) + end + + test "redirects work via proxy when hackney follow_redirect is disabled", %{ + tls_server: tls_server, + proxy: proxy + } do + url = "#{tls_server.base_url}/redirect" + + adapter_opts = [ + pool: :media, + proxy: proxy.proxy_url, + insecure: true, + connect_timeout: 1_000, + recv_timeout: 1_000, + follow_redirect: false, + force_redirect: false, + with_body: true + ] + + client = Tesla.client([Tesla.Middleware.FollowRedirects], Tesla.Adapter.Hackney) + + assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = + Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts]) + end + + defp start_tls_redirect_server do + certfile = Path.expand("../../fixtures/server.pem", __DIR__) + keyfile = Path.expand("../../fixtures/private_key.pem", __DIR__) + + {:ok, listener} = + :ssl.listen(0, [ + :binary, + certfile: certfile, + keyfile: keyfile, + reuseaddr: true, + active: false, + packet: :raw, + ip: {127, 0, 0, 1} + ]) + + {:ok, {{127, 0, 0, 1}, port}} = :ssl.sockname(listener) + + {:ok, acceptor} = + Task.start_link(fn -> + accept_tls_loop(listener) + end) + + {:ok, %{listener: listener, acceptor: acceptor, base_url: "https://127.0.0.1:#{port}"}} + end + + defp stop_tls_redirect_server(%{listener: listener, acceptor: acceptor}) do + :ok = :ssl.close(listener) + + if Process.alive?(acceptor) do + Process.exit(acceptor, :normal) + end + end + + defp accept_tls_loop(listener) do + case :ssl.transport_accept(listener) do + {:ok, socket} -> + _ = Task.start(fn -> serve_tls(socket) end) + accept_tls_loop(listener) + + {:error, :closed} -> + :ok + + {:error, _reason} -> + :ok + end + end + + defp serve_tls(tcp_socket) do + with {:ok, ssl_socket} <- :ssl.handshake(tcp_socket, 2_000), + {:ok, data} <- recv_ssl_headers(ssl_socket), + {:ok, path} <- parse_path(data) do + case path do + "/redirect" -> + send_ssl_response(ssl_socket, 302, "Found", [{"Location", "/final"}], "") + + "/final" -> + send_ssl_response(ssl_socket, 200, "OK", [], "ok") + + _ -> + send_ssl_response(ssl_socket, 404, "Not Found", [], "not found") + end + + :ssl.close(ssl_socket) + else + _ -> + _ = :gen_tcp.close(tcp_socket) + :ok + end + end + + defp recv_ssl_headers(socket, acc \\ <<>>) do + case :ssl.recv(socket, 0, 1_000) do + {:ok, data} -> + acc = acc <> data + + if :binary.match(acc, "\r\n\r\n") != :nomatch do + {:ok, acc} + else + if byte_size(acc) > 8_192 do + {:error, :too_large} + else + recv_ssl_headers(socket, acc) + end + end + + {:error, _} = error -> + error + end + end + + defp send_ssl_response(socket, status, reason, headers, body) do + base_headers = + [ + {"Content-Length", Integer.to_string(byte_size(body))}, + {"Connection", "close"} + ] ++ headers + + iodata = + [ + "HTTP/1.1 ", + Integer.to_string(status), + " ", + reason, + "\r\n", + Enum.map(base_headers, fn {k, v} -> [k, ": ", v, "\r\n"] end), + "\r\n", + body + ] + + :ssl.send(socket, iodata) + end + + defp start_connect_proxy do + {:ok, listener} = + :gen_tcp.listen(0, [ + :binary, + active: false, + packet: :raw, + reuseaddr: true, + ip: {127, 0, 0, 1} + ]) + + {:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(listener) + + {:ok, acceptor} = + Task.start_link(fn -> + accept_proxy_loop(listener) + end) + + {:ok, %{listener: listener, acceptor: acceptor, proxy_url: "127.0.0.1:#{port}"}} + end + + defp stop_connect_proxy(%{listener: listener, acceptor: acceptor}) do + :ok = :gen_tcp.close(listener) + + if Process.alive?(acceptor) do + Process.exit(acceptor, :normal) + end + end + + defp accept_proxy_loop(listener) do + case :gen_tcp.accept(listener) do + {:ok, socket} -> + _ = Task.start(fn -> serve_proxy(socket) end) + accept_proxy_loop(listener) + + {:error, :closed} -> + :ok + + {:error, _reason} -> + :ok + end + end + + defp serve_proxy(client_socket) do + with {:ok, {headers, rest}} <- recv_tcp_headers(client_socket), + {:ok, {host, port}} <- parse_connect(headers), + {:ok, upstream_socket} <- connect_upstream(host, port) do + :gen_tcp.send(client_socket, "HTTP/1.1 200 Connection established\r\n\r\n") + + if rest != <<>> do + :gen_tcp.send(upstream_socket, rest) + end + + tunnel(client_socket, upstream_socket) + else + _ -> + :gen_tcp.close(client_socket) + :ok + end + end + + defp tunnel(client_socket, upstream_socket) do + parent = self() + _ = spawn_link(fn -> forward(client_socket, upstream_socket, parent) end) + _ = spawn_link(fn -> forward(upstream_socket, client_socket, parent) end) + + receive do + :tunnel_closed -> :ok + after + 10_000 -> :ok + end + + :gen_tcp.close(client_socket) + :gen_tcp.close(upstream_socket) + end + + defp forward(from_socket, to_socket, parent) do + case :gen_tcp.recv(from_socket, 0, 10_000) do + {:ok, data} -> + _ = :gen_tcp.send(to_socket, data) + forward(from_socket, to_socket, parent) + + {:error, _reason} -> + send(parent, :tunnel_closed) + :ok + end + end + + defp recv_tcp_headers(socket, acc \\ <<>>) do + case :gen_tcp.recv(socket, 0, 1_000) do + {:ok, data} -> + acc = acc <> data + + case :binary.match(acc, "\r\n\r\n") do + :nomatch -> + if byte_size(acc) > 8_192 do + {:error, :too_large} + else + recv_tcp_headers(socket, acc) + end + + {idx, _len} -> + split_at = idx + 4 + <> = acc + {:ok, {headers, rest}} + end + + {:error, _} = error -> + error + end + end + + defp parse_connect(data) do + with [request_line | _] <- String.split(data, "\r\n", trim: true), + ["CONNECT", hostport | _] <- String.split(request_line, " ", parts: 3), + [host, port_str] <- String.split(hostport, ":", parts: 2), + {port, ""} <- Integer.parse(port_str) do + {:ok, {host, port}} + else + _ -> {:error, :invalid_connect} + end + end + + defp connect_upstream(host, port) do + address = + case :inet.parse_address(String.to_charlist(host)) do + {:ok, ip} -> ip + {:error, _} -> String.to_charlist(host) + end + + :gen_tcp.connect(address, port, [:binary, active: false, packet: :raw], 1_000) + end + + defp parse_path(data) do + case String.split(data, "\r\n", parts: 2) do + [request_line | _] -> + case String.split(request_line, " ") do + [_method, path, _protocol] -> {:ok, path} + _ -> {:error, :invalid_request} + end + + _ -> + {:error, :invalid_request} + end + end +end From 71a4b8d0f2cdeba56dfcfdc034f797f534bb9d76 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 24 Dec 2025 14:35:54 -0800 Subject: [PATCH 05/42] In-house redirect handler for mediaproxy with Hackney adapter Also ensure we always pass an absolute URL to Hackney when parsing a redirect response (cherry picked from commit 00ac6bce8d244eec7e2460358296619e5cacba6b) --- changelog.d/hackney-mediaproxy.change | 1 + lib/pleroma/reverse_proxy/client/hackney.ex | 44 ++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 changelog.d/hackney-mediaproxy.change diff --git a/changelog.d/hackney-mediaproxy.change b/changelog.d/hackney-mediaproxy.change new file mode 100644 index 000000000..10dfb0775 --- /dev/null +++ b/changelog.d/hackney-mediaproxy.change @@ -0,0 +1 @@ +Use a custom redirect handler to ensure MediaProxy redirects are followed with Hackney diff --git a/lib/pleroma/reverse_proxy/client/hackney.ex b/lib/pleroma/reverse_proxy/client/hackney.ex index 0aa5f5715..7ccd28bb1 100644 --- a/lib/pleroma/reverse_proxy/client/hackney.ex +++ b/lib/pleroma/reverse_proxy/client/hackney.ex @@ -5,6 +5,20 @@ defmodule Pleroma.ReverseProxy.Client.Hackney do @behaviour Pleroma.ReverseProxy.Client + # redirect handler from Pleb, slightly modified to work with Hackney + # https://declin.eu/objects/d4f38e62-5429-4614-86d1-e8fc16e6bf33 + # https://github.com/benoitc/hackney/issues/273 + @redirect_statuses [301, 302, 303, 307, 308] + defp absolute_redirect_url(original_url, resp_headers) do + location = + Enum.find(resp_headers, fn {header, _location} -> + String.downcase(header) == "location" + end) + + URI.merge(original_url, elem(location, 1)) + |> URI.to_string() + end + @impl true def request(method, url, headers, body, opts \\ []) do opts = @@ -12,7 +26,35 @@ defmodule Pleroma.ReverseProxy.Client.Hackney do path end) - :hackney.request(method, url, headers, body, opts) + if opts[:follow_redirect] != false do + {_state, req_opts} = Access.get_and_update(opts, :follow_redirect, fn a -> {a, false} end) + res = :hackney.request(method, url, headers, body, req_opts) + + case res do + {:ok, code, resp_headers, _client} when code in @redirect_statuses -> + :hackney.request( + method, + absolute_redirect_url(url, resp_headers), + headers, + body, + req_opts + ) + + {:ok, code, resp_headers} when code in @redirect_statuses -> + :hackney.request( + method, + absolute_redirect_url(url, resp_headers), + headers, + body, + req_opts + ) + + _ -> + res + end + else + :hackney.request(method, url, headers, body, opts) + end end @impl true From 89360664272bb94641802df26a81b90fae80420b Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 16 Jan 2026 22:00:36 +0400 Subject: [PATCH 06/42] test(http): cover reverse proxy redirects via CONNECT proxy Exercises Pleroma.ReverseProxy.Client.Hackney with follow_redirect enabled behind an HTTPS CONNECT proxy, ensuring the client follows a relative redirect and can stream the final body. --- ...ackney_follow_redirect_regression_test.exs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/pleroma/http/hackney_follow_redirect_regression_test.exs b/test/pleroma/http/hackney_follow_redirect_regression_test.exs index ea631024c..71fda4479 100644 --- a/test/pleroma/http/hackney_follow_redirect_regression_test.exs +++ b/test/pleroma/http/hackney_follow_redirect_regression_test.exs @@ -66,6 +66,36 @@ defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts]) end + test "reverse proxy hackney client follows redirects via proxy without crashing", %{ + tls_server: tls_server, + proxy: proxy + } do + url = "#{tls_server.base_url}/redirect" + + opts = [ + pool: :media, + proxy: proxy.proxy_url, + insecure: true, + connect_timeout: 1_000, + recv_timeout: 1_000, + follow_redirect: true + ] + + assert {:ok, 200, _headers, ref} = + Pleroma.ReverseProxy.Client.Hackney.request(:get, url, [], "", opts) + + assert collect_body(ref) == "ok" + Pleroma.ReverseProxy.Client.Hackney.close(ref) + end + + defp collect_body(ref, acc \\ "") do + case Pleroma.ReverseProxy.Client.Hackney.stream_body(ref) do + :done -> acc + {:ok, data, _ref} -> collect_body(ref, acc <> data) + {:error, error} -> flunk("stream_body failed: #{inspect(error)}") + end + end + defp start_tls_redirect_server do certfile = Path.expand("../../fixtures/server.pem", __DIR__) keyfile = Path.expand("../../fixtures/private_key.pem", __DIR__) From e40bedc601eeef73e0b3e3964110534a7710e16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sun, 7 Sep 2025 23:28:58 +0200 Subject: [PATCH 07/42] Add v1/instance/domain_blocks endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/instance-domain-blocks.add | 1 + .../api_spec/operations/instance_operation.ex | 31 ++++++++++++ .../controllers/instance_controller.ex | 7 ++- .../web/mastodon_api/views/instance_view.ex | 49 +++++++++++++++++++ lib/pleroma/web/router.ex | 1 + .../controllers/instance_controller_test.exs | 27 ++++++++++ 6 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 changelog.d/instance-domain-blocks.add diff --git a/changelog.d/instance-domain-blocks.add b/changelog.d/instance-domain-blocks.add new file mode 100644 index 000000000..85f01c5c2 --- /dev/null +++ b/changelog.d/instance-domain-blocks.add @@ -0,0 +1 @@ +Add v1/instance/domain_blocks endpoint diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index 911ffb994..d8b2901d3 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -57,6 +57,22 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do } end + def domain_blocks_operation do + %Operation{ + tags: ["Instance misc"], + summary: "Retrieve instance domain blocks", + operationId: "InstanceController.domain_blocks", + responses: %{ + 200 => + Operation.response( + "Array of domain blocks", + "application/json", + array_of_domain_blocks() + ) + } + } + end + def translation_languages_operation do %Operation{ tags: ["Instance misc"], @@ -420,4 +436,19 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do } } end + + defp array_of_domain_blocks do + %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + domain: %Schema{type: :string}, + digest: %Schema{type: :string}, + severity: %Schema{type: :string}, + comment: %Schema{type: :string} + } + } + } + end end diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex index 0f74c1dff..cf5bbf16e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceController do plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(:skip_auth when action in [:show, :show2, :peers]) + plug(:skip_auth) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.InstanceOperation @@ -31,6 +31,11 @@ defmodule Pleroma.Web.MastodonAPI.InstanceController do render(conn, "rules.json") end + @doc "GET /api/v1/instance/domain_blocks" + def domain_blocks(conn, _params) do + render(conn, "domain_blocks.json") + end + @doc "GET /api/v1/instance/translation_languages" def translation_languages(conn, _params) do render(conn, "translation_languages.json") diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 57372248f..18c6c0f80 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -5,11 +5,18 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do use Pleroma.Web, :view + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + alias Pleroma.Config alias Pleroma.Web.ActivityPub.MRF @mastodon_api_level "2.7.2" + @block_severities %{ + federated_timeline_removal: "silence", + reject: "suspend" + } + def render("show.json", _) do instance = Config.get(:instance) @@ -90,6 +97,48 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do } end + def render("domain_blocks.json", _) do + if Config.get([:mrf, :transparency]) do + exclusions = Config.get([:mrf, :transparency_exclusions]) |> MRF.instance_list_from_tuples() + + domain_blocks = + Config.get(:mrf_simple) + |> Enum.map(fn {rule, instances} -> + instances + |> Enum.map(fn + {host, reason} when not_empty_string(host) and not_empty_string(reason) -> + {host, reason} + + {host, _reason} when not_empty_string(host) -> + {host, ""} + + host when not_empty_string(host) -> + {host, ""} + + _ -> + nil + end) + |> Enum.reject(&is_nil/1) + |> Enum.reject(fn {host, _} -> + host in exclusions or not Map.has_key?(@block_severities, rule) + end) + |> Enum.map(fn {host, reason} -> + %{ + domain: host, + digest: :crypto.hash(:sha256, host) |> Base.encode16(case: :lower), + severity: Map.get(@block_severities, rule), + comment: reason + } + end) + end) + |> List.flatten() + + domain_blocks + else + [] + end + end + def render("translation_languages.json", _) do with true <- Pleroma.Language.Translation.configured?(), {:ok, languages} <- Pleroma.Language.Translation.languages_matrix() do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index da9626147..a0fa7b3e3 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -813,6 +813,7 @@ defmodule Pleroma.Web.Router do get("/instance", InstanceController, :show) get("/instance/peers", InstanceController, :peers) get("/instance/rules", InstanceController, :rules) + get("/instance/domain_blocks", InstanceController, :domain_blocks) get("/instance/translation_languages", InstanceController, :translation_languages) get("/statuses", StatusController, :index) 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 10c0b6ea7..e0c9091e0 100644 --- a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs @@ -153,6 +153,33 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do ] = result["rules"] end + describe "instance domain blocks" do + setup do + clear_config([:mrf_simple, :reject], [{"fediverse.pl", "uses pl-fe"}]) + end + + test "get instance domain blocks", %{conn: conn} do + conn = get(conn, "/api/v1/instance/domain_blocks") + + assert [ + %{ + "comment" => "uses pl-fe", + "digest" => "55e3f44aefe7eb022d3b1daaf7396cabf7f181bf6093c8ea841e30c9fc7d8226", + "domain" => "fediverse.pl", + "severity" => "suspend" + } + ] == json_response_and_validate_schema(conn, 200) + end + + test "returns empty array if mrf transparency is disabled", %{conn: conn} do + clear_config([:mrf, :transparency], false) + + conn = get(conn, "/api/v1/instance/domain_blocks") + + assert [] == json_response_and_validate_schema(conn, 200) + end + end + test "translation languages matrix", %{conn: conn} do clear_config([Pleroma.Language.Translation, :provider], TranslationMock) From 56006345746b97f3b8d267eec4c2cbbff137d4d5 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 31 Dec 2025 18:52:14 +0400 Subject: [PATCH 08/42] Installation: Add Release-Via-Docker option --- docs/installation/otp_en.md | 3 + docs/installation/release_to_docker_en.md | 61 +++++++++++++++++ installation/release-to-docker/Dockerfile | 24 +++++++ installation/release-to-docker/README.md | 66 +++++++++++++++++++ .../release-to-docker/docker-compose.yml | 22 +++++++ .../pleroma-host-release-entrypoint.sh | 19 ++++++ .../release-to-docker/pleroma.service | 16 +++++ 7 files changed, 211 insertions(+) create mode 100644 docs/installation/release_to_docker_en.md create mode 100644 installation/release-to-docker/Dockerfile create mode 100644 installation/release-to-docker/README.md create mode 100644 installation/release-to-docker/docker-compose.yml create mode 100644 installation/release-to-docker/pleroma-host-release-entrypoint.sh create mode 100644 installation/release-to-docker/pleroma.service diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 86efa27f8..123dccf3a 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -13,6 +13,9 @@ You will be running commands as root. If you aren't root already, please elevate Similarly to other binaries, OTP releases tend to be only compatible with the distro they are built on, as such this guide focuses only on Debian/Ubuntu and Alpine. +!!! note + If you get `GLIBC_... not found` errors on Debian/Ubuntu, you can run the OTP release from `/opt/pleroma` inside a newer distro container without upgrading the host. See [`release_to_docker_en.md`](release_to_docker_en.md). + ### Detecting flavour Paste the following into the shell: diff --git a/docs/installation/release_to_docker_en.md b/docs/installation/release_to_docker_en.md new file mode 100644 index 000000000..a1fd165ac --- /dev/null +++ b/docs/installation/release_to_docker_en.md @@ -0,0 +1,61 @@ +# Running OTP releases via Docker (glibc shim) + +Pleroma OTP releases are built on specific distros. If your host OS is older than +the build environment, you may hit runtime linker errors such as: + +``` +/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38' not found +``` + +If you don't want to upgrade your host OS, you can run the existing OTP release +from `/opt/pleroma` inside an Ubuntu 24.04 container while keeping your existing +host config and data directories. + +This approach uses a small "shim" container image to provide a newer `glibc`. +It is **not** the official Pleroma Docker image. + +## Requirements + +- Docker Engine + the Docker Compose plugin on the host +- Root access (or equivalent access to the Docker socket) +- Existing OTP release in `/opt/pleroma` +- Existing config in `/etc/pleroma` and data in `/var/lib/pleroma` + +## Setup + +1. Copy the provided templates: + + ```sh + mkdir -p /etc/pleroma/container + cp -a /opt/pleroma/installation/release-to-docker/* /etc/pleroma/container/ + ``` + +2. Build the shim image: + + ```sh + cd /etc/pleroma/container + docker compose build + ``` + +3. Replace your systemd unit: + + ```sh + cp /etc/pleroma/container/pleroma.service /etc/systemd/system/pleroma.service + systemctl daemon-reload + systemctl enable --now pleroma + journalctl -u pleroma -f + ``` + +## Running migrations / `pleroma_ctl` + +Migrations are run automatically by default when the container starts. You can +disable this by setting `PLEROMA_RUN_MIGRATIONS=0` in +`/etc/pleroma/container/docker-compose.yml`. + +To run admin commands inside the container: + +```sh +cd /etc/pleroma/container +docker compose exec pleroma /opt/pleroma/bin/pleroma_ctl status +docker compose run --rm --no-deps pleroma /opt/pleroma/bin/pleroma_ctl migrate +``` diff --git a/installation/release-to-docker/Dockerfile b/installation/release-to-docker/Dockerfile new file mode 100644 index 000000000..ddff4570d --- /dev/null +++ b/installation/release-to-docker/Dockerfile @@ -0,0 +1,24 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + gosu \ + libstdc++6 \ + libncurses6 libncursesw6 \ + openssl libssl3 \ + libmagic1t64 file \ + postgresql-client \ + ffmpeg imagemagick libimage-exiftool-perl \ + libvips42t64 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/pleroma + +COPY pleroma-host-release-entrypoint.sh /usr/local/bin/pleroma-host-release-entrypoint.sh +RUN chmod +x /usr/local/bin/pleroma-host-release-entrypoint.sh +ENTRYPOINT ["/usr/local/bin/pleroma-host-release-entrypoint.sh"] +CMD ["/opt/pleroma/bin/pleroma", "start"] diff --git a/installation/release-to-docker/README.md b/installation/release-to-docker/README.md new file mode 100644 index 000000000..4129bd94b --- /dev/null +++ b/installation/release-to-docker/README.md @@ -0,0 +1,66 @@ +# Run OTP releases on older glibc using Docker + +Pleroma OTP releases are built on specific distros and may require a newer `glibc` +than your host has. A typical failure looks like: + +``` +... /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38' not found ... +``` + +If you don't want to upgrade your host OS, you can run the existing OTP release +from `/opt/pleroma` inside an Ubuntu 24.04 container while keeping your existing +host paths (`/etc/pleroma`, `/var/lib/pleroma`, etc.). + +This folder provides a "shim" container + systemd unit. It is **not** the Pleroma +Docker image. + +## What this does + +- Builds a small Ubuntu 24.04 image with runtime libs (including newer `glibc`). +- Mounts your existing host release at `/opt/pleroma` into the container. +- Runs as the same UID/GID that owns `/opt/pleroma` on the host (via `gosu`). +- Optionally runs migrations automatically on container start. +- Uses `network_mode: host` so your existing config that talks to `localhost` + keeps working. + +## Setup (Debian/Ubuntu host) + +1. Install Docker Engine + the Docker Compose plugin. +2. Copy these files to a stable location (example: `/etc/pleroma/container`): + + ``` + mkdir -p /etc/pleroma/container + cp -a /opt/pleroma/installation/release-to-docker/* /etc/pleroma/container/ + ``` + +3. Build the shim image: + + ``` + cd /etc/pleroma/container + docker compose build + ``` + +4. Replace your systemd unit: + + ``` + cp /etc/pleroma/container/pleroma.service /etc/systemd/system/pleroma.service + systemctl daemon-reload + systemctl enable --now pleroma + journalctl -u pleroma -f + ``` + +## Running `pleroma_ctl` + +Since the host binary may not run on older `glibc`, run admin commands inside the +container: + +``` +cd /etc/pleroma/container +docker compose exec pleroma /opt/pleroma/bin/pleroma_ctl status +docker compose run --rm --no-deps pleroma /opt/pleroma/bin/pleroma_ctl migrate +``` + +## Configuration notes + +- Migrations run automatically by default. + - Set `PLEROMA_RUN_MIGRATIONS=0` in `docker-compose.yml` to disable. diff --git a/installation/release-to-docker/docker-compose.yml b/installation/release-to-docker/docker-compose.yml new file mode 100644 index 000000000..043803241 --- /dev/null +++ b/installation/release-to-docker/docker-compose.yml @@ -0,0 +1,22 @@ +services: + pleroma: + build: + context: . + dockerfile: Dockerfile + image: pleroma-host-release-wrapper:ubuntu24 + init: true + network_mode: host + restart: unless-stopped + environment: + HOME: /opt/pleroma + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + ELIXIR_ERL_OPTIONS: "+fnu" + # Set to 0 to skip running migrations on container start. + PLEROMA_RUN_MIGRATIONS: "1" + volumes: + # Existing OTP release installation (host) + - /opt/pleroma:/opt/pleroma:rw + # Existing config + uploads + static (host) + - /etc/pleroma:/etc/pleroma:rw + - /var/lib/pleroma:/var/lib/pleroma:rw diff --git a/installation/release-to-docker/pleroma-host-release-entrypoint.sh b/installation/release-to-docker/pleroma-host-release-entrypoint.sh new file mode 100644 index 000000000..b9c035678 --- /dev/null +++ b/installation/release-to-docker/pleroma-host-release-entrypoint.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +uid="${PLEROMA_UID:-}" +gid="${PLEROMA_GID:-}" + +if [[ -z "${uid}" || -z "${gid}" ]]; then + uid="$(stat -c '%u' /opt/pleroma)" + gid="$(stat -c '%g' /opt/pleroma)" +fi + +export HOME="${HOME:-/opt/pleroma}" + +if [[ "${PLEROMA_RUN_MIGRATIONS:-1}" != "0" && "${1:-}" == "/opt/pleroma/bin/pleroma" && "${2:-}" == "start" ]]; then + echo "Running migrations..." + gosu "${uid}:${gid}" /opt/pleroma/bin/pleroma_ctl migrate +fi + +exec gosu "${uid}:${gid}" "$@" diff --git a/installation/release-to-docker/pleroma.service b/installation/release-to-docker/pleroma.service new file mode 100644 index 000000000..d3833363c --- /dev/null +++ b/installation/release-to-docker/pleroma.service @@ -0,0 +1,16 @@ +[Unit] +Description=Pleroma social network (OTP release via Docker glibc shim) +After=network.target docker.service postgresql.service nginx.service +Requires=docker.service + +[Service] +Type=simple +WorkingDirectory=/etc/pleroma/container +ExecStart=/usr/bin/docker compose up --build --remove-orphans +ExecStop=/usr/bin/docker compose down +Restart=on-failure +RestartSec=5s +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target From 65456aed1279d989ffa46573e1e5b8f32893432e Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 1 Jan 2026 09:00:53 +0400 Subject: [PATCH 09/42] Release-to-Docker: Add unzip / curl to make updates work --- installation/release-to-docker/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/installation/release-to-docker/Dockerfile b/installation/release-to-docker/Dockerfile index ddff4570d..0ffeb5d5b 100644 --- a/installation/release-to-docker/Dockerfile +++ b/installation/release-to-docker/Dockerfile @@ -14,6 +14,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ postgresql-client \ ffmpeg imagemagick libimage-exiftool-perl \ libvips42t64 \ + unzip \ + curl \ && rm -rf /var/lib/apt/lists/* WORKDIR /opt/pleroma From 89ac0b8f0a71d271d32ee5151ed3e69a39091e71 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 1 Jan 2026 10:39:38 +0400 Subject: [PATCH 10/42] Add changelog --- changelog.d/release-to-docker.add | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/release-to-docker.add diff --git a/changelog.d/release-to-docker.add b/changelog.d/release-to-docker.add new file mode 100644 index 000000000..5fbf611a5 --- /dev/null +++ b/changelog.d/release-to-docker.add @@ -0,0 +1 @@ +Add instructions on how to run a release in docker, to make it easier to run on older distros. From 1085f6d7cd9fdf270464691cba47aa9bac89a888 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 5 Jan 2026 11:57:02 +0400 Subject: [PATCH 11/42] TransmogrifierTest: Add failing test for EmojiReact url encoding --- .../web/activity_pub/transmogrifier_test.exs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index 6dc4423fa..b54196a3c 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -759,6 +759,22 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do ) end + test "EmojiReact custom emoji urls are URI encoded" do + user = insert(:user, local: true) + note_activity = insert(:note_activity) + + {:ok, react_activity} = CommonAPI.react_with_emoji(note_activity.id, user, ":dinosaur:") + {:ok, data} = Transmogrifier.prepare_outgoing(react_activity.data) + + assert length(data["tag"]) == 1 + + tag = List.first(data["tag"]) + url = tag["icon"]["url"] + + assert url == "http://localhost:4001/emoji/dino%20walking.gif" + assert tag["id"] == "http://localhost:4001/emoji/dino%20walking.gif" + end + test "it prepares a quote post" do user = insert(:user) From 72fea0c901ea35c70ef5e672388b2a58d53f5556 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 5 Jan 2026 11:57:38 +0400 Subject: [PATCH 12/42] Emoji: Unify tag building, fix tests. --- lib/pleroma/emoji.ex | 12 ++++++++++++ lib/pleroma/web/activity_pub/builder.ex | 10 +--------- lib/pleroma/web/activity_pub/transmogrifier.ex | 17 +++-------------- .../transmogrifier/emoji_tag_building_test.exs | 2 +- .../views/notification_view_test.exs | 4 ++-- .../web/mastodon_api/views/status_view_test.exs | 4 ++-- .../emoji_reaction_controller_test.exs | 2 +- 7 files changed, 22 insertions(+), 29 deletions(-) diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index 21bcb0111..8c0257ef4 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -189,6 +189,18 @@ defmodule Pleroma.Emoji do def emoji_url(_), do: nil + def build_emoji_tag({name, url}) do + url = URI.encode(url) + + %{ + "icon" => %{"url" => "#{url}", "type" => "Image"}, + "name" => ":" <> name <> ":", + "type" => "Emoji", + "updated" => "1970-01-01T00:00:00Z", + "id" => url + } + end + def emoji_name_with_instance(name, url) do url = url |> URI.parse() |> Map.get(:host) "#{name}@#{url}" diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 046316024..4146fd6ab 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -64,15 +64,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do defp add_emoji_content(data, emoji, url) do tag = [ - %{ - "id" => url, - "type" => "Emoji", - "name" => Emoji.maybe_quote(emoji), - "icon" => %{ - "type" => "Image", - "url" => url - } - } + Emoji.build_emoji_tag({Emoji.maybe_strip_name(emoji), url}) ] data diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 6e04d95e6..a4b1ae349 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do """ @behaviour Pleroma.Web.ActivityPub.Transmogrifier.API alias Pleroma.Activity + alias Pleroma.Emoji alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Maps alias Pleroma.Object @@ -1005,32 +1006,20 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def take_emoji_tags(%User{emoji: emoji}) do emoji |> Map.to_list() - |> Enum.map(&build_emoji_tag/1) + |> Enum.map(&Emoji.build_emoji_tag/1) end # TODO: we should probably send mtime instead of unix epoch time for updated def add_emoji_tags(%{"emoji" => emoji} = object) do tags = object["tag"] || [] - out = Enum.map(emoji, &build_emoji_tag/1) + out = Enum.map(emoji, &Emoji.build_emoji_tag/1) Map.put(object, "tag", tags ++ out) end def add_emoji_tags(object), do: object - def build_emoji_tag({name, url}) do - url = URI.encode(url) - - %{ - "icon" => %{"url" => "#{url}", "type" => "Image"}, - "name" => ":" <> name <> ":", - "type" => "Emoji", - "updated" => "1970-01-01T00:00:00Z", - "id" => url - } - end - def set_conversation(object) do Map.put(object, "conversation", object["context"]) end diff --git a/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs b/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs index c632c199c..a6c5d689c 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs @@ -7,7 +7,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiTagBuildingTest do name = "hanapog" url = "https://misskey.local.live/emojis/hana pog.png" - tag = Transmogrifier.build_emoji_tag({name, url}) + tag = Pleroma.Emoji.build_emoji_tag({name, url}) assert tag["id"] == "https://misskey.local.live/emojis/hana%20pog.png" end diff --git a/test/pleroma/web/mastodon_api/views/notification_view_test.exs b/test/pleroma/web/mastodon_api/views/notification_view_test.exs index ce5ddd0fc..c8287bb79 100644 --- a/test/pleroma/web/mastodon_api/views/notification_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/notification_view_test.exs @@ -219,7 +219,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do data: %{ "reactions" => [ ["👍", [user.ap_id], nil], - ["dinosaur", [user.ap_id], "http://localhost:4001/emoji/dino walking.gif"] + ["dinosaur", [user.ap_id], "http://localhost:4001/emoji/dino%20walking.gif"] ] } ) @@ -243,7 +243,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do account: AccountView.render("show.json", %{user: other_user, for: user}), status: StatusView.render("show.json", %{activity: activity, for: user}), created_at: Utils.to_masto_date(notification.inserted_at), - emoji_url: "http://localhost:4001/emoji/dino walking.gif" + emoji_url: "http://localhost:4001/emoji/dino%20walking.gif" } test_notifications_rendering([notification], user, [expected]) 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 d7908886b..73cab817b 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -54,7 +54,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do count: 2, me: false, name: "dinosaur", - url: "http://localhost:4001/emoji/dino walking.gif", + url: "http://localhost:4001/emoji/dino%20walking.gif", account_ids: [other_user.id, user.id] }, %{name: "🍵", count: 1, me: false, url: nil, account_ids: [third_user.id]} @@ -70,7 +70,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do count: 2, me: true, name: "dinosaur", - url: "http://localhost:4001/emoji/dino walking.gif", + url: "http://localhost:4001/emoji/dino%20walking.gif", account_ids: [other_user.id, user.id] }, %{name: "🍵", count: 1, me: false, url: nil, account_ids: [third_user.id]} diff --git a/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs index 79b805aca..8fcdb233b 100644 --- a/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs @@ -100,7 +100,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do "name" => "dinosaur", "count" => 1, "me" => true, - "url" => "http://localhost:4001/emoji/dino walking.gif", + "url" => "http://localhost:4001/emoji/dino%20walking.gif", "account_ids" => [other_user.id] } ] From b552c2503925a63ec9315680982081385199612e Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 5 Jan 2026 12:12:21 +0400 Subject: [PATCH 13/42] Add changelog --- changelog.d/emoji-reaction-url-escape.fix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/emoji-reaction-url-escape.fix diff --git a/changelog.d/emoji-reaction-url-escape.fix b/changelog.d/emoji-reaction-url-escape.fix new file mode 100644 index 000000000..c3a1c8823 --- /dev/null +++ b/changelog.d/emoji-reaction-url-escape.fix @@ -0,0 +1 @@ +Encode custom emoji URLs in EmojiReact activity tags. From 33b8ccf21fbe1fb0f68455eb4858ba522a8a7d17 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 6 Jan 2026 15:12:49 +0400 Subject: [PATCH 14/42] Emoji: Handle more edge cases for local emoji with strange filenames. --- lib/pleroma/emoji.ex | 19 +++++++++++- lib/pleroma/emoji/formatter.ex | 3 +- lib/pleroma/web/activity_pub/builder.ex | 3 +- .../mastodon_api/views/custom_emoji_view.ex | 3 +- .../pleroma_api/views/bookmark_folder_view.ex | 3 +- .../emoji_tag_building_test.exs | 30 +++++++++++++++++-- 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index 8c0257ef4..9fead1010 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -11,6 +11,8 @@ defmodule Pleroma.Emoji do alias Pleroma.Emoji.Combinations alias Pleroma.Emoji.Loader + alias Pleroma.Utils.URIEncoding + alias Pleroma.Web.Endpoint require Logger @@ -189,8 +191,23 @@ defmodule Pleroma.Emoji do def emoji_url(_), do: nil + @spec local_url(String.t() | nil) :: String.t() | nil + def local_url(nil), do: nil + + def local_url("http" <> _ = url) do + URIEncoding.encode_url(url) + end + + def local_url("/" <> _ = path) do + URIEncoding.encode_url(Endpoint.url() <> path, bypass_decode: true) + end + + def local_url(path) when is_binary(path) do + local_url("/" <> path) + end + def build_emoji_tag({name, url}) do - url = URI.encode(url) + url = URIEncoding.encode_url(url) %{ "icon" => %{"url" => "#{url}", "type" => "Image"}, diff --git a/lib/pleroma/emoji/formatter.ex b/lib/pleroma/emoji/formatter.ex index 87fd35f13..9238bf912 100644 --- a/lib/pleroma/emoji/formatter.ex +++ b/lib/pleroma/emoji/formatter.ex @@ -5,7 +5,6 @@ defmodule Pleroma.Emoji.Formatter do alias Pleroma.Emoji alias Pleroma.HTML - alias Pleroma.Web.Endpoint alias Pleroma.Web.MediaProxy def emojify(text) do @@ -44,7 +43,7 @@ defmodule Pleroma.Emoji.Formatter do Emoji.get_all() |> Enum.filter(fn {emoji, %Emoji{}} -> String.contains?(text, ":#{emoji}:") end) |> Enum.reduce(%{}, fn {name, %Emoji{file: file}}, acc -> - Map.put(acc, name, to_string(URI.merge(Endpoint.url(), file))) + Map.put(acc, name, Emoji.local_url(file)) end) end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 4146fd6ab..167c769a9 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -17,7 +17,6 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI.ActivityDraft - alias Pleroma.Web.Endpoint require Pleroma.Constants @@ -105,7 +104,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do defp local_custom_emoji_react(data, emoji) do with %{file: path} = emojo <- Emoji.get(emoji) do - url = "#{Endpoint.url()}#{path}" + url = Emoji.local_url(path) add_emoji_content(data, emojo.code, url) else _ -> {:error, "Emoji does not exist"} diff --git a/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex index cd59ab946..6ef48e98a 100644 --- a/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex +++ b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex @@ -6,14 +6,13 @@ defmodule Pleroma.Web.MastodonAPI.CustomEmojiView do use Pleroma.Web, :view alias Pleroma.Emoji - alias Pleroma.Web.Endpoint def render("index.json", %{custom_emojis: custom_emojis}) do render_many(custom_emojis, __MODULE__, "show.json") end def render("show.json", %{custom_emoji: {shortcode, %Emoji{file: relative_url, tags: tags}}}) do - url = Endpoint.url() |> URI.merge(relative_url) |> to_string() + url = Emoji.local_url(relative_url) %{ "shortcode" => shortcode, diff --git a/lib/pleroma/web/pleroma_api/views/bookmark_folder_view.ex b/lib/pleroma/web/pleroma_api/views/bookmark_folder_view.ex index 12decb816..29028781f 100644 --- a/lib/pleroma/web/pleroma_api/views/bookmark_folder_view.ex +++ b/lib/pleroma/web/pleroma_api/views/bookmark_folder_view.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.PleromaAPI.BookmarkFolderView do alias Pleroma.BookmarkFolder alias Pleroma.Emoji - alias Pleroma.Web.Endpoint def render("show.json", %{folder: %BookmarkFolder{} = folder}) do %{ @@ -33,7 +32,7 @@ defmodule Pleroma.Web.PleromaAPI.BookmarkFolderView do emoji = Emoji.get(emoji) if emoji != nil do - Endpoint.url() |> URI.merge(emoji.file) |> to_string() + Emoji.local_url(emoji.file) else nil end diff --git a/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs b/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs index a6c5d689c..ff005b466 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs @@ -1,8 +1,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiTagBuildingTest do use Pleroma.DataCase, async: true - alias Pleroma.Web.ActivityPub.Transmogrifier - test "it encodes the id to be a valid url" do name = "hanapog" url = "https://misskey.local.live/emojis/hana pog.png" @@ -11,4 +9,32 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiTagBuildingTest do assert tag["id"] == "https://misskey.local.live/emojis/hana%20pog.png" end + + test "it does not double-encode already encoded urls" do + name = "hanapog" + url = "https://misskey.local.live/emojis/hana%20pog.png" + + tag = Pleroma.Emoji.build_emoji_tag({name, url}) + + assert tag["id"] == url + end + + test "it encodes disallowed path characters" do + name = "hanapog" + url = "https://example.com/emojis/hana[pog].png" + + tag = Pleroma.Emoji.build_emoji_tag({name, url}) + + assert tag["id"] == "https://example.com/emojis/hana%5Bpog%5D.png" + end + + test "local_url does not decode percent in filenames" do + url = Pleroma.Emoji.local_url("/emoji/hana%20pog.png") + + assert url == Pleroma.Web.Endpoint.url() <> "/emoji/hana%2520pog.png" + + tag = Pleroma.Emoji.build_emoji_tag({"hanapog", url}) + + assert tag["id"] == url + end end From 7ce7d4d319b822b2e29acf8fc6a60aa233c80375 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 6 Jan 2026 15:38:15 +0400 Subject: [PATCH 15/42] Linting --- lib/pleroma/web/activity_pub/transmogrifier.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index a4b1ae349..ba6f4030d 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -8,8 +8,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do """ @behaviour Pleroma.Web.ActivityPub.Transmogrifier.API alias Pleroma.Activity - alias Pleroma.Emoji alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Emoji alias Pleroma.Maps alias Pleroma.Object alias Pleroma.Object.Containment From b013ec9123db3f35be5fb497fcb715b933bd2375 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 7 Jan 2026 10:40:45 +0400 Subject: [PATCH 16/42] Emoji, AccountView, UtilController: Handle encoding of emoji --- lib/pleroma/emoji.ex | 3 ++- .../web/mastodon_api/views/account_view.ex | 15 ++++++++++++++- .../controllers/util_controller.ex | 11 +++++++++++ .../emoji_tag_building_test.exs | 6 ++++++ .../mastodon_api/views/account_view_test.exs | 19 +++++++++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index 9fead1010..2fe425955 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -199,7 +199,8 @@ defmodule Pleroma.Emoji do end def local_url("/" <> _ = path) do - URIEncoding.encode_url(Endpoint.url() <> path, bypass_decode: true) + path = URIEncoding.encode_url(path, bypass_parse: true, bypass_decode: true) + Endpoint.url() <> path end def local_url(path) when is_binary(path) do diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 03a2fc55a..d5f44676d 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do alias Pleroma.User alias Pleroma.UserNote alias Pleroma.UserRelationship + alias Pleroma.Utils.URIEncoding alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MediaProxy @@ -238,7 +239,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do emojis = Enum.map(user.emoji, fn {shortcode, raw_url} -> - url = MediaProxy.url(raw_url) + url = + raw_url + |> encode_emoji_url() + |> MediaProxy.url() %{ shortcode: shortcode, @@ -511,4 +515,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do # See https://git.pleroma.social/pleroma/pleroma-meta/-/issues/14 user.actor_type == "Service" || user.actor_type == "Group" end + + defp encode_emoji_url(nil), do: nil + defp encode_emoji_url("http" <> _ = url), do: URIEncoding.encode_url(url) + + defp encode_emoji_url("/" <> _ = path), + do: URIEncoding.encode_url(path, bypass_parse: true, bypass_decode: true) + + defp encode_emoji_url(path) when is_binary(path), + do: URIEncoding.encode_url(path, bypass_parse: true, bypass_decode: true) end diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index aeafa195d..45df3d31c 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.Config alias Pleroma.Emoji alias Pleroma.Healthcheck + alias Pleroma.Utils.URIEncoding alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.Auth.WrapperAuthenticator, as: Authenticator @@ -180,12 +181,22 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do def emoji(conn, _params) do emoji = Enum.reduce(Emoji.get_all(), %{}, fn {code, %Emoji{file: file, tags: tags}}, acc -> + file = encode_emoji_url(file) Map.put(acc, code, %{image_url: file, tags: tags}) end) json(conn, emoji) end + defp encode_emoji_url(nil), do: nil + defp encode_emoji_url("http" <> _ = url), do: URIEncoding.encode_url(url) + + defp encode_emoji_url("/" <> _ = path), + do: URIEncoding.encode_url(path, bypass_parse: true, bypass_decode: true) + + defp encode_emoji_url(path) when is_binary(path), + do: URIEncoding.encode_url(path, bypass_parse: true, bypass_decode: true) + def update_notification_settings(%{assigns: %{user: user}} = conn, params) do with {:ok, _} <- User.update_notification_settings(user, params) do json(conn, %{status: "success"}) diff --git a/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs b/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs index ff005b466..b91658ce4 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/emoji_tag_building_test.exs @@ -37,4 +37,10 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiTagBuildingTest do assert tag["id"] == url end + + test "local_url encodes question marks in filenames" do + url = Pleroma.Emoji.local_url("/emoji/file?name.png") + + assert url == Pleroma.Web.Endpoint.url() <> "/emoji/file%3Fname.png" + end end diff --git a/test/pleroma/web/mastodon_api/views/account_view_test.exs b/test/pleroma/web/mastodon_api/views/account_view_test.exs index 5d24c0e9f..bd151cc61 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -105,6 +105,25 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do assert expected == AccountView.render("show.json", %{user: user, skip_visibility_check: true}) end + test "encodes emoji urls in the emojis field" do + user = + insert(:user, + name: ":brackets: :percent:", + emoji: %{ + "brackets" => "/emoji/hana[pog].png", + "percent" => "/emoji/hana%20pog.png" + } + ) + + %{emojis: emojis} = + AccountView.render("show.json", %{user: user, skip_visibility_check: true}) + + emoji_urls = Map.new(emojis, &{&1.shortcode, &1.url}) + + assert emoji_urls["brackets"] == "/emoji/hana%5Bpog%5D.png" + assert emoji_urls["percent"] == "/emoji/hana%2520pog.png" + end + describe "roles and privileges" do setup do clear_config([:instance, :moderator_privileges], [:cofe, :only_moderator]) From 3d7d1197820aec9b10d8f004160a7cabbac87b34 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 7 Jan 2026 11:14:45 +0400 Subject: [PATCH 17/42] Linting --- lib/pleroma/web/twitter_api/controllers/util_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 45df3d31c..1c072f98a 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -11,8 +11,8 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.Config alias Pleroma.Emoji alias Pleroma.Healthcheck - alias Pleroma.Utils.URIEncoding alias Pleroma.User + alias Pleroma.Utils.URIEncoding alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.Auth.WrapperAuthenticator, as: Authenticator alias Pleroma.Web.CommonAPI From c07506ab6e8dfae3eb90ded86fcaf9146749090e Mon Sep 17 00:00:00 2001 From: floatingghost Date: Sat, 4 Feb 2023 20:51:17 +0000 Subject: [PATCH 18/42] paginate follow requests (#460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matches https://docs.joinmastodon.org/methods/follow_requests/#get mostly Co-authored-by: FloatingGhost Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/460 Signed-off-by: nicole mikołajczyk --- lib/pleroma/following_relationship.ex | 18 +++++++-------- lib/pleroma/user.ex | 10 +++++++-- .../operations/follow_request_operation.ex | 19 ++++++++++++++++ .../pleroma_follow_request_operation.ex | 21 +++++++++++++++++- .../controllers/follow_request_controller.ex | 15 ++++++++++--- .../web/mastodon_api/views/account_view.ex | 3 ++- .../controllers/follow_request_controller.ex | 10 ++++++++- .../follow_request_controller_test.exs | 22 +++++++++++++++++++ 8 files changed, 100 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 653feb32f..e27f78d33 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -147,24 +147,22 @@ defmodule Pleroma.FollowingRelationship do |> Repo.aggregate(:count, :id) end - def get_follow_requests(%User{id: id}) do + def get_follow_requests_query(%User{id: id}) do __MODULE__ - |> join(:inner, [r], f in assoc(r, :follower)) + |> join(:inner, [r], f in assoc(r, :follower), as: :follower) |> where([r], r.state == ^:follow_pending) |> where([r], r.following_id == ^id) - |> where([r, f], f.is_active == true) - |> select([r, f], f) - |> Repo.all() + |> where([r, follower: f], f.is_active == true) + |> select([r, follower: f], f) end - def get_outgoing_follow_requests(%User{id: id}) do + def get_outgoing_follow_requests_query(%User{id: id}) do __MODULE__ - |> join(:inner, [r], f in assoc(r, :following)) + |> join(:inner, [r], f in assoc(r, :following), as: :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() + |> where([r, following: f], f.is_active == true) + |> select([r, following: f], f) end def following?(%User{id: follower_id}, %User{id: followed_id}) do diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 904e9e056..75da41da9 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -287,8 +287,14 @@ defmodule Pleroma.User do defdelegate following(user), to: FollowingRelationship 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 get_follow_requests_query(user), to: FollowingRelationship + defdelegate get_outgoing_follow_requests_query(user), to: FollowingRelationship + + def get_follow_requests(user) do + get_follow_requests_query(user) + |> Repo.all() + end + defdelegate search(query, opts \\ []), to: User.Search @doc """ diff --git a/lib/pleroma/web/api_spec/operations/follow_request_operation.ex b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex index 72dc8b5fa..fbb997447 100644 --- a/lib/pleroma/web/api_spec/operations/follow_request_operation.ex +++ b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex @@ -19,6 +19,7 @@ defmodule Pleroma.Web.ApiSpec.FollowRequestOperation do summary: "Retrieve follow requests", security: [%{"oAuth" => ["read:follows", "follow"]}], operationId: "FollowRequestController.index", + parameters: pagination_params(), responses: %{ 200 => Operation.response("Array of Account", "application/json", %Schema{ @@ -62,4 +63,22 @@ defmodule Pleroma.Web.ApiSpec.FollowRequestOperation do required: true ) end + + defp pagination_params do + [ + Operation.parameter(:max_id, :query, :string, "Return items older than this ID"), + Operation.parameter( + :since_id, + :query, + :string, + "Return the oldest items newer than this ID" + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 20}, + "Maximum number of items to return. Will be ignored if it's more than 40" + ) + ] + end end 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 index b5a413490..5e2cf6a78 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_follow_request_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_follow_request_operation.ex @@ -17,7 +17,8 @@ defmodule Pleroma.Web.ApiSpec.PleromaFollowRequestOperation do tags: ["Follow requests"], summary: "Retrieve outgoing follow requests", security: [%{"oAuth" => ["read:follows", "follow"]}], - operationId: "PleromaFollowRequestController.outgoing", + operationId: "PleromaFollowRequestController.outgoing",, + parameters: pagination_params(), responses: %{ 200 => Operation.response("Array of Account", "application/json", %Schema{ @@ -28,4 +29,22 @@ defmodule Pleroma.Web.ApiSpec.PleromaFollowRequestOperation do } } end + + defp pagination_params do + [ + Operation.parameter(:max_id, :query, :string, "Return items older than this ID"), + Operation.parameter( + :since_id, + :query, + :string, + "Return the oldest items newer than this ID" + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 20}, + "Maximum number of items to return. Will be ignored if it's more than 40" + ) + ] + end end diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex index 6eee55d1b..a15029d92 100644 --- a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -5,6 +5,10 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do use Pleroma.Web, :controller + import Pleroma.Web.ControllerHelper, + only: [add_link_headers: 2] + + alias Pleroma.Pagination alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.Plugs.OAuthScopesPlug @@ -24,10 +28,15 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FollowRequestOperation @doc "GET /api/v1/follow_requests" - def index(%{assigns: %{user: followed}} = conn, _params) do - follow_requests = User.get_follow_requests(followed) + def index(%{assigns: %{user: followed}} = conn, params) do + follow_requests = + followed + |> User.get_follow_requests_query() + |> Pagination.fetch_paginated(params, :keyset, :follower) - render(conn, "index.json", for: followed, users: follow_requests, as: :user) + conn + |> add_link_headers(follow_requests) + |> render("index.json", for: followed, users: follow_requests, as: :user) end @doc "POST /api/v1/follow_requests/:id/authorize" diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index d5f44676d..988384489 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -360,7 +360,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do %User{id: user_id} ) do count = - User.get_follow_requests(user) + user + |> User.get_follow_requests() |> length() data diff --git a/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex b/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex index 656d477da..387309cd8 100644 --- a/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex @@ -5,6 +5,10 @@ defmodule Pleroma.Web.PleromaAPI.FollowRequestController do use Pleroma.Web, :controller + import Pleroma.Web.ControllerHelper, + only: [add_link_headers: 2] + + alias Pleroma.Pagination alias Pleroma.User alias Pleroma.Web.Plugs.OAuthScopesPlug @@ -18,10 +22,14 @@ defmodule Pleroma.Web.PleromaAPI.FollowRequestController do @doc "GET /api/v1/pleroma/outgoing_follow_requests" def outgoing(%{assigns: %{user: follower}} = conn, _params) do - follow_requests = User.get_outgoing_follow_requests(follower) + follow_requests = + follower + |> User.get_outgoing_follow_requests_query() + |> Pagination.fetch_paginated(params, :keyset, :follower) conn |> put_view(Pleroma.Web.MastodonAPI.FollowRequestView) + |> add_link_headers(follow_requests) |> render("index.json", for: follower, users: follow_requests, as: :user) end end diff --git a/test/pleroma/web/mastodon_api/controllers/follow_request_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/follow_request_controller_test.exs index b7c7ccae0..276866d75 100644 --- a/test/pleroma/web/mastodon_api/controllers/follow_request_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/follow_request_controller_test.exs @@ -10,6 +10,11 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do import Pleroma.Factory + defp extract_next_link_header(header) do + [_, next_link] = Regex.run(~r{<(?.*)>; rel="next"}, header) + next_link + end + describe "locked accounts" do setup do user = insert(:user, is_locked: true) @@ -31,6 +36,23 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do assert to_string(other_user.id) == relationship["id"] end + test "/api/v1/follow_requests paginates", %{user: user, conn: conn} do + for _ <- 1..21 do + other_user = insert(:user) + {:ok, _, _, _activity} = CommonAPI.follow(other_user, user) + {:ok, _, _} = User.follow(other_user, user, :follow_pending) + end + + conn = get(conn, "/api/v1/follow_requests") + assert length(json_response_and_validate_schema(conn, 200)) == 20 + assert [link_header] = get_resp_header(conn, "link") + assert link_header =~ "rel=\"next\"" + next_link = extract_next_link_header(link_header) + assert next_link =~ "/api/v1/follow_requests" + conn = get(conn, next_link) + assert length(json_response_and_validate_schema(conn, 200)) == 1 + end + test "/api/v1/follow_requests/:id/authorize works", %{user: user, conn: conn} do other_user = insert(:user) From 5068e31583d7adb05cc2e4141eace09752da56d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 14 Oct 2025 07:38:59 +0200 Subject: [PATCH 19/42] optimize follow_request_count for own account view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- lib/pleroma/web/mastodon_api/views/account_view.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 988384489..a7d994593 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -361,8 +361,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do ) do count = user - |> User.get_follow_requests() - |> length() + |> User.get_follow_requests_query() + |> Pleroma.Repo.aggregate(:count) data |> Kernel.put_in([:follow_requests_count], count) From 520bac27dcf97678d7aff59b692bbea8761ac9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 7 Jan 2026 15:34:31 +0100 Subject: [PATCH 20/42] 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/paginate-follow-requests.change | 1 + .../api_spec/operations/pleroma_follow_request_operation.ex | 2 +- .../web/pleroma_api/controllers/follow_request_controller.ex | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changelog.d/paginate-follow-requests.change diff --git a/changelog.d/paginate-follow-requests.change b/changelog.d/paginate-follow-requests.change new file mode 100644 index 000000000..1a88995b7 --- /dev/null +++ b/changelog.d/paginate-follow-requests.change @@ -0,0 +1 @@ +Paginate follow requests 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 index 5e2cf6a78..b3fa0457d 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_follow_request_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_follow_request_operation.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaFollowRequestOperation do tags: ["Follow requests"], summary: "Retrieve outgoing follow requests", security: [%{"oAuth" => ["read:follows", "follow"]}], - operationId: "PleromaFollowRequestController.outgoing",, + operationId: "PleromaFollowRequestController.outgoing", parameters: pagination_params(), responses: %{ 200 => diff --git a/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex b/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex index 387309cd8..c72b6941b 100644 --- a/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex @@ -21,11 +21,11 @@ defmodule Pleroma.Web.PleromaAPI.FollowRequestController do 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 + def outgoing(%{assigns: %{user: follower}} = conn, params) do follow_requests = follower |> User.get_outgoing_follow_requests_query() - |> Pagination.fetch_paginated(params, :keyset, :follower) + |> Pagination.fetch_paginated(params, :keyset, :following) conn |> put_view(Pleroma.Web.MastodonAPI.FollowRequestView) From 6a81e4fe003792ae95ba950dd2d3a3dcc2801e8a Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 7 Jan 2026 17:59:37 +0400 Subject: [PATCH 21/42] Tests: Syncify tests that mutate global state. --- test/mix/pleroma_test.exs | 2 +- test/mix/tasks/pleroma/app_test.exs | 2 +- test/mix/tasks/pleroma/database_test.exs | 2 +- test/pleroma/http_test.exs | 2 +- .../pleroma/repo/migrations/autolinker_to_linkify_test.exs | 2 +- .../web/activity_pub/mrf/remote_report_policy_test.exs | 2 +- test/pleroma/web/activity_pub/utils_test.exs | 2 +- test/pleroma/web/activity_pub/views/user_view_test.exs | 2 +- .../pleroma_api/views/chat_message_reference_view_test.exs | 7 ++++++- test/pleroma/web/rich_media/card_test.exs | 4 +++- test/pleroma/web/web_finger_test.exs | 3 ++- test/pleroma/workers/publisher_worker_test.exs | 2 +- test/pleroma/workers/reachability_worker_test.exs | 2 +- test/pleroma/workers/receiver_worker_test.exs | 2 +- test/pleroma/workers/remote_fetcher_worker_test.exs | 2 +- 15 files changed, 23 insertions(+), 15 deletions(-) diff --git a/test/mix/pleroma_test.exs b/test/mix/pleroma_test.exs index e362223b2..e8f801913 100644 --- a/test/mix/pleroma_test.exs +++ b/test/mix/pleroma_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.PleromaTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false import Mix.Pleroma setup_all do diff --git a/test/mix/tasks/pleroma/app_test.exs b/test/mix/tasks/pleroma/app_test.exs index 65245eadd..6156214c1 100644 --- a/test/mix/tasks/pleroma/app_test.exs +++ b/test/mix/tasks/pleroma/app_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.AppTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false setup_all do Mix.shell(Mix.Shell.Process) diff --git a/test/mix/tasks/pleroma/database_test.exs b/test/mix/tasks/pleroma/database_test.exs index 38ed096ae..19df17b60 100644 --- a/test/mix/tasks/pleroma/database_test.exs +++ b/test/mix/tasks/pleroma/database_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.DatabaseTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false use Oban.Testing, repo: Pleroma.Repo alias Pleroma.Activity diff --git a/test/pleroma/http_test.exs b/test/pleroma/http_test.exs index 7b6847cf9..e673e6591 100644 --- a/test/pleroma/http_test.exs +++ b/test/pleroma/http_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTPTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false use Pleroma.Tests.Helpers import Tesla.Mock diff --git a/test/pleroma/repo/migrations/autolinker_to_linkify_test.exs b/test/pleroma/repo/migrations/autolinker_to_linkify_test.exs index 99522994a..c15967eee 100644 --- a/test/pleroma/repo/migrations/autolinker_to_linkify_test.exs +++ b/test/pleroma/repo/migrations/autolinker_to_linkify_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Repo.Migrations.AutolinkerToLinkifyTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false import Pleroma.Factory import Pleroma.Tests.Helpers alias Pleroma.ConfigDB diff --git a/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs b/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs index 8d2a6b4fa..270c650e0 100644 --- a/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs @@ -1,5 +1,5 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false alias Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy diff --git a/test/pleroma/web/activity_pub/utils_test.exs b/test/pleroma/web/activity_pub/utils_test.exs index a48639c38..f162f3684 100644 --- a/test/pleroma/web/activity_pub/utils_test.exs +++ b/test/pleroma/web/activity_pub/utils_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.UtilsTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false alias Pleroma.Activity alias Pleroma.Object alias Pleroma.Repo diff --git a/test/pleroma/web/activity_pub/views/user_view_test.exs b/test/pleroma/web/activity_pub/views/user_view_test.exs index 7ac5f7c0f..a3f807ca9 100644 --- a/test/pleroma/web/activity_pub/views/user_view_test.exs +++ b/test/pleroma/web/activity_pub/views/user_view_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.UserViewTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false import Pleroma.Factory alias Pleroma.User diff --git a/test/pleroma/web/pleroma_api/views/chat_message_reference_view_test.exs b/test/pleroma/web/pleroma_api/views/chat_message_reference_view_test.exs index c78c03aba..8c3682798 100644 --- a/test/pleroma/web/pleroma_api/views/chat_message_reference_view_test.exs +++ b/test/pleroma/web/pleroma_api/views/chat_message_reference_view_test.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageReferenceViewTest do alias Pleroma.NullCache - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false alias Pleroma.Chat alias Pleroma.Chat.MessageReference @@ -18,6 +18,11 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageReferenceViewTest do import Mox import Pleroma.Factory + setup do + Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache) + :ok + end + setup do: clear_config([:rich_media, :enabled], true) test "it displays a chat message" do diff --git a/test/pleroma/web/rich_media/card_test.exs b/test/pleroma/web/rich_media/card_test.exs index c69f85323..723446c86 100644 --- a/test/pleroma/web/rich_media/card_test.exs +++ b/test/pleroma/web/rich_media/card_test.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Web.RichMedia.CardTest do use Oban.Testing, repo: Pleroma.Repo - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false alias Pleroma.Tests.ObanHelpers alias Pleroma.UnstubbedConfigMock, as: ConfigMock @@ -19,6 +19,8 @@ defmodule Pleroma.Web.RichMedia.CardTest do setup do mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache) + ConfigMock |> stub_with(Pleroma.Test.StaticConfig) diff --git a/test/pleroma/web/web_finger_test.exs b/test/pleroma/web/web_finger_test.exs index eb03c736e..da10abdd2 100644 --- a/test/pleroma/web/web_finger_test.exs +++ b/test/pleroma/web/web_finger_test.exs @@ -3,12 +3,13 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.WebFingerTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false alias Pleroma.Web.WebFinger import Pleroma.Factory import Tesla.Mock setup do + Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache) mock(fn env -> apply(HttpRequestMock, :request, [env]) end) :ok end diff --git a/test/pleroma/workers/publisher_worker_test.exs b/test/pleroma/workers/publisher_worker_test.exs index ca432d9bf..4519864a5 100644 --- a/test/pleroma/workers/publisher_worker_test.exs +++ b/test/pleroma/workers/publisher_worker_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.PublisherWorkerTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false use Oban.Testing, repo: Pleroma.Repo import Pleroma.Factory diff --git a/test/pleroma/workers/reachability_worker_test.exs b/test/pleroma/workers/reachability_worker_test.exs index 4854aff77..c641ccf65 100644 --- a/test/pleroma/workers/reachability_worker_test.exs +++ b/test/pleroma/workers/reachability_worker_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.ReachabilityWorkerTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false use Oban.Testing, repo: Pleroma.Repo import Mock diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index 7f4789f91..12abc1a27 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.ReceiverWorkerTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false use Oban.Testing, repo: Pleroma.Repo import Mock diff --git a/test/pleroma/workers/remote_fetcher_worker_test.exs b/test/pleroma/workers/remote_fetcher_worker_test.exs index 6eb6932cb..a3b900a9b 100644 --- a/test/pleroma/workers/remote_fetcher_worker_test.exs +++ b/test/pleroma/workers/remote_fetcher_worker_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.RemoteFetcherWorkerTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase, async: false use Oban.Testing, repo: Pleroma.Repo alias Pleroma.Workers.RemoteFetcherWorker From 7283d4f9beaad241b0c039938131f5b2ccffb74e Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 8 Jan 2026 13:39:55 +0400 Subject: [PATCH 22/42] Config: Make streaming in tests actually synchronous --- config/test.exs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/test.exs b/config/test.exs index 4da2b4f57..9652b7d4b 100644 --- a/config/test.exs +++ b/config/test.exs @@ -102,7 +102,6 @@ config :pleroma, :http, send_user_agent: false rum_enabled = System.get_env("RUM_ENABLED") == "true" config :pleroma, :database, rum_enabled: rum_enabled -IO.puts("RUM enabled: #{rum_enabled}") config :joken, default_signer: "yU8uHKq+yyAkZ11Hx//jcdacWc8yQ1bxAAGrplzB0Zwwjkp35v0RK9SO8WTPr6QZ" @@ -192,7 +191,7 @@ config :pleroma, Pleroma.Application, streamer_registry: false, test_http_pools: true -config :pleroma, Pleroma.Web.Streaming, sync_streaming: true +config :pleroma, Pleroma.Web.Streamer, sync_streaming: true config :pleroma, Pleroma.Uploaders.Uploader, timeout: 1_000 @@ -207,8 +206,9 @@ config :pleroma, Pleroma.User.Backup, tempdir: "test/tmp" if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" -else - IO.puts( - "You may want to create test.secret.exs to declare custom database connection parameters." - ) end + +# Avoid noisy shutdown logs from os_mon during tests. +config :os_mon, + start_cpu_sup: false, + start_memsup: false From 02185ec7114b5ea0faba79dee2cc427f122421b9 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 8 Jan 2026 13:40:25 +0400 Subject: [PATCH 23/42] StripLocation, ReadDescription: Silence noisy errors. --- lib/mix/tasks/pleroma/config.ex | 8 ++++- .../filter/exiftool/read_description.ex | 32 +++++++++++-------- .../upload/filter/exiftool/strip_location.ex | 5 +-- .../filter/exiftool/strip_location_test.exs | 4 +-- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 8b3b2f18b..834b4fe14 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -330,7 +330,13 @@ defmodule Mix.Tasks.Pleroma.Config do |> Enum.each(&write_and_delete(&1, file, opts[:delete])) :ok = File.close(file) - System.cmd("mix", ["format", path]) + + # Ensure `mix format` runs in the same env as the current task and doesn't + # emit config-time stderr noise (e.g. dev secret warnings) into `mix test`. + System.cmd("mix", ["format", path], + env: [{"MIX_ENV", to_string(Mix.env())}], + stderr_to_stdout: true + ) end defp config_header, do: "import Config\r\n\r\n" diff --git a/lib/pleroma/upload/filter/exiftool/read_description.ex b/lib/pleroma/upload/filter/exiftool/read_description.ex index 8c1ed82f8..8283b8643 100644 --- a/lib/pleroma/upload/filter/exiftool/read_description.ex +++ b/lib/pleroma/upload/filter/exiftool/read_description.ex @@ -29,22 +29,26 @@ defmodule Pleroma.Upload.Filter.Exiftool.ReadDescription do do: current_description defp read_when_empty(_, file, tag) do - try do - {tag_content, 0} = - System.cmd("exiftool", ["-b", "-s3", tag, file], - stderr_to_stdout: false, - parallelism: true - ) + if File.exists?(file) do + try do + {tag_content, 0} = + System.cmd("exiftool", ["-m", "-b", "-s3", tag, file], + stderr_to_stdout: false, + parallelism: true + ) - tag_content = String.trim(tag_content) + tag_content = String.trim(tag_content) - if tag_content != "" and - String.length(tag_content) <= - Pleroma.Config.get([:instance, :description_limit]), - do: tag_content, - else: nil - rescue - _ in ErlangError -> nil + if tag_content != "" and + String.length(tag_content) <= + Pleroma.Config.get([:instance, :description_limit]), + do: tag_content, + else: nil + rescue + _ in ErlangError -> nil + end + else + nil end end end diff --git a/lib/pleroma/upload/filter/exiftool/strip_location.ex b/lib/pleroma/upload/filter/exiftool/strip_location.ex index 1744a286d..23346d234 100644 --- a/lib/pleroma/upload/filter/exiftool/strip_location.ex +++ b/lib/pleroma/upload/filter/exiftool/strip_location.ex @@ -16,11 +16,12 @@ defmodule Pleroma.Upload.Filter.Exiftool.StripLocation do def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do try do - case System.cmd("exiftool", ["-overwrite_original", "-gps:all=", "-png:all=", file], + case System.cmd("exiftool", ["-m", "-overwrite_original", "-gps:all=", "-png:all=", file], + stderr_to_stdout: true, parallelism: true ) do {_response, 0} -> {:ok, :filtered} - {error, 1} -> {:error, error} + {error, _} -> {:error, error} end rescue e in ErlangError -> diff --git a/test/pleroma/upload/filter/exiftool/strip_location_test.exs b/test/pleroma/upload/filter/exiftool/strip_location_test.exs index 4dcd4dce3..485060215 100644 --- a/test/pleroma/upload/filter/exiftool/strip_location_test.exs +++ b/test/pleroma/upload/filter/exiftool/strip_location_test.exs @@ -25,8 +25,8 @@ defmodule Pleroma.Upload.Filter.Exiftool.StripLocationTest do assert Filter.Exiftool.StripLocation.filter(upload) == {:ok, :filtered} - {exif_original, 0} = System.cmd("exiftool", ["test/fixtures/DSCN0010.#{type}"]) - {exif_filtered, 0} = System.cmd("exiftool", ["test/fixtures/DSCN0010_tmp.#{type}"]) + {exif_original, 0} = System.cmd("exiftool", ["-m", "test/fixtures/DSCN0010.#{type}"]) + {exif_filtered, 0} = System.cmd("exiftool", ["-m", "test/fixtures/DSCN0010_tmp.#{type}"]) assert String.match?(exif_original, ~r/GPS/) refute String.match?(exif_filtered, ~r/GPS/) From b2be7d48bc5129fa60ebb6db2c77fb13115253fd Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 8 Jan 2026 13:40:43 +0400 Subject: [PATCH 24/42] Mix: Silence migrations --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index ac998e9ee..dff40c675 100644 --- a/mix.exs +++ b/mix.exs @@ -230,7 +230,7 @@ defmodule Pleroma.Mixfile do "ecto.rollback": ["pleroma.ecto.rollback"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], - test: ["ecto.create --quiet", "ecto.migrate", "test --warnings-as-errors"], + test: ["ecto.create --quiet", "pleroma.ecto.migrate --quiet", "test --warnings-as-errors"], docs: ["pleroma.docs", "docs"], analyze: ["credo --strict --only=warnings,todo,fixme,consistency,readability"], copyright: &add_copyright/1, From 343e42126a1ede3578c643c5d8a05ca92eb30119 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 8 Jan 2026 13:40:51 +0400 Subject: [PATCH 25/42] Add changelog --- changelog.d/reduce-flaky-tests.skip | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/reduce-flaky-tests.skip diff --git a/changelog.d/reduce-flaky-tests.skip b/changelog.d/reduce-flaky-tests.skip new file mode 100644 index 000000000..0375762c0 --- /dev/null +++ b/changelog.d/reduce-flaky-tests.skip @@ -0,0 +1 @@ +Reduce the number of flaky tests by making them sync if they affect the global state, and silence noisy test output. From 033083d1d18dc038f5b6c13596f403272670c5de Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 8 Jan 2026 14:06:24 +0400 Subject: [PATCH 26/42] Streamer: Fix Marker streaming bug, fix caching in tests. --- lib/pleroma/web/streamer.ex | 2 +- test/pleroma/web/streamer_test.exs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index aba42ee78..120e94751 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -300,7 +300,7 @@ defmodule Pleroma.Web.Streamer do end) end - defp do_stream("user", item) do + defp do_stream("user", %Activity{} = item) do Logger.debug("Trying to push to users") recipient_topics = diff --git a/test/pleroma/web/streamer_test.exs b/test/pleroma/web/streamer_test.exs index 096ca2d2a..f5008f6b9 100644 --- a/test/pleroma/web/streamer_test.exs +++ b/test/pleroma/web/streamer_test.exs @@ -19,7 +19,12 @@ defmodule Pleroma.Web.StreamerTest do @moduletag needs_streamer: true, capture_log: true - setup do: clear_config([:instance, :skip_thread_containment]) + setup do + clear_config([:instance, :skip_thread_containment]) + Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache) + + :ok + end describe "get_topic/_ (unauthenticated)" do test "allows no stream" do From af4bed50e608bce0bc6aec467ddc3e3992b4947b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 7 Jan 2026 20:41:02 +0100 Subject: [PATCH 27/42] Add Oban Web and upgrade LiveView, plug --- lib/pleroma/web/router.ex | 2 ++ mix.exs | 3 ++- mix.lock | 6 ++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index a0fa7b3e3..43b74c357 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.Router do use Pleroma.Web, :router import Phoenix.LiveDashboard.Router + import Oban.Web.Router pipeline :accepts_html do plug(:accepts, ["html"]) @@ -1045,6 +1046,7 @@ defmodule Pleroma.Web.Router do scope "/" do pipe_through([:pleroma_html, :authenticate, :require_admin]) live_dashboard("/phoenix/live_dashboard", additional_pages: [oban: Oban.LiveDashboard]) + oban_dashboard("/pleroma/oban") end # Test-only routes needed to test action dispatching and plug chain execution diff --git a/mix.exs b/mix.exs index dff40c675..95783cf4e 100644 --- a/mix.exs +++ b/mix.exs @@ -130,7 +130,7 @@ defmodule Pleroma.Mixfile do {:ecto_enum, "~> 1.4"}, {:postgrex, ">= 0.20.0"}, {:phoenix_html, "~> 3.3"}, - {:phoenix_live_view, "~> 0.19.0"}, + {:phoenix_live_view, "~> 1.1.0"}, {:phoenix_live_dashboard, "~> 0.8.0"}, {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 1.0"}, @@ -140,6 +140,7 @@ defmodule Pleroma.Mixfile do {:oban_plugins_lazarus, git: "https://git.pleroma.social/pleroma/elixir-libraries/oban_plugins_lazarus.git", ref: "e49fc355baaf0e435208bf5f534d31e26e897711"}, + {:oban_web, "~> 2.11"}, {:gettext, "~> 0.20"}, {:bcrypt_elixir, "~> 2.2"}, {:trailing_format_plug, "~> 0.0.7"}, diff --git a/mix.lock b/mix.lock index b8c9e240d..a2c5e1ec7 100644 --- a/mix.lock +++ b/mix.lock @@ -95,7 +95,9 @@ "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "oban": {:hex, :oban, "2.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"}, "oban_live_dashboard": {:hex, :oban_live_dashboard, "0.1.1", "8aa4ceaf381c818f7d5c8185cc59942b8ac82ef0cf559881aacf8d3f8ac7bdd3", [:mix], [{:oban, "~> 2.15", [hex: :oban, repo: "hexpm", optional: false]}, {:phoenix_live_dashboard, "~> 0.7", [hex: :phoenix_live_dashboard, repo: "hexpm", optional: false]}], "hexpm", "16dc4ce9c9a95aa2e655e35ed4e675652994a8def61731a18af85e230e1caa63"}, + "oban_met": {:hex, :oban_met, "1.0.5", "bb633ab06448dab2ef9194f6688d33b3d07fc3f2ad793a1a08f4dfbb2cc9fe50", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "64664d50805bbfd3903aeada1f3c39634652a87844797ee400b0bcc95a28f5ea"}, "oban_plugins_lazarus": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/oban_plugins_lazarus.git", "e49fc355baaf0e435208bf5f534d31e26e897711", [ref: "e49fc355baaf0e435208bf5f534d31e26e897711"]}, + "oban_web": {:hex, :oban_web, "2.11.6", "53933cb4253c4d9f1098ee311c06f07935259f0e564dcf2d66bae4cc98e317fe", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.0", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "576d94b705688c313694c2c114ca21aa0f8f2ad1b9ca45c052c5ba316d3e8d10"}, "octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"}, "open_api_spex": {:hex, :open_api_spex, "3.22.0", "fbf90dc82681dc042a4ee79853c8e989efbba73d9e87439085daf849bbf8bc20", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "dd751ddbdd709bb4a5313e9a24530da6e66594773c7242a0c2592cbd9f589063"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, @@ -105,12 +107,12 @@ "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "0.19.5", "6e730595e8e9b8c5da230a814e557768828fd8dfeeb90377d2d8dbb52d4ec00a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b2eaa0dd3cfb9bd7fb949b88217df9f25aed915e986a28ad5c8a0d054e7ca9d3"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, - "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"}, From ac6ec02725c17741d93e166ef7b468ccc2f19072 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 7 Jan 2026 21:12:28 +0100 Subject: [PATCH 28/42] changelog --- changelog.d/oban-web.add | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/oban-web.add diff --git a/changelog.d/oban-web.add b/changelog.d/oban-web.add new file mode 100644 index 000000000..c59e2ebca --- /dev/null +++ b/changelog.d/oban-web.add @@ -0,0 +1 @@ +Added Oban Web dashboard located at /pleroma/oban From 421187dbfa4b7b5fe5e758666055ef417202110c Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 8 Jan 2026 00:28:29 +0100 Subject: [PATCH 29/42] Remove /pleroma/oban and /phoenix/live_dashboard from API routes This is needed to prevent admin frontend overrides from misbehaving when overriding AdminFE located at /pleroma/admin, since API routes are interpreted as the first portion of their full path, ie: /api/v1/pleroma/admin -> /api --- lib/pleroma/web/router.ex | 6 ++++++ test/pleroma/web/plugs/frontend_static_plug_test.exs | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 43b74c357..df1812bd5 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -1092,9 +1092,15 @@ defmodule Pleroma.Web.Router do options("/*path", RedirectController, :empty) end + # /pleroma/oban/* needs to get filtered out from api routes for frontend configuration + # to not drop admin overrides for /pleroma/admin. + # Also removing /phoenix since it is not an API route + @non_api_routes ["/phoenix/live_dashboard", "/pleroma/oban"] + def get_api_routes do Phoenix.Router.routes(__MODULE__) |> Enum.reject(fn r -> r.plug == Pleroma.Web.Fallback.RedirectController end) + |> Enum.reject(fn r -> String.starts_with?(r.path, @non_api_routes) end) |> Enum.map(fn r -> r.path |> String.split("/", trim: true) diff --git a/test/pleroma/web/plugs/frontend_static_plug_test.exs b/test/pleroma/web/plugs/frontend_static_plug_test.exs index cbe200738..e1e331c06 100644 --- a/test/pleroma/web/plugs/frontend_static_plug_test.exs +++ b/test/pleroma/web/plugs/frontend_static_plug_test.exs @@ -106,7 +106,6 @@ defmodule Pleroma.Web.Plugs.FrontendStaticPlugTest do "manifest.json", "auth", "proxy", - "phoenix", "test", "user_exists", "check_password" From f16dad287952532cf125c337bb4f66b9737be30f Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 8 Jan 2026 22:23:38 +0100 Subject: [PATCH 30/42] Docs: Add admin documentation for LiveDashboard and Oban Web --- docs/administration/dashboards.md | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/administration/dashboards.md diff --git a/docs/administration/dashboards.md b/docs/administration/dashboards.md new file mode 100644 index 000000000..8e8987633 --- /dev/null +++ b/docs/administration/dashboards.md @@ -0,0 +1,47 @@ +# Dashboards + +Pleroma comes with two types of backend dashboards viewable to instance administrators: + +* [Phoenix LiveDashboard](https://hexdocs.pm/phoenix_live_dashboard/Phoenix.LiveDashboard.html) - A general system oriented dashboard for viewing statistics about Pleroma resource consumption, Pleroma's database and Pleroma's job processor (Oban). +* [Oban Web](https://hexdocs.pm/oban_web/overview.html) - A dashboard specific to Oban for viewing Oban statistics, managing jobs and job queues. + +!!! note + Both dashboards require working Websockets. + If your browser or web server don't support Websockets, both dashboards either won't update or will not display all information. + +## Phoenix LiveDashboard + +Instance administrators can access this dashboard at `/phoenix/live_dashboard`, giving a simple overview of software versions including Erlang and Elixir versions, instance uptime and resource consumption. + +This dashboard gives insights into the current state of the BEAM VM running Pleroma code and database statistics including basic diagnostics. +It can be useful for troubleshooting of some issues namely regarding database performance. + +### Relevant dashboard tabs + +* Home - A general overview of system information including software versions, uptime and memory BEAM memory consumption. +* OS Data - Information about the OS and system such as CPU load, memory usage and disk usage. +* Ecto Stats - Information about the Pleroma database. + - Diagnose - Basic database diagnostics, including a `bloat` warning when an index or a table have excessive bloat, which can lead to bad database performance. + - Bloat - A table showing size of "bloat" (unused wasted space) in database tables and indexes. Very high bloat size in the `activities` and `objects` tables can lead to bad performance especially on slower disks such as on most VPS providers. + - Db settings - A small list of PostgreSQL settings mostly relevant to database performance. + - Total table size - Shows sizes of all database tables including indexes sorted by size, useful for quickly checking overall database size. + - Long running queries - A list of of slow database queries and their duration. Multiple entries with duration in multiple seconds indicate a slowly performing database. +* Oban - Shows a list of all Oban jobs. + +!!! note + The DB bloat warning for `index 'oban_jobs::oban_jobs_args_index'` in Ecto Stats can be safely ignored. + +## Oban Web + +An advanced dashboard and management console viewable to instance administrators specifically for Oban, Pleroma's job processor. +It allows managing jobs, including force retrying failed jobs and job deletion. +It can be accessed at `/pleroma/oban`. + +!!! danger + This dashboard is very powerful! If you are unsure what a certain feature does, don't use it. + Changing individual queue state/settings in the "Queues" view is heavily discouraged. + +* Shows a real time chart of either a number of executed jobs, or job execution/wait time per a given time frame and the state/queue/worker. +* Shows a list of jobs in each state, their argument, number of attempts and execution/scheduled time. +* Selecting one or multiple jobs in the list allows performing actions like canceling/deleting and retrying. +* Clicking on a job shows a detailed view including the full argument, when it was inserted, information about its attempts, and performing actions on it. From f6c410b06c9864a884ee9ab622f533a9340694d1 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 9 Jan 2026 11:41:12 +0100 Subject: [PATCH 31/42] Move LiveDashboard to /pleroma/live_dashboard --- changelog.d/phoenix-livedashboard-move.change | 1 + docs/administration/dashboards.md | 2 +- lib/pleroma/web/fallback/redirect_controller.ex | 5 +++++ lib/pleroma/web/router.ex | 8 ++++---- test/pleroma/web/fallback_test.exs | 4 ++++ 5 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 changelog.d/phoenix-livedashboard-move.change diff --git a/changelog.d/phoenix-livedashboard-move.change b/changelog.d/phoenix-livedashboard-move.change new file mode 100644 index 000000000..116b1523a --- /dev/null +++ b/changelog.d/phoenix-livedashboard-move.change @@ -0,0 +1 @@ +Moved Phoenix LiveDashboard to /pleroma/live_dashboard diff --git a/docs/administration/dashboards.md b/docs/administration/dashboards.md index 8e8987633..b95e0fac0 100644 --- a/docs/administration/dashboards.md +++ b/docs/administration/dashboards.md @@ -11,7 +11,7 @@ Pleroma comes with two types of backend dashboards viewable to instance administ ## Phoenix LiveDashboard -Instance administrators can access this dashboard at `/phoenix/live_dashboard`, giving a simple overview of software versions including Erlang and Elixir versions, instance uptime and resource consumption. +Instance administrators can access this dashboard at `/pleroma/live_dashboard`, giving a simple overview of software versions including Erlang and Elixir versions, instance uptime and resource consumption. This dashboard gives insights into the current state of the BEAM VM running Pleroma code and database statistics including basic diagnostics. It can be useful for troubleshooting of some issues namely regarding database performance. diff --git a/lib/pleroma/web/fallback/redirect_controller.ex b/lib/pleroma/web/fallback/redirect_controller.ex index 60fc15b9e..d75a95fb3 100644 --- a/lib/pleroma/web/fallback/redirect_controller.ex +++ b/lib/pleroma/web/fallback/redirect_controller.ex @@ -29,6 +29,11 @@ defmodule Pleroma.Web.Fallback.RedirectController do ) end + def live_dashboard(conn, _params) do + conn + |> redirect(to: "/pleroma/live_dashboard") + end + def redirector(conn, _params, code \\ 200) do {:ok, index_content} = File.read(index_file_path(conn)) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index df1812bd5..008f48575 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -1045,7 +1045,7 @@ defmodule Pleroma.Web.Router do scope "/" do pipe_through([:pleroma_html, :authenticate, :require_admin]) - live_dashboard("/phoenix/live_dashboard", additional_pages: [oban: Oban.LiveDashboard]) + live_dashboard("/pleroma/live_dashboard", additional_pages: [oban: Oban.LiveDashboard]) oban_dashboard("/pleroma/oban") end @@ -1087,15 +1087,15 @@ defmodule Pleroma.Web.Router do get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta) match(:*, "/api/pleroma/*path", LegacyPleromaApiRerouterPlug, []) get("/api/*path", RedirectController, :api_not_implemented) + get("/phoenix/live_dashboard", RedirectController, :live_dashboard) get("/*path", RedirectController, :redirector_with_preload) options("/*path", RedirectController, :empty) end - # /pleroma/oban/* needs to get filtered out from api routes for frontend configuration + # /pleroma/{phoenix,oban}/* need to get filtered out from api routes for frontend configuration # to not drop admin overrides for /pleroma/admin. - # Also removing /phoenix since it is not an API route - @non_api_routes ["/phoenix/live_dashboard", "/pleroma/oban"] + @non_api_routes ["/pleroma/live_dashboard", "/pleroma/oban"] def get_api_routes do Phoenix.Router.routes(__MODULE__) diff --git a/test/pleroma/web/fallback_test.exs b/test/pleroma/web/fallback_test.exs index 9184cf8f1..6d0ba3d2a 100644 --- a/test/pleroma/web/fallback_test.exs +++ b/test/pleroma/web/fallback_test.exs @@ -77,6 +77,10 @@ defmodule Pleroma.Web.FallbackTest do assert redirected_to(get(conn, "/pleroma/admin")) =~ "/pleroma/admin/" end + test "GET /phoenix/live_dashboard -> /pleroma/live_dashboard", %{conn: conn} do + assert redirected_to(get(conn, "/phoenix/live_dashboard")) =~ "/pleroma/live_dashboard" + end + test "OPTIONS /*path", %{conn: conn} do assert conn |> options("/foo") From 519ef4be5efd7abb664b1f4368883219dff871c3 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 14 Jan 2026 02:37:55 +0100 Subject: [PATCH 32/42] mix: upgrade vix from "~> 0.26.0" to "~> 0.36" Dropping the last zero should allow to get 0.x updates rather than only 0.36.x updates. Fixes: https://git.pleroma.social/pleroma/pleroma/-/issues/3393 --- changelog.d/vix-0.36.0.fix | 1 + mix.exs | 2 +- mix.lock | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 changelog.d/vix-0.36.0.fix diff --git a/changelog.d/vix-0.36.0.fix b/changelog.d/vix-0.36.0.fix new file mode 100644 index 000000000..43a8dd8f8 --- /dev/null +++ b/changelog.d/vix-0.36.0.fix @@ -0,0 +1 @@ +Fix compilation with vips-8.18.0 with bumping to vix 0.36.0 diff --git a/mix.exs b/mix.exs index 95783cf4e..a4415fddc 100644 --- a/mix.exs +++ b/mix.exs @@ -193,7 +193,7 @@ defmodule Pleroma.Mixfile do {:majic, "~> 1.0"}, {:open_api_spex, "~> 3.16"}, {:ecto_psql_extras, "~> 0.8"}, - {:vix, "~> 0.26.0"}, + {:vix, "~> 0.36"}, {:elixir_make, "~> 0.7.7", override: true}, {:blurhash, "~> 0.1.0", hex: :rinpatch_blurhash}, {:exile, "~> 0.10.0"}, diff --git a/mix.lock b/mix.lock index a2c5e1ec7..8e1f684dc 100644 --- a/mix.lock +++ b/mix.lock @@ -152,7 +152,7 @@ "ueberauth": {:hex, :ueberauth, "0.10.8", "ba78fbcbb27d811a6cd06ad851793aaf7d27c3b30c9e95349c2c362b344cd8f0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f2d3172e52821375bccb8460e5fa5cb91cfd60b19b636b6e57e9759b6f8c10c1"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, - "vix": {:hex, :vix, "0.26.0", "027f10b6969b759318be84bd0bd8c88af877445e4e41cf96a0460392cea5399c", [:make, :mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "71b0a79ae7f199cacfc8e679b0e4ba25ee47dc02e182c5b9097efb29fbe14efd"}, + "vix": {:hex, :vix, "0.36.0", "3132dc065beda06dab1895a53d8c852d8e6a5bbca375c609435e968b1290e113", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "92f912b4e90c453f92942742105bcdb367ad53406759da251bd2e587e33f4134"}, "web_push_encryption": {:hex, :web_push_encryption, "0.3.1", "76d0e7375142dfee67391e7690e89f92578889cbcf2879377900b5620ee4708d", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.11.1", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "4f82b2e57622fb9337559058e8797cb0df7e7c9790793bdc4e40bc895f70e2a2"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, From 861a9f93659482739db09ace9fcf4a4df6276384 Mon Sep 17 00:00:00 2001 From: MediaFormat Date: Sun, 11 Jan 2026 01:12:42 +0000 Subject: [PATCH 33/42] Change redirect_uris to accept array of strings --- .../web/api_spec/operations/admin/o_auth_app_operation.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex b/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex index 2b2496c26..3dbef62cd 100644 --- a/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex @@ -123,7 +123,7 @@ defmodule Pleroma.Web.ApiSpec.Admin.OAuthAppOperation do name: %Schema{type: :string, description: "Application Name"}, scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, redirect_uris: %Schema{ - type: :string, + type: :array, items: %Schema{type: :string}, description: "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." }, @@ -141,7 +141,7 @@ defmodule Pleroma.Web.ApiSpec.Admin.OAuthAppOperation do }, example: %{ "name" => "My App", - "redirect_uris" => "https://myapp.com/auth/callback", + "redirect_uris" => ["https://myapp.com/auth/callback"], "website" => "https://myapp.com/", "scopes" => ["read", "write"], "trusted" => true @@ -157,7 +157,7 @@ defmodule Pleroma.Web.ApiSpec.Admin.OAuthAppOperation do name: %Schema{type: :string, description: "Application Name"}, scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, redirect_uris: %Schema{ - type: :string, + type: :array, items: %Schema{type: :string}, description: "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." }, @@ -175,7 +175,7 @@ defmodule Pleroma.Web.ApiSpec.Admin.OAuthAppOperation do }, example: %{ "name" => "My App", - "redirect_uris" => "https://myapp.com/auth/callback", + "redirect_uris" => ["https://myapp.com/auth/callback"], "website" => "https://myapp.com/", "scopes" => ["read", "write"], "trusted" => true From ba280b2d0fda75faf7cdd4f9b3ee3d7269c6ecc3 Mon Sep 17 00:00:00 2001 From: MediaFormat Date: Sun, 11 Jan 2026 01:15:55 +0000 Subject: [PATCH 34/42] add changelog.d entry --- changelog.d/oauth-registration-redirect_uris.fix | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 changelog.d/oauth-registration-redirect_uris.fix diff --git a/changelog.d/oauth-registration-redirect_uris.fix b/changelog.d/oauth-registration-redirect_uris.fix new file mode 100644 index 000000000..e69de29bb From cb389e788d8759398bf44fe1c8b4755690ae7c3b Mon Sep 17 00:00:00 2001 From: MediaFormat Date: Sun, 11 Jan 2026 05:34:17 +0000 Subject: [PATCH 35/42] fix field type, fix formatting --- .../web/api_spec/operations/admin/o_auth_app_operation.ex | 6 ++++-- lib/pleroma/web/o_auth/app.ex | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex b/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex index 3dbef62cd..5d4306754 100644 --- a/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex @@ -123,7 +123,8 @@ defmodule Pleroma.Web.ApiSpec.Admin.OAuthAppOperation do name: %Schema{type: :string, description: "Application Name"}, scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, redirect_uris: %Schema{ - type: :array, items: %Schema{type: :string}, + type: :array, + items: %Schema{type: :string}, description: "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." }, @@ -157,7 +158,8 @@ defmodule Pleroma.Web.ApiSpec.Admin.OAuthAppOperation do name: %Schema{type: :string, description: "Application Name"}, scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, redirect_uris: %Schema{ - type: :array, items: %Schema{type: :string}, + type: :array, + items: %Schema{type: :string}, description: "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." }, diff --git a/lib/pleroma/web/o_auth/app.ex b/lib/pleroma/web/o_auth/app.ex index 7661c2566..f1145d500 100644 --- a/lib/pleroma/web/o_auth/app.ex +++ b/lib/pleroma/web/o_auth/app.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.OAuth.App do schema "apps" do field(:client_name, :string) - field(:redirect_uris, :string) + field(:redirect_uris, {:array, :string}) field(:scopes, {:array, :string}, default: []) field(:website, :string) field(:client_id, :string) From ad5bb02bd6009a75d25ee57b0a2da26070ccd029 Mon Sep 17 00:00:00 2001 From: MediaFormat Date: Sun, 11 Jan 2026 17:47:27 +0000 Subject: [PATCH 36/42] fix tests --- test/pleroma/web/o_auth/app_test.exs | 14 +++++++------- test/pleroma/web/o_auth/o_auth_controller_test.exs | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/pleroma/web/o_auth/app_test.exs b/test/pleroma/web/o_auth/app_test.exs index a69ba371e..f90bee358 100644 --- a/test/pleroma/web/o_auth/app_test.exs +++ b/test/pleroma/web/o_auth/app_test.exs @@ -10,20 +10,20 @@ defmodule Pleroma.Web.OAuth.AppTest do describe "get_or_make/2" do test "gets exist app" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} + attrs = %{client_name: "Mastodon-Local", redirect_uris: ["."]} app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]})) {:ok, %App{} = exist_app} = App.get_or_make(attrs, []) assert exist_app == app end test "make app" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} + attrs = %{client_name: "Mastodon-Local", redirect_uris: ["."]} {:ok, %App{} = app} = App.get_or_make(attrs, ["write"]) assert app.scopes == ["write"] end test "gets exist app and updates scopes" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} + attrs = %{client_name: "Mastodon-Local", redirect_uris: ["."]} app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]})) {:ok, %App{} = exist_app} = App.get_or_make(attrs, ["read", "write", "follow", "push"]) assert exist_app.id == app.id @@ -31,10 +31,10 @@ defmodule Pleroma.Web.OAuth.AppTest do end test "has unique client_id" do - insert(:oauth_app, client_name: "", redirect_uris: "", client_id: "boop") + insert(:oauth_app, client_name: "", redirect_uris: [""], client_id: "boop") error = - catch_error(insert(:oauth_app, client_name: "", redirect_uris: "", client_id: "boop")) + catch_error(insert(:oauth_app, client_name: "", redirect_uris: [""], client_id: "boop")) assert %Ecto.ConstraintError{} = error assert error.constraint == "apps_client_id_index" @@ -55,7 +55,7 @@ defmodule Pleroma.Web.OAuth.AppTest do end test "removes orphaned apps" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} + attrs = %{client_name: "Mastodon-Local", redirect_uris: ["."]} {:ok, %App{} = old_app} = App.get_or_make(attrs, ["write"]) # backdate the old app so it's within the threshold for being cleaned up @@ -66,7 +66,7 @@ defmodule Pleroma.Web.OAuth.AppTest do |> Pleroma.Repo.query([one_hour_ago, old_app.id]) # Create the new app after backdating the old one - attrs = %{client_name: "PleromaFE", redirect_uris: "."} + attrs = %{client_name: "PleromaFE", redirect_uris: ["."]} {:ok, %App{} = app} = App.get_or_make(attrs, ["write"]) # Ensure the new app has a recent timestamp diff --git a/test/pleroma/web/o_auth/o_auth_controller_test.exs b/test/pleroma/web/o_auth/o_auth_controller_test.exs index 260442771..3788b9c65 100644 --- a/test/pleroma/web/o_auth/o_auth_controller_test.exs +++ b/test/pleroma/web/o_auth/o_auth_controller_test.exs @@ -406,7 +406,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do describe "GET /oauth/authorize" do setup do [ - app: insert(:oauth_app, redirect_uris: "https://redirect.url"), + app: insert(:oauth_app, redirect_uris: ["https://redirect.url"]), conn: build_conn() |> Plug.Session.call(Plug.Session.init(@session_opts)) From 820a4cd97c7f933cfcba8a884b2cb83f980a642c Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 16 Jan 2026 11:25:39 +0400 Subject: [PATCH 37/42] Fix OAuth registration redirect_uris array support --- .../oauth-registration-redirect_uris.fix | 1 + .../operations/admin/o_auth_app_operation.ex | 12 ++++++--- .../web/api_spec/operations/app_operation.ex | 5 +++- lib/pleroma/web/o_auth/app.ex | 25 ++++++++++++++++- .../o_auth_app_controller_test.exs | 22 +++++++++++++++ .../controllers/app_controller_test.exs | 27 +++++++++++++++++++ test/pleroma/web/o_auth/app_test.exs | 14 +++++----- .../web/o_auth/o_auth_controller_test.exs | 2 +- 8 files changed, 94 insertions(+), 14 deletions(-) diff --git a/changelog.d/oauth-registration-redirect_uris.fix b/changelog.d/oauth-registration-redirect_uris.fix index e69de29bb..76ace55df 100644 --- a/changelog.d/oauth-registration-redirect_uris.fix +++ b/changelog.d/oauth-registration-redirect_uris.fix @@ -0,0 +1 @@ +Fix OAuth app registration to accept `redirect_uris` as an array of strings (RFC 7591), while keeping backwards compatibility with string input. diff --git a/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex b/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex index 5d4306754..7d83066ca 100644 --- a/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex @@ -123,8 +123,10 @@ defmodule Pleroma.Web.ApiSpec.Admin.OAuthAppOperation do name: %Schema{type: :string, description: "Application Name"}, scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, redirect_uris: %Schema{ - type: :array, - items: %Schema{type: :string}, + oneOf: [ + %Schema{type: :string}, + %Schema{type: :array, items: %Schema{type: :string}} + ], description: "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." }, @@ -158,8 +160,10 @@ defmodule Pleroma.Web.ApiSpec.Admin.OAuthAppOperation do name: %Schema{type: :string, description: "Application Name"}, scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, redirect_uris: %Schema{ - type: :array, - items: %Schema{type: :string}, + oneOf: [ + %Schema{type: :string}, + %Schema{type: :array, items: %Schema{type: :string}} + ], description: "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." }, diff --git a/lib/pleroma/web/api_spec/operations/app_operation.ex b/lib/pleroma/web/api_spec/operations/app_operation.ex index dfa2237c0..71c15a665 100644 --- a/lib/pleroma/web/api_spec/operations/app_operation.ex +++ b/lib/pleroma/web/api_spec/operations/app_operation.ex @@ -97,7 +97,10 @@ defmodule Pleroma.Web.ApiSpec.AppOperation do properties: %{ client_name: %Schema{type: :string, description: "A name for your application."}, redirect_uris: %Schema{ - type: :string, + oneOf: [ + %Schema{type: :string}, + %Schema{type: :array, items: %Schema{type: :string}} + ], description: "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." }, diff --git a/lib/pleroma/web/o_auth/app.ex b/lib/pleroma/web/o_auth/app.ex index f1145d500..a2841b2bb 100644 --- a/lib/pleroma/web/o_auth/app.ex +++ b/lib/pleroma/web/o_auth/app.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.OAuth.App do schema "apps" do field(:client_name, :string) - field(:redirect_uris, {:array, :string}) + field(:redirect_uris, :string) field(:scopes, {:array, :string}, default: []) field(:website, :string) field(:client_id, :string) @@ -31,9 +31,32 @@ defmodule Pleroma.Web.OAuth.App do @spec changeset(t(), map()) :: Ecto.Changeset.t() def changeset(struct, params) do + params = normalize_redirect_uris_param(params) + cast(struct, params, [:client_name, :redirect_uris, :scopes, :website, :trusted, :user_id]) end + defp normalize_redirect_uris_param(%{} = params) do + case params do + %{redirect_uris: redirect_uris} when is_list(redirect_uris) -> + Map.put(params, :redirect_uris, normalize_redirect_uris(redirect_uris)) + + %{"redirect_uris" => redirect_uris} when is_list(redirect_uris) -> + Map.put(params, "redirect_uris", normalize_redirect_uris(redirect_uris)) + + _ -> + params + end + end + + defp normalize_redirect_uris(redirect_uris) when is_list(redirect_uris) do + redirect_uris + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n") + end + @spec register_changeset(t(), map()) :: Ecto.Changeset.t() def register_changeset(struct, params \\ %{}) do changeset = diff --git a/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs b/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs index 10eefbeca..2c2d13bb7 100644 --- a/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs @@ -57,6 +57,28 @@ defmodule Pleroma.Web.AdminAPI.OAuthAppControllerTest do } = response end + test "success with redirect_uris array", %{conn: conn} do + base_url = Endpoint.url() + app_name = "Trusted app" + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/oauth_app", %{ + name: app_name, + redirect_uris: [base_url] + }) + |> json_response_and_validate_schema(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "name" => ^app_name, + "redirect_uri" => ^base_url, + "trusted" => false + } = response + end + test "with trusted", %{conn: conn} do base_url = Endpoint.url() app_name = "Trusted app" diff --git a/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs index bc9d4048c..45902d7d9 100644 --- a/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs @@ -61,6 +61,33 @@ defmodule Pleroma.Web.MastodonAPI.AppControllerTest do assert app.user_id == nil end + test "creates an oauth app with redirect_uris array", %{conn: conn} do + app_attrs = build(:oauth_app) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/apps", %{ + client_name: app_attrs.client_name, + redirect_uris: [app_attrs.redirect_uris] + }) + + [app] = Repo.all(App) + + expected = %{ + "name" => app.client_name, + "website" => app.website, + "client_id" => app.client_id, + "client_secret" => app.client_secret, + "id" => app.id |> to_string(), + "redirect_uri" => app.redirect_uris, + "vapid_key" => Push.vapid_config() |> Keyword.get(:public_key) + } + + assert expected == json_response_and_validate_schema(conn, 200) + assert app.user_id == nil + end + test "creates an oauth app with a user", %{conn: conn} do user = insert(:user) app_attrs = build(:oauth_app) diff --git a/test/pleroma/web/o_auth/app_test.exs b/test/pleroma/web/o_auth/app_test.exs index f90bee358..a69ba371e 100644 --- a/test/pleroma/web/o_auth/app_test.exs +++ b/test/pleroma/web/o_auth/app_test.exs @@ -10,20 +10,20 @@ defmodule Pleroma.Web.OAuth.AppTest do describe "get_or_make/2" do test "gets exist app" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: ["."]} + attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]})) {:ok, %App{} = exist_app} = App.get_or_make(attrs, []) assert exist_app == app end test "make app" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: ["."]} + attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} {:ok, %App{} = app} = App.get_or_make(attrs, ["write"]) assert app.scopes == ["write"] end test "gets exist app and updates scopes" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: ["."]} + attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]})) {:ok, %App{} = exist_app} = App.get_or_make(attrs, ["read", "write", "follow", "push"]) assert exist_app.id == app.id @@ -31,10 +31,10 @@ defmodule Pleroma.Web.OAuth.AppTest do end test "has unique client_id" do - insert(:oauth_app, client_name: "", redirect_uris: [""], client_id: "boop") + insert(:oauth_app, client_name: "", redirect_uris: "", client_id: "boop") error = - catch_error(insert(:oauth_app, client_name: "", redirect_uris: [""], client_id: "boop")) + catch_error(insert(:oauth_app, client_name: "", redirect_uris: "", client_id: "boop")) assert %Ecto.ConstraintError{} = error assert error.constraint == "apps_client_id_index" @@ -55,7 +55,7 @@ defmodule Pleroma.Web.OAuth.AppTest do end test "removes orphaned apps" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: ["."]} + attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} {:ok, %App{} = old_app} = App.get_or_make(attrs, ["write"]) # backdate the old app so it's within the threshold for being cleaned up @@ -66,7 +66,7 @@ defmodule Pleroma.Web.OAuth.AppTest do |> Pleroma.Repo.query([one_hour_ago, old_app.id]) # Create the new app after backdating the old one - attrs = %{client_name: "PleromaFE", redirect_uris: ["."]} + attrs = %{client_name: "PleromaFE", redirect_uris: "."} {:ok, %App{} = app} = App.get_or_make(attrs, ["write"]) # Ensure the new app has a recent timestamp diff --git a/test/pleroma/web/o_auth/o_auth_controller_test.exs b/test/pleroma/web/o_auth/o_auth_controller_test.exs index 3788b9c65..260442771 100644 --- a/test/pleroma/web/o_auth/o_auth_controller_test.exs +++ b/test/pleroma/web/o_auth/o_auth_controller_test.exs @@ -406,7 +406,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do describe "GET /oauth/authorize" do setup do [ - app: insert(:oauth_app, redirect_uris: ["https://redirect.url"]), + app: insert(:oauth_app, redirect_uris: "https://redirect.url"), conn: build_conn() |> Plug.Session.call(Plug.Session.init(@session_opts)) From 029994aa75a41aeec3966b6fb8b029eea2cbbfa7 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 16 Jan 2026 16:17:21 +0400 Subject: [PATCH 38/42] InstanceView: Omit comment if it's empty --- .../web/mastodon_api/views/instance_view.ex | 11 ++++++++--- .../controllers/instance_controller_test.exs | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 18c6c0f80..0dc7a5fea 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -123,12 +123,17 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do host in exclusions or not Map.has_key?(@block_severities, rule) end) |> Enum.map(fn {host, reason} -> - %{ + domain_block = %{ domain: host, digest: :crypto.hash(:sha256, host) |> Base.encode16(case: :lower), - severity: Map.get(@block_severities, rule), - comment: reason + severity: Map.get(@block_severities, rule) } + + if not_empty_string(reason) do + Map.put(domain_block, :comment, reason) + else + domain_block + end end) end) |> List.flatten() 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 e0c9091e0..461b46066 100644 --- a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs @@ -171,6 +171,22 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do ] == json_response_and_validate_schema(conn, 200) end + test "omits comment field if comment is empty", %{conn: conn} do + clear_config([:mrf_simple, :reject], ["fediverse.pl"]) + + conn = get(conn, "/api/v1/instance/domain_blocks") + + assert [ + %{ + "digest" => "55e3f44aefe7eb022d3b1daaf7396cabf7f181bf6093c8ea841e30c9fc7d8226", + "domain" => "fediverse.pl", + "severity" => "suspend" + } = domain_block + ] = json_response_and_validate_schema(conn, 200) + + refute Map.has_key?(domain_block, "comment") + end + test "returns empty array if mrf transparency is disabled", %{conn: conn} do clear_config([:mrf, :transparency], false) From 0b813b9c4a8dfbc9d191f05e0e066d41f92598ad Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sat, 17 Jan 2026 02:24:07 +0400 Subject: [PATCH 39/42] mrf(media_proxy_warming): avoid adapter-level redirects Drop follow_redirect/force_redirect from the HTTP options used when warming MediaProxy, relying on Tesla middleware instead (Hackney redirect handling can crash behind CONNECT proxies). Also add a regression assertion in the policy test and document the upstream Hackney issues in ReverseProxy redirect handling. --- lib/pleroma/reverse_proxy/client/hackney.ex | 7 +++++-- .../mrf/media_proxy_warming_policy.ex | 9 +++++++- .../mrf/media_proxy_warming_policy_test.exs | 21 +++++++------------ 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/reverse_proxy/client/hackney.ex b/lib/pleroma/reverse_proxy/client/hackney.ex index 7ccd28bb1..7e1fca80d 100644 --- a/lib/pleroma/reverse_proxy/client/hackney.ex +++ b/lib/pleroma/reverse_proxy/client/hackney.ex @@ -5,9 +5,12 @@ defmodule Pleroma.ReverseProxy.Client.Hackney do @behaviour Pleroma.ReverseProxy.Client - # redirect handler from Pleb, slightly modified to work with Hackney + # In-app redirect handler to avoid Hackney redirect bugs: + # - https://github.com/benoitc/hackney/issues/527 (relative/protocol-less redirects can crash Hackney) + # - https://github.com/benoitc/hackney/issues/273 (redirects not followed when using HTTP proxy) + # + # Based on a redirect handler from Pleb, slightly modified to work with Hackney: # https://declin.eu/objects/d4f38e62-5429-4614-86d1-e8fc16e6bf33 - # https://github.com/benoitc/hackney/issues/273 @redirect_statuses [301, 302, 303, 307, 308] defp absolute_redirect_url(original_url, resp_headers) do location = diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex index b0d07a6f8..43c2c0449 100644 --- a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex @@ -27,7 +27,14 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do end defp fetch(url) do - http_client_opts = Pleroma.Config.get([:media_proxy, :proxy_opts, :http], pool: :media) + # This module uses Tesla (Pleroma.HTTP) to fetch the MediaProxy URL. + # Redirect following is handled by Tesla middleware, so we must not enable + # adapter-level redirect logic (Hackney can crash on relative redirects when proxied). + http_client_opts = + [:media_proxy, :proxy_opts, :http] + |> Pleroma.Config.get(pool: :media) + |> Keyword.drop([:follow_redirect, :force_redirect]) + HTTP.get(url, [], http_client_opts) 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 0da3afa3b..4b94b9ac7 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 @@ -54,14 +54,17 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do setup do: clear_config([:media_proxy, :enabled], true) test "it prefetches media proxy URIs" do - Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} -> - {:ok, %Tesla.Env{status: 200, body: ""}} - end) - - with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do + with_mock HTTP, + get: fn _, _, opts -> + send(self(), {:prefetch_opts, opts}) + {:ok, []} + end do MediaProxyWarmingPolicy.filter(@message) assert called(HTTP.get(:_, :_, :_)) + assert_receive {:prefetch_opts, opts} + refute Keyword.has_key?(opts, :follow_redirect) + refute Keyword.has_key?(opts, :force_redirect) end end @@ -81,10 +84,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do end test "history-aware" do - Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} -> - {:ok, %Tesla.Env{status: 200, body: ""}} - end) - with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history) @@ -93,10 +92,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do end test "works with Updates" do - Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} -> - {: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")) From 6439a5b334212eae54e5e336ce8c68862f0045fc Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 22 Jan 2026 23:01:11 +0100 Subject: [PATCH 40/42] MastoAPI AccountView: Add mute/block expiry to the relationship key --- .../web/mastodon_api/views/account_view.ex | 40 ++++++---- .../mastodon_api/views/account_view_test.exs | 78 ++++++++++++++++++- 2 files changed, 99 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index a7d994593..909212711 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -124,6 +124,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do target, &User.blocks_user?(&1, &2) ), + block_expires_at: maybe_put_block_expires_at(user_relationships, target, reading_user), blocked_by: UserRelationship.exists?( user_relationships, @@ -140,6 +141,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do target, &User.mutes?(&1, &2) ), + mute_expires_at: maybe_put_mute_expires_at(user_relationships, target, reading_user), muting_notifications: UserRelationship.exists?( user_relationships, @@ -343,8 +345,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do |> maybe_put_unread_conversation_count(user, opts[:for]) |> maybe_put_unread_notification_count(user, opts[:for]) |> maybe_put_email_address(user, opts[:for]) - |> maybe_put_mute_expires_at(user, opts[:for], opts) - |> maybe_put_block_expires_at(user, opts[:for], opts) |> maybe_show_birthday(user, opts[:for]) end @@ -472,26 +472,32 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do defp maybe_put_email_address(data, _, _), do: data - defp maybe_put_mute_expires_at(data, %User{} = user, target, %{mutes: true}) do - Map.put( - data, - :mute_expires_at, - UserRelationship.get_mute_expire_date(target, user) - ) + defp maybe_put_mute_expires_at(user_relationships, %User{} = target, %User{} = user) do + cond do + UserRelationship.exists?(user_relationships, :mute, user, target, &User.mutes_user?(&1, &2)) -> + UserRelationship.get_mute_expire_date(user, target) + + true -> + nil + end end - defp maybe_put_mute_expires_at(data, _, _, _), do: data + defp maybe_put_block_expires_at(user_relationships, %User{} = target, %User{} = user) do + cond do + UserRelationship.exists?( + user_relationships, + :block, + user, + target, + &User.blocks_user?(&1, &2) + ) -> + UserRelationship.get_block_expire_date(user, target) - defp maybe_put_block_expires_at(data, %User{} = user, target, %{blocks: true}) do - Map.put( - data, - :block_expires_at, - UserRelationship.get_block_expire_date(target, user) - ) + true -> + nil + end end - defp maybe_put_block_expires_at(data, _, _, _), do: data - defp maybe_show_birthday(data, %User{id: user_id} = user, %User{id: user_id}) do data |> Kernel.put_in([:pleroma, :birthday], user.birthday) diff --git a/test/pleroma/web/mastodon_api/views/account_view_test.exs b/test/pleroma/web/mastodon_api/views/account_view_test.exs index bd151cc61..c230cf653 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -439,8 +439,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do following: false, followed_by: false, blocking: false, + block_expires_at: nil, blocked_by: false, muting: false, + mute_expires_at: nil, muting_notifications: false, subscribing: false, notifying: false, @@ -536,6 +538,53 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do test_relationship_rendering(user, other_user, expected) end + test "represent a relationship for the blocking and blocked user with expiry" do + user = insert(:user) + other_user = insert(:user) + date = DateTime.utc_now() |> DateTime.add(24 * 60 * 60) |> DateTime.truncate(:second) + + {:ok, user, other_user} = User.follow(user, other_user) + {:ok, _subscription} = User.subscribe(user, other_user) + {:ok, _user_relationship} = User.block(user, other_user, %{duration: 24 * 60 * 60}) + {:ok, _user_relationship} = User.block(other_user, user) + + expected = + Map.merge( + @blank_response, + %{ + following: false, + blocking: true, + block_expires_at: date, + blocked_by: true, + id: to_string(other_user.id) + } + ) + + test_relationship_rendering(user, other_user, expected) + end + + test "represent a relationship for the muting user with expiry" do + user = insert(:user) + other_user = insert(:user) + date = DateTime.utc_now() |> DateTime.add(24 * 60 * 60) |> DateTime.truncate(:second) + + {:ok, _user_relationship} = + User.mute(user, other_user, %{notifications: true, duration: 24 * 60 * 60}) + + expected = + Map.merge( + @blank_response, + %{ + muting: true, + mute_expires_at: date, + muting_notifications: true, + id: to_string(other_user.id) + } + ) + + test_relationship_rendering(user, other_user, expected) + end + test "represent a relationship for the user blocking a domain" do user = insert(:user) other_user = insert(:user, ap_id: "https://bad.site/users/other_user") @@ -856,12 +905,37 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do User.mute(user, other_user, %{notifications: true, duration: 24 * 60 * 60}) %{ - mute_expires_at: mute_expires_at - } = AccountView.render("show.json", %{user: other_user, for: user, mutes: true}) + pleroma: %{ + relationship: %{ + mute_expires_at: mute_expires_at + } + } + } = AccountView.render("show.json", %{user: other_user, for: user, embed_relationships: true}) assert DateTime.diff( mute_expires_at, DateTime.utc_now() |> DateTime.add(24 * 60 * 60) ) in -3..3 end + + test "renders block expiration date" do + user = insert(:user) + other_user = insert(:user) + + {:ok, _user_relationships} = + User.block(user, other_user, %{duration: 24 * 60 * 60}) + + %{ + pleroma: %{ + relationship: %{ + block_expires_at: block_expires_at + } + } + } = AccountView.render("show.json", %{user: other_user, for: user, embed_relationships: true}) + + assert DateTime.diff( + block_expires_at, + DateTime.utc_now() |> DateTime.add(24 * 60 * 60) + ) in -3..3 + end end From f4a8c426df5eb6589ec3375ca8e450ab4720d960 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 24 Jan 2026 21:33:49 +0100 Subject: [PATCH 41/42] MastoAPI AccountView: Readd block/mute expiry outside the relationship --- .../web/mastodon_api/views/account_view.ex | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 909212711..e215b073e 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -345,6 +345,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do |> maybe_put_unread_conversation_count(user, opts[:for]) |> maybe_put_unread_notification_count(user, opts[:for]) |> maybe_put_email_address(user, opts[:for]) + |> maybe_put_mute_expires_at(user, opts[:for], relationship) + |> maybe_put_block_expires_at(user, opts[:for], relationship) |> maybe_show_birthday(user, opts[:for]) end @@ -482,6 +484,21 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do end end + defp maybe_put_mute_expires_at(data, %User{} = target, %User{} = user, relationship) do + cond do + Map.has_key?(relationship, :mute_expires_at) -> + Map.put(data, :mute_expires_at, relationship.mute_expires_at) + + User.mutes_user?(user, target) -> + Map.put(data, :mute_expires_at, UserRelationship.get_mute_expire_date(user, target)) + + true -> + Map.put(data, :mute_expires_at, nil) + end + end + + defp maybe_put_mute_expires_at(data, _, _, _), do: data + defp maybe_put_block_expires_at(user_relationships, %User{} = target, %User{} = user) do cond do UserRelationship.exists?( @@ -498,6 +515,21 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do end end + defp maybe_put_block_expires_at(data, %User{} = target, %User{} = user, relationship) do + cond do + Map.has_key?(relationship, :block_expires_at) -> + Map.put(data, :block_expires_at, relationship.block_expires_at) + + User.blocks_user?(user, target) -> + Map.put(data, :block_expires_at, UserRelationship.get_block_expire_date(user, target)) + + true -> + Map.put(data, :block_expires_at, nil) + end + end + + defp maybe_put_block_expires_at(data, _, _, _), do: data + defp maybe_show_birthday(data, %User{id: user_id} = user, %User{id: user_id}) do data |> Kernel.put_in([:pleroma, :birthday], user.birthday) From 521fc70e4878fde6c2ef2aef5f640c9dc5e9dbf2 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 24 Jan 2026 21:35:27 +0100 Subject: [PATCH 42/42] MastoAPI AccountView AccountController: Add more block/mute expiry tests --- .../controllers/account_controller_test.exs | 103 ++++++++++++++++-- .../mastodon_api/views/account_view_test.exs | 6 +- 2 files changed, 97 insertions(+), 12 deletions(-) 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 02da781dd..ea98b53a8 100644 --- a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs @@ -1901,7 +1901,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do {:ok, _user_relationships} = User.mute(user, other_user1) {:ok, _user_relationships} = User.mute(user, other_user2) - {:ok, _user_relationships} = User.mute(user, other_user3) + {:ok, _user_relationships} = User.mute(user, other_user3, %{duration: 24 * 60 * 60}) + + date = + DateTime.utc_now() + |> DateTime.add(24 * 60 * 60) + |> DateTime.truncate(:second) + |> DateTime.to_iso8601() result = conn @@ -1937,6 +1943,17 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do |> json_response_and_validate_schema(200) assert [%{"id" => ^id3}] = result + + result = + conn + |> get("/api/v1/mutes") + |> json_response_and_validate_schema(200) + + assert [ + %{"id" => ^id3, "mute_expires_at" => ^date}, + %{"id" => ^id2, "mute_expires_at" => nil}, + %{"id" => ^id1, "mute_expires_at" => nil} + ] = result end test "list of mutes with with_relationships parameter" do @@ -1951,20 +1968,44 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do {:ok, _} = User.mute(user, other_user1) {:ok, _} = User.mute(user, other_user2) - {:ok, _} = User.mute(user, other_user3) + {:ok, _} = User.mute(user, other_user3, %{duration: 24 * 60 * 60}) + + date = + DateTime.utc_now() + |> DateTime.add(24 * 60 * 60) + |> DateTime.truncate(:second) + |> DateTime.to_iso8601() assert [ %{ "id" => ^id3, - "pleroma" => %{"relationship" => %{"muting" => true, "followed_by" => true}} + "pleroma" => %{ + "relationship" => %{ + "muting" => true, + "mute_expires_at" => ^date, + "followed_by" => true + } + } }, %{ "id" => ^id2, - "pleroma" => %{"relationship" => %{"muting" => true, "followed_by" => true}} + "pleroma" => %{ + "relationship" => %{ + "muting" => true, + "mute_expires_at" => nil, + "followed_by" => true + } + } }, %{ "id" => ^id1, - "pleroma" => %{"relationship" => %{"muting" => true, "followed_by" => true}} + "pleroma" => %{ + "relationship" => %{ + "muting" => true, + "mute_expires_at" => nil, + "followed_by" => true + } + } } ] = conn @@ -1980,7 +2021,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do {:ok, _user_relationship} = User.block(user, other_user1) {:ok, _user_relationship} = User.block(user, other_user3) - {:ok, _user_relationship} = User.block(user, other_user2) + {:ok, _user_relationship} = User.block(user, other_user2, %{duration: 24 * 60 * 60}) + + date = + DateTime.utc_now() + |> DateTime.add(24 * 60 * 60) + |> DateTime.truncate(:second) + |> DateTime.to_iso8601() result = conn @@ -2045,6 +2092,18 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do |> json_response_and_validate_schema(200) assert [%{"id" => ^id1}] = result + + result = + conn + |> assign(:user, user) + |> get("api/v1/blocks") + |> json_response_and_validate_schema(200) + + assert [ + %{"id" => ^id3, "block_expires_at" => nil}, + %{"id" => ^id2, "block_expires_at" => ^date}, + %{"id" => ^id1, "block_expires_at" => nil} + ] = result end test "list of blocks with with_relationships parameter" do @@ -2059,20 +2118,44 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do {:ok, _} = User.block(user, other_user1) {:ok, _} = User.block(user, other_user2) - {:ok, _} = User.block(user, other_user3) + {:ok, _} = User.block(user, other_user3, %{duration: 24 * 60 * 60}) + + date = + DateTime.utc_now() + |> DateTime.add(24 * 60 * 60) + |> DateTime.truncate(:second) + |> DateTime.to_iso8601() assert [ %{ "id" => ^id3, - "pleroma" => %{"relationship" => %{"blocking" => true, "followed_by" => false}} + "pleroma" => %{ + "relationship" => %{ + "blocking" => true, + "block_expires_at" => ^date, + "followed_by" => false + } + } }, %{ "id" => ^id2, - "pleroma" => %{"relationship" => %{"blocking" => true, "followed_by" => false}} + "pleroma" => %{ + "relationship" => %{ + "blocking" => true, + "block_expires_at" => nil, + "followed_by" => false + } + } }, %{ "id" => ^id1, - "pleroma" => %{"relationship" => %{"blocking" => true, "followed_by" => false}} + "pleroma" => %{ + "relationship" => %{ + "blocking" => true, + "block_expires_at" => nil, + "followed_by" => false + } + } } ] = conn diff --git a/test/pleroma/web/mastodon_api/views/account_view_test.exs b/test/pleroma/web/mastodon_api/views/account_view_test.exs index c230cf653..6984442cc 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -909,7 +909,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do relationship: %{ mute_expires_at: mute_expires_at } - } + }, + mute_expires_at: mute_expires_at } = AccountView.render("show.json", %{user: other_user, for: user, embed_relationships: true}) assert DateTime.diff( @@ -930,7 +931,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do relationship: %{ block_expires_at: block_expires_at } - } + }, + block_expires_at: block_expires_at } = AccountView.render("show.json", %{user: other_user, for: user, embed_relationships: true}) assert DateTime.diff(