From e091349718f7458775d92782135f4b32c883062c Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Dec 2025 05:08:52 +0000 Subject: [PATCH 01/26] 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 546b60005..8e486ba13 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 ef0f04ca48e8b7e848ef5fd7cac9d9e074248d59 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 16 Jan 2026 21:16:04 +0400 Subject: [PATCH 02/26] 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 52fc344b0a8c8ebe3496fb3d7bc7ae026377dbd2 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 16 Jan 2026 21:16:26 +0400 Subject: [PATCH 03/26] 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 e67b4cd8b2b8beded29880778d8e044ec0a4fc21 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 16 Jan 2026 21:16:39 +0400 Subject: [PATCH 04/26] 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 9b1941366f08d3046abd8c943e726f4e29c81e4c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 24 Dec 2025 14:35:54 -0800 Subject: [PATCH 05/26] 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 1a6a8f6fb4e001885d7163d6840e3fa1d7c09a78 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 16 Jan 2026 22:00:36 +0400 Subject: [PATCH 06/26] 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 e7d2d9bd89bd006d094015711e5b8a220cd490d1 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sat, 17 Jan 2026 02:24:07 +0400 Subject: [PATCH 07/26] 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 54092d2b7c481aee51331feec029ab05d4303c60 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sun, 25 Jan 2026 06:46:28 +0100 Subject: [PATCH 08/26] Docs: Remove outdated, incorrect, inappropriate or unmaintained install docs --- README.md | 2 - changelog.d/inappropriate-docs.remove | 1 + docs/installation/arch_linux_en.md | 226 -------------------- docs/installation/netbsd_en.md | 294 -------------------------- docs/installation/nixos_en.md | 15 -- 5 files changed, 1 insertion(+), 537 deletions(-) create mode 100644 changelog.d/inappropriate-docs.remove delete mode 100644 docs/installation/arch_linux_en.md delete mode 100644 docs/installation/netbsd_en.md delete mode 100644 docs/installation/nixos_en.md diff --git a/README.md b/README.md index 2837b6ef8..8a5eb238f 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,6 @@ If you are running Linux (glibc or musl) on x86/arm, the recommended way to inst If your platform is not supported, or you just want to be able to edit the source code easily, you may install Pleroma from source. - [Alpine Linux](https://docs-develop.pleroma.social/backend/installation/alpine_linux_en/) -- [Arch Linux](https://docs-develop.pleroma.social/backend/installation/arch_linux_en/) -- [CentOS 7](https://docs-develop.pleroma.social/backend/installation/centos7_en/) - [Debian-based](https://docs-develop.pleroma.social/backend/installation/debian_based_en/) - [Debian-based (jp)](https://docs-develop.pleroma.social/backend/installation/debian_based_jp/) - [FreeBSD](https://docs-develop.pleroma.social/backend/installation/freebsd_en/) diff --git a/changelog.d/inappropriate-docs.remove b/changelog.d/inappropriate-docs.remove new file mode 100644 index 000000000..699c9186a --- /dev/null +++ b/changelog.d/inappropriate-docs.remove @@ -0,0 +1 @@ +Docs: Removed outdated, incorrect, unmaintained and inappropriate installation documentation (Arch, NetBSD, NixOS) diff --git a/docs/installation/arch_linux_en.md b/docs/installation/arch_linux_en.md deleted file mode 100644 index f7d722ef9..000000000 --- a/docs/installation/arch_linux_en.md +++ /dev/null @@ -1,226 +0,0 @@ -# Installing on Arch Linux - -{! backend/installation/otp_vs_from_source_source.include !} - -## Installation - -This guide will assume that you have administrative rights, either as root or a user with [sudo permissions](https://wiki.archlinux.org/index.php/Sudo). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su -s $SHELL -c 'command'` instead. - -### Required packages - -* `postgresql` -* `elixir` -* `git` -* `base-devel` -* `cmake` -* `file` - -#### Optional packages used in this guide - -* `nginx` (preferred, example configs for other reverse proxies can be found in the repo) -* `certbot` (or any other ACME client for Let’s Encrypt certificates) -* `ImageMagick` -* `ffmpeg` -* `exiftool` - -### Prepare the system - -* First update the system, if not already done: - -```shell -sudo pacman -Syu -``` - -* Install some of the above mentioned programs: - -```shell -sudo pacman -S git base-devel elixir cmake file -``` - -### Install PostgreSQL - -[Arch Wiki article](https://wiki.archlinux.org/index.php/PostgreSQL) - -* Install the `postgresql` package: - -```shell -sudo pacman -S postgresql -``` - -* Initialize the database cluster: - -```shell -sudo -iu postgres initdb -D /var/lib/postgres/data -``` - -* Start and enable the `postgresql.service` - -```shell -sudo systemctl enable --now postgresql.service -``` - -### Install media / graphics packages (optional, see [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md)) - -```shell -sudo pacman -S ffmpeg imagemagick perl-image-exiftool -``` - -### Install PleromaBE - -* Add a new system user for the Pleroma service: - -```shell -sudo useradd -r -s /bin/false -m -d /var/lib/pleroma -U pleroma -``` - -**Note**: To execute a single command as the Pleroma system user, use `sudo -Hu pleroma command`. You can also switch to a shell by using `sudo -Hu pleroma $SHELL`. If you don’t have and want `sudo` on your system, you can use `su` as root user (UID 0) for a single command by using `su -l pleroma -s $SHELL -c 'command'` and `su -l pleroma -s $SHELL` for starting a shell. - -* Git clone the PleromaBE repository and make the Pleroma user the owner of the directory: - -```shell -sudo mkdir -p /opt/pleroma -sudo chown -R pleroma:pleroma /opt/pleroma -sudo -Hu pleroma git clone -b stable https://git.pleroma.social/pleroma/pleroma /opt/pleroma -``` - -* Change to the new directory: - -```shell -cd /opt/pleroma -``` - -* Install the dependencies for Pleroma and answer with `yes` if it asks you to install `Hex`: - -```shell -sudo -Hu pleroma mix deps.get -``` - -* Generate the configuration: `sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen` - * Answer with `yes` if it asks you to install `rebar3`. - * This may take some time, because parts of pleroma get compiled first. - * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. - -* Check the configuration and if all looks right, rename it, so Pleroma will load it (`prod.secret.exs` for productive instance, `dev.secret.exs` for development instances): - -```shell -sudo -Hu pleroma mv config/{generated_config.exs,prod.secret.exs} -``` - -* The previous command creates also the file `config/setup_db.psql`, with which you can create the database: - -```shell -sudo -Hu postgres psql -f config/setup_db.psql -``` - -* Now run the database migration: - -```shell -sudo -Hu pleroma MIX_ENV=prod mix ecto.migrate -``` - -* Now you can start Pleroma already - -```shell -sudo -Hu pleroma MIX_ENV=prod mix phx.server -``` - -### Finalize installation - -If you want to open your newly installed instance to the world, you should run nginx or some other webserver/proxy in front of Pleroma and you should consider to create a systemd service file for Pleroma. - -#### Nginx - -* Install nginx, if not already done: - -```shell -sudo pacman -S nginx -``` - -* Create directories for available and enabled sites: - -```shell -sudo mkdir -p /etc/nginx/sites-{available,enabled} -``` - -* Append the following line at the end of the `http` block in `/etc/nginx/nginx.conf`: - -```Nginx -include sites-enabled/*; -``` - -* Setup your SSL cert, using your method of choice or certbot. If using certbot, first install it: - -```shell -sudo pacman -S certbot certbot-nginx -``` - -and then set it up: - -```shell -sudo mkdir -p /var/lib/letsencrypt/ -sudo certbot certonly --email -d --standalone -``` - -If that doesn’t work, make sure, that nginx is not already running. If it still doesn’t work, try setting up nginx first (change ssl “on” to “off” and try again). - ---- - -* Copy the example nginx configuration and activate it: - -```shell -sudo cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/pleroma.nginx -sudo ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/pleroma.nginx -``` - -* Before starting nginx edit the configuration and change it to your needs (e.g. change servername, change cert paths) - -* (Strongly recommended) serve media on another domain - -Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. - -* Enable and start nginx: - -```shell -sudo systemctl enable --now nginx.service -``` - -If you need to renew the certificate in the future, uncomment the relevant location block in the nginx config and run: - -```shell -sudo certbot certonly --email -d --webroot -w /var/lib/letsencrypt/ -``` - -#### Other webserver/proxies - -You can find example configurations for them in `/opt/pleroma/installation/`. - -#### Systemd service - -* Copy example service file - -```shell -sudo cp /opt/pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service -``` - -* Edit the service file and make sure that all paths fit your installation -* Enable and start `pleroma.service`: - -```shell -sudo systemctl enable --now pleroma.service -``` - -#### Create your first user - -If your instance is up and running, you can create your first user with administrative rights with the following task: - -```shell -sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new --admin -``` - -#### Further reading - -{! backend/installation/further_reading.include !} - -## Questions - -Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/netbsd_en.md b/docs/installation/netbsd_en.md deleted file mode 100644 index 7c003700c..000000000 --- a/docs/installation/netbsd_en.md +++ /dev/null @@ -1,294 +0,0 @@ -# Installing on NetBSD - -{! backend/installation/generic_dependencies.include !} - -# Installation options - -Currently there are two options available for NetBSD: manual installation (from source) or using experimental package from [pkgsrc-wip](https://github.com/NetBSD/pkgsrc-wip/tree/master/pleroma). - -WIP package can be installed via pkgsrc and can be crosscompiled for easier binary distribution. Source installation most probably will be restricted to a single machine. - -## pkgsrc installation - -WIP package creates Mix.Release (similar to how Docker images are built) but doesn't bundle Erlang runtime, listing it as a dependency instead. This allows for easier and more modular installations, especially on weaker machines. Currently this method also does not support all features of `pleroma_ctl` command (like changing installation type or managing frontends) as NetBSD is not yet a supported binary flavour of Pleroma's CI. - -In any case, you can install it the same way as any other `pkgsrc-wip` package: - -``` -cd /usr/pkgsrc -git clone --depth 1 git://wip.pkgsrc.org/pkgsrc-wip.git wip -cp -rf wip/pleroma www -cp -rf wip/libvips graphics -cd /usr/pkgsrc/www/pleroma -bmake && bmake install -``` - -Use `bmake package` to create a binary package. This can come especially handy if you're targeting embedded or low-power systems and are crosscompiling on a more powerful machine. - -> Note: Elixir has [endianness bug](https://github.com/elixir-lang/elixir/issues/2785) which requires it to be compiled on a machine with the same endianness. In other words, package crosscompiled on amd64 (little endian) won't work on powerpc or sparc machines (big endian). While _in theory™_ nothing catastrophic should happen, one can see that for example regexes won't work properly. Some distributions just strip this warning away, so it doesn't bother the users... anyway, you've been warned. - -## Source installation - -pkgin should have been installed by the NetBSD installer if you selected -the right options. If it isn't installed, install it using `pkg_add`. - -Note that `postgresql11-contrib` is needed for the Postgres extensions -Pleroma uses. - -> Note: you can use modern versions of PostgreSQL. In this case, just use `postgresql16-contrib` and so on. - -The `mksh` shell is needed to run the Elixir `mix` script. - -`# pkgin install acmesh elixir git-base git-docs mksh nginx postgresql11-server postgresql11-client postgresql11-contrib sudo ffmpeg4 ImageMagick` - -You can also build these packages using pkgsrc: -``` -databases/postgresql11-contrib -databases/postgresql11-client -databases/postgresql11-server -devel/git-base -devel/git-docs -devel/cmake -lang/elixir -security/acmesh -security/sudo -shells/mksh -www/nginx -``` - -Create a user for Pleroma: - -``` -# groupadd pleroma -# useradd -d /home/pleroma -m -g pleroma -s /usr/pkg/bin/mksh pleroma -# echo 'export LC_ALL="en_GB.UTF-8"' >> /home/pleroma/.profile -# su -l pleroma -c $SHELL -``` - -Clone the repository: - -``` -$ cd /home/pleroma -$ git clone -b stable https://git.pleroma.social/pleroma/pleroma.git -``` - -Get deps and compile: - -``` -$ cd /home/pleroma/pleroma -$ export MIX_ENV=prod -$ mix deps.get -$ mix compile -``` - -## Install media / graphics packages (optional, see [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md)) - -`# pkgin install ImageMagick ffmpeg4 p5-Image-ExifTool` - -or via pkgsrc: - -``` -graphics/p5-Image-ExifTool -graphics/ImageMagick -multimedia/ffmpeg4 -``` - -# Configuration - -## Understanding $PREFIX - -From now on, you may encounter `$PREFIX` variable in the paths. This variable indicates your current local pkgsrc prefix. Usually it's `/usr/pkg` unless you configured it otherwise. Translating to pkgsrc's lingo, it's called `LOCALBASE`, which essentially means the same this. You may want to set it up for your local shell session (this uses `mksh` which should already be installed as one of the required dependencies): - -``` -$ export PREFIX=$(pkg_info -Q LOCALBASE mksh) -$ echo $PREFIX -/usr/pkg -``` - -## Setting up your instance - -Now, you need to configure your instance. During this initial configuration, you will be asked some questions about your server. You will need a domain name at this point; it doesn't have to be deployed, but changing it later will be very cumbersome. - -If you've installed via pkgsrc, `pleroma_ctl` should already be in your `PATH`; if you've installed from source, it's located at `/home/pleroma/pleroma/release/bin/pleroma_ctl`. - -``` -$ su -l pleroma -$ pleroma_ctl instance gen --output $PREFIX/etc/pleroma/config.exs --output-psql /tmp/setup_db.psql -``` - -During installation, you will be asked about static and upload directories. Don't forget to create them and update permissions: - -``` -mkdir -p /var/lib/pleroma/uploads -chown -R pleroma:pleroma /var/lib/pleroma -``` - -## Setting up the database - -First, run `# /etc/rc.d/pgsql start`. Then, `$ sudo -Hu pgsql -g pgsql createdb`. - -We can now initialize the database. You'll need to edit generated SQL file from the previous step. It's located at `/tmp/setup_db.psql`. - -Edit this file, and *change the password* to a password of your choice. Make sure it is secure, since -it'll be protecting your database. Now initialize the database: - -``` -$ sudo -Hu pgsql -g pgsql psql -f /tmp/setup_db.psql -``` - -Postgres allows connections from all users without a password by default. To -fix this, edit `$PREFIX/pgsql/data/pg_hba.conf`. Change every `trust` to -`password`. - -Once this is done, restart Postgres with `# /etc/rc.d/pgsql restart`. - -Run the database migrations. - -### pkgsrc installation - -``` -pleroma_ctl migrate -``` - -### Source installation - -You will need to do this whenever you update with `git pull`: - -``` -$ cd /home/pleroma/pleroma -$ MIX_ENV=prod mix ecto.migrate -``` - -## Configuring nginx - -Install the example configuration file -(`$PREFIX/share/examples/pleroma/pleroma.nginx` or `/home/pleroma/pleroma/installation/pleroma.nginx`) to -`$PREFIX/etc/nginx.conf`. - -Note that it will need to be wrapped in a `http {}` block. You should add -settings for the nginx daemon outside of the http block, for example: - -``` -user nginx nginx; -error_log /var/log/nginx/error.log; -worker_processes 4; - -events { -} -``` - -Edit the defaults: - -* Change `ssl_certificate` and `ssl_trusted_certificate` to -`/etc/nginx/tls/fullchain`. -* Change `ssl_certificate_key` to `/etc/nginx/tls/key`. -* Change `example.tld` to your instance's domain name. - -### (Strongly recommended) serve media on another domain - -Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. - -## Configuring acme.sh - -We'll be using acme.sh in Stateless Mode for TLS certificate renewal. - -First, get your account fingerprint: - -``` -$ sudo -Hu nginx -g nginx acme.sh --register-account -``` - -You need to add the following to your nginx configuration for the server -running on port 80: - -``` - location ~ ^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)$ { - default_type text/plain; - return 200 "$1.6fXAG9VyG0IahirPEU2ZerUtItW2DHzDzD9wZaEKpqd"; - } -``` - -Replace the string after after `$1.` with your fingerprint. - -Start nginx: - -``` -# /etc/rc.d/nginx start -``` - -It should now be possible to issue a cert (replace `example.com` -with your domain name): - -``` -$ sudo -Hu nginx -g nginx acme.sh --issue -d example.com --stateless -``` - -Let's add auto-renewal to `/etc/daily.local` -(replace `example.com` with your domain): - -``` -/usr/pkg/bin/sudo -Hu nginx -g nginx \ - /usr/pkg/sbin/acme.sh -r \ - -d example.com \ - --cert-file /etc/nginx/tls/cert \ - --key-file /etc/nginx/tls/key \ - --ca-file /etc/nginx/tls/ca \ - --fullchain-file /etc/nginx/tls/fullchain \ - --stateless -``` - -## Autostart - -For properly functioning instance, you will need pleroma (backend service), nginx (reverse proxy) and postgresql (database) services running. There's no requirement for them to reside on the same machine, but you have to provide autostart for each of them. - -### nginx -``` -# cp $PREFIX/share/examples/rc.d/nginx /etc/rc.d -# echo "nginx=YES" >> /etc/rc.conf -``` - -### postgresql - -``` -# cp $PREFIX/share/examples/rc.d/pgsql /etc/rc.d -# echo "pgsql=YES" >> /etc/rc.conf -``` - -### pleroma - -First, copy the script (pkgsrc variant) -``` -# cp $PREFIX/share/examples/pleroma/pleroma.rc /etc/rc.d/pleroma -``` - -or source variant -``` -# cp /home/pleroma/pleroma/installation/netbsd/rc.d/pleroma /etc/rc.d/pleroma -# chmod +x /etc/rc.d/pleroma -``` - -Then, add the following to `/etc/rc.conf`: - -``` -pleroma=YES -``` - -## Conclusion - -Run `# /etc/rc.d/pleroma start` to start Pleroma. -Restart nginx with `# /etc/rc.d/nginx restart` and you should be up and running. - -Make sure your time is in sync, or other instances will receive your posts with -incorrect timestamps. You should have ntpd running. - -## Instances running NetBSD - -* - -#### Further reading - -{! backend/installation/further_reading.include !} - -## Questions - -Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/nixos_en.md b/docs/installation/nixos_en.md deleted file mode 100644 index f3c4988b1..000000000 --- a/docs/installation/nixos_en.md +++ /dev/null @@ -1,15 +0,0 @@ -# Installing on NixOS - -NixOS contains a source build package of pleroma and a NixOS module to install it. -For installation add this to your configuration.nix and add a config.exs next to it: -```nix - services.pleroma = { - enable = true; - configs = [ (lib.fileContents ./config.exs) ]; - secretConfigFile = "/var/lib/pleroma/secret.exs"; - }; -``` - -## Questions -The nix community uses matrix for communication: [#nix:nixos.org](https://matrix.to/#/#nix:nixos.org) - From 80ede85f75f24c968bfa63fb52a7616c32fc788b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 19 Sep 2025 16:43:55 +0200 Subject: [PATCH 09/26] Allow assigning users to reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/assign-users.add | 1 + docs/development/API/admin_api.md | 33 +++++++ lib/pleroma/constants.ex | 3 +- lib/pleroma/moderation_log.ex | 38 +++++++- lib/pleroma/web/activity_pub/activity_pub.ex | 9 ++ lib/pleroma/web/activity_pub/utils.ex | 28 ++++++ .../controllers/report_controller.ex | 55 ++++++++++- lib/pleroma/web/admin_api/report.ex | 13 ++- .../web/admin_api/views/report_view.ex | 16 +++- .../operations/admin/report_operation.ex | 55 ++++++++++- lib/pleroma/web/common_api.ex | 16 ++++ lib/pleroma/web/router.ex | 1 + ...00_add_activity_assigned_account_index.exs | 11 +++ test/pleroma/web/activity_pub/utils_test.exs | 13 +++ .../controllers/report_controller_test.exs | 92 +++++++++++++++++++ .../web/admin_api/views/report_view_test.exs | 2 + test/pleroma/web/common_api_test.exs | 23 +++++ 17 files changed, 402 insertions(+), 7 deletions(-) create mode 100644 changelog.d/assign-users.add create mode 100644 priv/repo/migrations/20220225164000_add_activity_assigned_account_index.exs diff --git a/changelog.d/assign-users.add b/changelog.d/assign-users.add new file mode 100644 index 000000000..f50ad94c6 --- /dev/null +++ b/changelog.d/assign-users.add @@ -0,0 +1 @@ +Allow assigning users to reports \ No newline at end of file diff --git a/docs/development/API/admin_api.md b/docs/development/API/admin_api.md index 64c06ca2b..3719ceeb9 100644 --- a/docs/development/API/admin_api.md +++ b/docs/development/API/admin_api.md @@ -665,6 +665,7 @@ Status: 404 - *optional* `limit`: **integer** the number of records to retrieve - *optional* `page`: **integer** page number - *optional* `page_size`: **integer** number of log entries per page (default is `50`) + - *optional* `assigned_account`: **string** assigned account ID - Response: - On failure: 403 Forbidden error `{"error": "error_msg"}` when requested by anonymous or non-admin - On success: JSON, returns a list of reports, where: @@ -749,6 +750,7 @@ Status: 404 "url": "https://pleroma.example.org/users/lain", "username": "lain" }, + "assigned_account": null, "content": "Please delete it", "created_at": "2019-04-29T19:48:15.000Z", "id": "9iJGOv1j8hxuw19bcm", @@ -868,6 +870,37 @@ Status: 404 ] ``` +- Response: + - On failure: + - 400 Bad Request, JSON: + + ```json + [ + { + `id`, // report id + `error` // error message + } + ] + ``` + + - On success: `204`, empty response + +## `POST /api/v1/pleroma/admin/reports/assign_account` + +### Assign account to one or multiple reports + +- Params: + +```json + `reports`: [ + { + `id`, // required, report id + `nickname` // account nickname, use null to unassign account + }, + ... + ] +``` + - Response: - On failure: - 400 Bad Request, JSON: diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index c0411edbf..f78b84099 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -22,7 +22,8 @@ defmodule Pleroma.Constants do "generator", "rules", "language", - "voters" + "voters", + "assigned_account" ] ) diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index 52a71bc2d..90219312c 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -132,11 +132,18 @@ defmodule Pleroma.ModerationLog do end def insert_log(%{actor: %User{}, action: action, subject: %Activity{} = subject} = attrs) - when action in ["report_note_delete", "report_update", "report_note"] do + when action in [ + "report_note_delete", + "report_update", + "report_note", + "report_unassigned", + "report_assigned" + ] do data = attrs |> prepare_log_data |> Pleroma.Maps.put_if_present("text", attrs[:text]) + |> Pleroma.Maps.put_if_present("assigned_account", attrs[:assigned_account]) |> Map.merge(%{"subject" => report_to_map(subject)}) insert_log_entry_with_message(%ModerationLog{data: data}) @@ -441,6 +448,35 @@ defmodule Pleroma.ModerationLog do " with '#{state}' state" end + def get_log_entry_message( + %ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_assigned", + "subject" => %{"id" => subject_id, "type" => "report"}, + "assigned_account" => assigned_account + } + } = log + ) do + "@#{actor_nickname} assigned report ##{subject_id}" <> + subject_actor_nickname(log, " (on user ", ")") <> + " to user #{assigned_account}" + end + + def get_log_entry_message( + %ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_unassigned", + "subject" => %{"id" => subject_id, "type" => "report"} + } + } = log + ) do + "@#{actor_nickname} unassigned report ##{subject_id}" <> + subject_actor_nickname(log, " (on user ", ")") <> + " from a user" + end + def get_log_entry_message( %ModerationLog{ data: %{ diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index e58e3dd57..44e9a22e5 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1003,6 +1003,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_state(query, _), do: query + defp restrict_assigned_account(query, %{assigned_account: assigned_account}) do + from(activity in query, + where: fragment("?->>'assigned_account' = ?", activity.data, ^assigned_account) + ) + end + + defp restrict_assigned_account(query, _), do: query + defp restrict_favorited_by(query, %{favorited_by: ap_id}) do from( [_activity, object] in query, @@ -1471,6 +1479,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> restrict_actor(opts) |> restrict_type(opts) |> restrict_state(opts) + |> restrict_assigned_account(opts) |> restrict_favorited_by(opts) |> restrict_blocked(restrict_blocked_opts) |> restrict_blockers_visibility(opts) diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index c5a6901d4..43c0f456d 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -863,6 +863,34 @@ defmodule Pleroma.Web.ActivityPub.Utils do def update_report_state(_, _), do: {:error, "Unsupported state"} + def assign_report_to_account(%Activity{} = activity, nil = _account) do + new_data = Map.delete(activity.data, "assigned_account") + + activity + |> Changeset.change(data: new_data) + |> Repo.update() + end + + def assign_report_to_account(%Activity{} = activity, account) do + new_data = Map.put(activity.data, "assigned_account", account) + + activity + |> Changeset.change(data: new_data) + |> Repo.update() + end + + def assign_report_to_account(activity_ids, account) do + activities_num = length(activity_ids) + + from(a in Activity, where: a.id in ^activity_ids) + |> update(set: [data: fragment("jsonb_set(data, '{assigned_account}', ?)", ^account)]) + |> Repo.update_all([]) + |> case do + {^activities_num, _} -> :ok + _ -> {:error, activity_ids} + end + end + def strip_report_status_data(%Activity{} = activity) do with {:ok, new_data} <- strip_report_status_data(activity.data) do {:ok, %{activity | data: new_data}} diff --git a/lib/pleroma/web/admin_api/controllers/report_controller.ex b/lib/pleroma/web/admin_api/controllers/report_controller.ex index 89d8cc820..dbac03ef4 100644 --- a/lib/pleroma/web/admin_api/controllers/report_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/report_controller.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.AdminAPI.ReportController do alias Pleroma.Activity alias Pleroma.ModerationLog alias Pleroma.ReportNote + alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.Report @@ -24,7 +25,7 @@ defmodule Pleroma.Web.AdminAPI.ReportController do plug( OAuthScopesPlug, %{scopes: ["admin:write:reports"]} - when action in [:update, :notes_create, :notes_delete] + when action in [:update, :assign_account, :notes_create, :notes_delete] ) action_fallback(AdminAPI.FallbackController) @@ -79,6 +80,22 @@ defmodule Pleroma.Web.AdminAPI.ReportController do end end + def assign_account( + %{ + assigns: %{user: admin}, + private: %{open_api_spex: %{body_params: %{reports: reports}}} + } = conn, + _ + ) do + result = Enum.map(reports, &do_assign_account(&1, admin)) + + if Enum.any?(result, &Map.has_key?(&1, :error)) do + json_response(conn, :bad_request, result) + else + json_response(conn, :no_content, "") + end + end + def notes_create( %{ assigns: %{user: user}, @@ -131,4 +148,40 @@ defmodule Pleroma.Web.AdminAPI.ReportController do _ -> json_response(conn, :bad_request, "") end end + + defp do_assign_account(%{assigned_account: nil, id: id}, admin) do + with {:ok, activity} <- CommonAPI.assign_report_to_account(id, nil), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_unassigned", + actor: admin, + subject: activity, + subject_actor: report.user_actor + }) + + activity + else + {:error, message} -> + %{id: id, error: message} + end + end + + defp do_assign_account(%{assigned_account: assigned_account, id: id}, admin) do + with %User{id: account} = user <- User.get_cached_by_nickname(assigned_account), + {:ok, activity} <- CommonAPI.assign_report_to_account(id, account), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_assigned", + actor: admin, + subject: activity, + subject_actor: report.user_actor, + assigned_account: user.nickname + }) + + activity + else + {:error, message} -> + %{id: id, error: message} + end + end end diff --git a/lib/pleroma/web/admin_api/report.ex b/lib/pleroma/web/admin_api/report.ex index fa89e3405..753b92d88 100644 --- a/lib/pleroma/web/admin_api/report.ex +++ b/lib/pleroma/web/admin_api/report.ex @@ -13,6 +13,11 @@ defmodule Pleroma.Web.AdminAPI.Report do user = User.get_cached_by_ap_id(actor) account = User.get_cached_by_ap_id(account_ap_id) + assigned_account = + if Map.has_key?(report.data, "assigned_account") do + User.get_cached_by_id(report.data["assigned_account"]) + end + statuses = status_ap_ids |> Enum.reject(&is_nil(&1)) @@ -26,7 +31,13 @@ defmodule Pleroma.Web.AdminAPI.Report do Activity.get_by_ap_id_with_object(act) end) - %{report: report, user: user, account: account, statuses: statuses} + %{ + report: report, + user: user, + account: account, + statuses: statuses, + assigned_account: assigned_account + } end defp make_fake_activity(act, user) do diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index b4b0be267..da6166050 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -26,7 +26,13 @@ defmodule Pleroma.Web.AdminAPI.ReportView do } end - def render("show.json", %{report: report, user: user, account: account, statuses: statuses}) do + def render("show.json", %{ + report: report, + user: user, + account: account, + statuses: statuses, + assigned_account: assigned_account + }) do created_at = Utils.to_masto_date(report.data["published"]) content = @@ -36,6 +42,11 @@ defmodule Pleroma.Web.AdminAPI.ReportView do nil end + assigned_account = + if assigned_account do + merge_account_views(assigned_account) + end + %{ id: report.id, account: merge_account_views(account), @@ -49,7 +60,8 @@ defmodule Pleroma.Web.AdminAPI.ReportView do }), state: report.data["state"], notes: render(__MODULE__, "index_notes.json", %{notes: report.report_notes}), - rules: rules(Map.get(report.data, "rules", nil)) + rules: rules(Map.get(report.data, "rules", nil)), + assigned_account: assigned_account } end diff --git a/lib/pleroma/web/api_spec/operations/admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex index 25a604beb..58669a1fc 100644 --- a/lib/pleroma/web/api_spec/operations/admin/report_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex @@ -53,6 +53,12 @@ defmodule Pleroma.Web.ApiSpec.Admin.ReportOperation do :query, %Schema{type: :integer, default: 50}, "Number number of log entries per page" + ), + Operation.parameter( + :assigned_account, + :query, + %Schema{type: :string}, + "Filter by assigned account ID" ) | admin_api_params() ], @@ -103,6 +109,22 @@ defmodule Pleroma.Web.ApiSpec.Admin.ReportOperation do } end + def assign_account_operation do + %Operation{ + tags: ["Report management"], + summary: "Assign account to specified reports", + operationId: "AdminAPI.ReportController.assign_account", + security: [%{"oAuth" => ["admin:write:reports"]}], + parameters: admin_api_params(), + requestBody: request_body("Parameters", assign_account_request(), required: true), + responses: %{ + 204 => no_content_response(), + 400 => Operation.response("Bad Request", "application/json", update_400_response()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + def notes_create_operation do %Operation{ tags: ["Report management"], @@ -186,7 +208,10 @@ defmodule Pleroma.Web.ApiSpec.Admin.ReportOperation do hint: %Schema{type: :string, nullable: true} } } - } + }, + assigned_account: + account_admin() + |> Map.put(:nullable, true) } } end @@ -242,6 +267,34 @@ defmodule Pleroma.Web.ApiSpec.Admin.ReportOperation do } end + defp assign_account_request do + %Schema{ + type: :object, + required: [:reports], + properties: %{ + reports: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{allOf: [FlakeID], description: "Required, report ID"}, + assigned_account: %Schema{ + type: :string, + description: "User nickname", + nullable: true + } + } + }, + example: %{ + "reports" => [ + %{"id" => "123", "assigned_account" => "pleroma"} + ] + } + } + } + } + end + defp update_400_response do %Schema{ type: :array, diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 8e96ef5b6..04181ad8f 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -709,6 +709,22 @@ defmodule Pleroma.Web.CommonAPI do end end + def assign_report_to_account(activity_ids, user) when is_list(activity_ids) do + case Utils.assign_report_to_account(activity_ids, user) do + :ok -> {:ok, activity_ids} + _ -> {:error, dgettext("errors", "Could not assign account")} + end + end + + def assign_report_to_account(activity_id, user) do + with %Activity{} = activity <- Activity.get_by_id(activity_id) do + Utils.assign_report_to_account(activity, user) + else + nil -> {:error, :not_found} + _ -> {:error, dgettext("errors", "Could not assign account")} + end + end + @spec update_activity_scope(String.t(), map()) :: {:ok, any()} | {:error, any()} def update_activity_scope(activity_id, opts \\ %{}) do with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 008f48575..20ac1c67b 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -395,6 +395,7 @@ defmodule Pleroma.Web.Router do get("/reports", ReportController, :index) get("/reports/:id", ReportController, :show) patch("/reports", ReportController, :update) + post("/reports/assign_account", ReportController, :assign_account) post("/reports/:id/notes", ReportController, :notes_create) delete("/reports/:report_id/notes/:id", ReportController, :notes_delete) end diff --git a/priv/repo/migrations/20220225164000_add_activity_assigned_account_index.exs b/priv/repo/migrations/20220225164000_add_activity_assigned_account_index.exs new file mode 100644 index 000000000..b54daf43d --- /dev/null +++ b/priv/repo/migrations/20220225164000_add_activity_assigned_account_index.exs @@ -0,0 +1,11 @@ +defmodule Pleroma.Repo.Migrations.AddActivityAssignedAccountIndex do + use Ecto.Migration + + def change do + create_if_not_exists( + index(:activities, ["(data->>'assigned_account')"], + name: :activities_assigned_account_index + ) + ) + end +end diff --git a/test/pleroma/web/activity_pub/utils_test.exs b/test/pleroma/web/activity_pub/utils_test.exs index f162f3684..3b77f0867 100644 --- a/test/pleroma/web/activity_pub/utils_test.exs +++ b/test/pleroma/web/activity_pub/utils_test.exs @@ -671,6 +671,19 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do end end + describe "assign_report_to_account/2" do + test "assigns report to an account" do + reporter = insert(:user) + target_account = insert(:user) + %{id: assigned_id} = insert(:user) + + {:ok, report} = CommonAPI.report(reporter, %{account_id: target_account.id}) + {:ok, report} = Utils.assign_report_to_account(report, assigned_id) + + assert %{data: %{"assigned_account" => ^assigned_id}} = report + end + end + describe "maybe_anonymize_reporter/1" do setup do reporter = insert(:user) diff --git a/test/pleroma/web/admin_api/controllers/report_controller_test.exs b/test/pleroma/web/admin_api/controllers/report_controller_test.exs index b626ddf55..9fbb608c4 100644 --- a/test/pleroma/web/admin_api/controllers/report_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/report_controller_test.exs @@ -388,6 +388,38 @@ defmodule Pleroma.Web.AdminAPI.ReportControllerTest do |> json_response_and_validate_schema(:ok) end + test "returns reports with specified assigned user", %{conn: conn, admin: admin} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, _report} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + {:ok, %{id: second_report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I don't like this user" + }) + + CommonAPI.assign_report_to_account(second_report_id, admin.id) + + response = + conn + |> get(report_path(conn, :index, %{assigned_account: admin.id})) + |> json_response_and_validate_schema(:ok) + + assert [open_report] = response["reports"] + + assert length(response["reports"]) == 1 + assert open_report["id"] == second_report_id + + assert response["total"] == 1 + end + test "renders content correctly", %{conn: conn} do [reporter, target_user] = insert_pair(:user) note = insert(:note, user: target_user, data: %{"content" => "mew 1"}) @@ -467,6 +499,66 @@ defmodule Pleroma.Web.AdminAPI.ReportControllerTest do end end + describe "POST /api/pleroma/admin/reports/assign_account" do + test "assigns account to report", %{conn: conn, admin: admin} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + status_ids: [activity.id] + }) + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/reports/assign_account", %{ + "reports" => [ + %{"assigned_account" => admin.nickname, "id" => report_id} + ] + }) + |> json_response_and_validate_schema(:no_content) + + activity = Activity.get_by_id_with_user_actor(report_id) + assert activity.data["assigned_account"] == admin.id + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} assigned report ##{report_id} (on user @#{activity.user_actor.nickname}) to user #{admin.nickname}" + end + + test "unassigns account from report", %{conn: conn, admin: admin} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + status_ids: [activity.id] + }) + + CommonAPI.assign_report_to_account(report_id, admin.id) + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/reports/assign_account", %{ + "reports" => [ + %{"assigned_account" => nil, "id" => report_id} + ] + }) + |> json_response_and_validate_schema(:no_content) + + activity = Activity.get_by_id_with_user_actor(report_id) + assert activity.data["assigned_account"] == nil + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} unassigned report ##{report_id} (on user @#{activity.user_actor.nickname}) from a user" + end + end + describe "POST /api/pleroma/admin/reports/:id/notes" do setup %{conn: conn, admin: admin} do clear_config([:instance, :admin_privileges], [:reports_manage_reports]) diff --git a/test/pleroma/web/admin_api/views/report_view_test.exs b/test/pleroma/web/admin_api/views/report_view_test.exs index 1b16aca6a..6e155ef58 100644 --- a/test/pleroma/web/admin_api/views/report_view_test.exs +++ b/test/pleroma/web/admin_api/views/report_view_test.exs @@ -36,6 +36,7 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do }), AdminAPI.AccountView.render("show.json", %{user: other_user}) ), + assigned_account: nil, statuses: [], notes: [], state: "open", @@ -75,6 +76,7 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do }), AdminAPI.AccountView.render("show.json", %{user: other_user}) ), + assigned_account: nil, statuses: [StatusView.render("show.json", %{activity: activity})], state: "open", notes: [], diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 52829b734..4eb057712 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -1458,6 +1458,29 @@ defmodule Pleroma.Web.CommonAPITest do } } = flag_activity end + + test "assigns report to an account" do + [reporter, target_user] = insert_pair(:user) + %{id: assigned} = insert(:user) + + {:ok, %Activity{id: report_id}} = CommonAPI.report(reporter, %{account_id: target_user.id}) + + {:ok, activity} = CommonAPI.assign_report_to_account(report_id, assigned) + + assert %{data: %{"assigned_account" => ^assigned}} = activity + end + + test "unassigns report from account" do + [reporter, target_user] = insert_pair(:user) + %{id: assigned} = insert(:user) + + {:ok, %Activity{id: report_id}} = CommonAPI.report(reporter, %{account_id: target_user.id}) + + CommonAPI.assign_report_to_account(report_id, assigned) + {:ok, activity} = CommonAPI.assign_report_to_account(report_id, nil) + + refute Map.has_key?(activity.data, "assigned_account") + end end describe "reblog muting" do From 6fac6ff7f1aa61a80295097ef2760b98c00313ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 28 Jan 2026 13:49:34 +0100 Subject: [PATCH 10/26] MastoAPI AccountView: Add mute/block expiry to the relationship object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- lib/pleroma/user_relationship.ex | 2 +- .../api_spec/schemas/account_relationship.ex | 4 +- .../web/mastodon_api/views/account_view.ex | 76 +++++++++++++------ 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index 07b6e46f7..cf5025670 100644 --- a/lib/pleroma/user_relationship.ex +++ b/lib/pleroma/user_relationship.ex @@ -45,7 +45,7 @@ defmodule Pleroma.UserRelationship do do: exists?(unquote(relationship_type), source, target) # `def get_block_expire_date/2`, `def get_mute_expire_date/2`, - # `def get_reblog_mute_expire_date/2`, `def get_notification_mute_exists?/2`, + # `def get_reblog_mute_expire_date/2`, `def get_notification_mute_expire_date/2`, # `def get_inverse_subscription_expire_date/2`, `def get_inverse_endorsement_expire_date/2` def unquote(:"get_#{relationship_type}_expire_date")(source, target), do: get_expire_date(unquote(relationship_type), source, target) diff --git a/lib/pleroma/web/api_spec/schemas/account_relationship.ex b/lib/pleroma/web/api_spec/schemas/account_relationship.ex index 68219a099..247a94bb9 100644 --- a/lib/pleroma/web/api_spec/schemas/account_relationship.ex +++ b/lib/pleroma/web/api_spec/schemas/account_relationship.ex @@ -26,7 +26,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationship do requested: %Schema{type: :boolean}, showing_reblogs: %Schema{type: :boolean}, subscribing: %Schema{type: :boolean}, - notifying: %Schema{type: :boolean} + notifying: %Schema{type: :boolean}, + mute_expires_at: %Schema{type: :string, format: "date-time", nullable: true}, + block_expires_at: %Schema{type: :string, format: "date-time", nullable: true} }, example: %{ "blocked_by" => false, diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index a7d994593..bfcc170d6 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -96,6 +96,24 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do followed_by = FollowingRelationship.following?(target, reading_user) following = FollowingRelationship.following?(reading_user, target) + blocking = + UserRelationship.exists?( + user_relationships, + :block, + reading_user, + target, + &User.blocks_user?(&1, &2) + ) + + muting = + UserRelationship.exists?( + user_relationships, + :mute, + reading_user, + target, + &User.mutes?(&1, &2) + ) + requested = cond do following -> false @@ -116,14 +134,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do id: to_string(target.id), following: following, followed_by: followed_by, - blocking: - UserRelationship.exists?( - user_relationships, - :block, - reading_user, - target, - &User.blocks_user?(&1, &2) - ), + blocking: blocking, blocked_by: UserRelationship.exists?( user_relationships, @@ -132,14 +143,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do reading_user, &User.blocks_user?(&1, &2) ), - muting: - UserRelationship.exists?( - user_relationships, - :mute, - reading_user, - target, - &User.mutes?(&1, &2) - ), + muting: muting, muting_notifications: UserRelationship.exists?( user_relationships, @@ -174,6 +178,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do &User.endorses?(&1, &2) ) } + |> maybe_put_mute_expires_at(target, reading_user, %{mutes: muting}) + |> maybe_put_block_expires_at(target, reading_user, %{blocks: blocking}) end def render("relationships.json", %{user: user, targets: targets} = opts) do @@ -343,8 +349,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], opts) - |> maybe_put_block_expires_at(user, opts[:for], opts) + |> maybe_put_mute_expires_at(user, opts[:for], opts, relationship) + |> maybe_put_block_expires_at(user, opts[:for], opts, relationship) |> maybe_show_birthday(user, opts[:for]) end @@ -472,25 +478,47 @@ 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 + defp maybe_put_mute_expires_at(data, target, user, opts, relationship \\ nil) + + defp maybe_put_mute_expires_at(data, _target, _user, %{mutes: true}, %{ + mute_expires_at: mute_expires_at + }) do + Map.put(data, :mute_expires_at, mute_expires_at) + end + + defp maybe_put_mute_expires_at(data, %User{} = target, user, %{mutes: true}, _relationship) do Map.put( data, :mute_expires_at, - UserRelationship.get_mute_expire_date(target, user) + UserRelationship.get_mute_expire_date(user, target) ) end - defp maybe_put_mute_expires_at(data, _, _, _), do: data + defp maybe_put_mute_expires_at(data, _, _, _, _), do: data - defp maybe_put_block_expires_at(data, %User{} = user, target, %{blocks: true}) do + defp maybe_put_block_expires_at(data, target, user, opts, relationship \\ nil) + + defp maybe_put_block_expires_at(data, _target, _user, %{blocks: true}, %{ + block_expires_at: block_expires_at + }) do + Map.put(data, :block_expires_at, block_expires_at) + end + + defp maybe_put_block_expires_at( + data, + %User{} = target, + %User{} = user, + %{blocks: true}, + _relationship + ) do Map.put( data, :block_expires_at, - UserRelationship.get_block_expire_date(target, user) + UserRelationship.get_block_expire_date(user, target) ) end - defp maybe_put_block_expires_at(data, _, _, _), do: data + 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 From e7a4d5ea66af84aca8f972e65308c231abb4d2a7 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 22 Jan 2026 23:01:11 +0100 Subject: [PATCH 11/26] MastoAPI AccountView: Add mute/block expiry to the relationship key --- .../mastodon_api/views/account_view_test.exs | 78 ++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) 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 c1e33bfadbc82cc7f019927b4b77a07883501cb7 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 24 Jan 2026 21:35:27 +0100 Subject: [PATCH 12/26] 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( From bc0c7fb3105d1c9fb0f54390f37f35b613062976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 28 Jan 2026 13:58:33 +0100 Subject: [PATCH 13/26] Fix tests, relationship should always define `_expires_at` 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 | 2 ++ test/pleroma/web/mastodon_api/views/account_view_test.exs | 6 ++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index bfcc170d6..29c63eb60 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -143,6 +143,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do reading_user, &User.blocks_user?(&1, &2) ), + block_expires_at: nil, muting: muting, muting_notifications: UserRelationship.exists?( @@ -152,6 +153,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do target, &User.muted_notifications?(&1, &2) ), + mute_expires_at: nil, subscribing: subscribing, notifying: subscribing, requested: requested, 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 6984442cc..c230cf653 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -909,8 +909,7 @@ 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( @@ -931,8 +930,7 @@ 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( From 5001fb3a78737273fef7c36a9507ba0e35fd47f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 28 Jan 2026 14:02:55 +0100 Subject: [PATCH 14/26] Update changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/relationship-expires-at.change | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/relationship-expires-at.change diff --git a/changelog.d/relationship-expires-at.change b/changelog.d/relationship-expires-at.change new file mode 100644 index 000000000..286dba197 --- /dev/null +++ b/changelog.d/relationship-expires-at.change @@ -0,0 +1 @@ +Add mute/block expiry to the relationship object From bd30d461b05cc8b29f5a0818596ea640e2484487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 28 Jan 2026 22:02:22 +0100 Subject: [PATCH 15/26] Add /api/v2/instance profile fields limits info used by Mastodon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/instance-profile-fields.add | 1 + .../web/api_spec/operations/instance_operation.ex | 12 ++++++++++++ lib/pleroma/web/mastodon_api/views/instance_view.ex | 9 +++++++++ 3 files changed, 22 insertions(+) create mode 100644 changelog.d/instance-profile-fields.add diff --git a/changelog.d/instance-profile-fields.add b/changelog.d/instance-profile-fields.add new file mode 100644 index 000000000..712bd68d9 --- /dev/null +++ b/changelog.d/instance-profile-fields.add @@ -0,0 +1 @@ +Add /api/v2/instance profile fields limits info used by Mastodon diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index d8b2901d3..6be4ea996 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -342,6 +342,18 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do max_pinned_statuses: %Schema{ type: :integer, description: "The maximum number of pinned statuses for each account." + }, + max_profile_fields: %Schema{ + type: :integer, + description: "The maximum number of custom profile fields allowed to be set." + }, + profile_field_name_limit: %Schema{ + type: :integer, + description: "The maximum size of a profile field name, in characters." + }, + profile_field_value_limit: %Schema{ + type: :integer, + description: "The maximum size of a profile field value, in characters." } } }, diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 0dc7a5fea..d64ce4fb5 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -303,6 +303,15 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do defp configuration2 do configuration() |> put_in([:accounts, :max_pinned_statuses], Config.get([:instance, :max_pinned_statuses], 0)) + |> put_in([:accounts, :max_profile_fields], Config.get([:instance, :max_account_fields])) + |> put_in( + [:accounts, :profile_field_name_limit], + Config.get([:instance, :account_field_name_length]) + ) + |> put_in( + [:accounts, :profile_field_value_limit], + Config.get([:instance, :account_field_value_length]) + ) |> put_in([:statuses, :characters_reserved_per_url], 0) |> Map.merge(%{ urls: %{ From feda4d0718b06732bee9011bc95485159f5f61f5 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 9 Feb 2026 09:01:16 +0400 Subject: [PATCH 16/26] CI: Add basic woodpecker file --- .woodpecker.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .woodpecker.yml diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 000000000..9cc4f7261 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,29 @@ +when: + - event: + - push + - pull_request + +steps: + test: + image: elixir:1.15-alpine + environment: + MIX_ENV: test + DB_HOST: postgres + DB_PORT: 5432 + commands: + - apk add --no-cache build-base cmake exiftool ffmpeg file-dev git openssl + - adduser -D -h /home/testuser testuser + - mkdir -p /home/testuser/.mix /home/testuser/.hex + - chown -R testuser:testuser . /home/testuser + - su testuser -c "HOME=/home/testuser mix local.hex --force" + - su testuser -c "HOME=/home/testuser mix local.rebar --force" + - su testuser -c "HOME=/home/testuser mix deps.get" + - su testuser -c "HOME=/home/testuser mix test" + +services: + postgres: + image: postgres:13-alpine + environment: + POSTGRES_DB: pleroma_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres From 4693dc837b8d8da7276b7d1816eea684b7b6bdfa Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 9 Feb 2026 10:13:29 +0400 Subject: [PATCH 17/26] CI: Only run on PR --- .woodpecker.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 9cc4f7261..4ceb1cab5 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -1,6 +1,5 @@ when: - event: - - push - pull_request steps: From b798f7d6e9f57f80851eb5e1eacf49711e4f5f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 10 Feb 2026 14:51:37 +0100 Subject: [PATCH 18/26] Add issue and pull request templates for Forgejo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .forgejo/issue_template/bug.yaml | 25 +++++++++++++++++++++++++ .forgejo/pull_request_template.md | 10 ++++++++++ 2 files changed, 35 insertions(+) create mode 100644 .forgejo/issue_template/bug.yaml create mode 100644 .forgejo/pull_request_template.md diff --git a/.forgejo/issue_template/bug.yaml b/.forgejo/issue_template/bug.yaml new file mode 100644 index 000000000..1b92658f7 --- /dev/null +++ b/.forgejo/issue_template/bug.yaml @@ -0,0 +1,25 @@ +name: 'Bug report' +about: 'Report a bug in Pleroma' +body: +- type: markdown + attributes: + value: | + ### Precheck + + * For support use https://git.pleroma.social/pleroma/pleroma-support or [community channels](https://git.pleroma.social/pleroma/pleroma#community-channels). + * Please do a quick search to ensure no similar bug has been reported before. If the bug has not been addressed after 2 weeks, it's fine to bump it. + * Try to ensure that the bug is actually related to the Pleroma backend. For example, if a bug happens in Pleroma-FE but not in Mastodon-FE or mobile clients, it's likely that the bug should be filed in [Pleroma-FE](https://git.pleroma.social/pleroma/pleroma-fe/issues/new) repository. +- type: textarea + id: environment + attributes: + label: Environment + value: | + * Installation type (OTP or From Source): + * Pleroma version (could be found in the "Version" tab of settings in Pleroma-FE): + * Elixir version (`elixir -v` for from source installations, N/A for OTP): + * Operating system: + * PostgreSQL version (`psql -V`): +- type: textarea + id: bug-description + attributes: + label: Bug description \ No newline at end of file diff --git a/.forgejo/pull_request_template.md b/.forgejo/pull_request_template.md new file mode 100644 index 000000000..799da5355 --- /dev/null +++ b/.forgejo/pull_request_template.md @@ -0,0 +1,10 @@ +### Checklist +- [ ] Adding a changelog: In the `changelog.d` directory, create a file named `.`. + + `` can be anything, but we recommend using a more or less unique identifier to avoid collisions, such as the branch name. + + `` can be `add`, `change`, `remove`, `fix`, `security` or `skip`. `skip` is only used if there is no user-visible change in the PR (for example, only editing comments in the code). Otherwise, choose a type that corresponds to your change. + + In the file, write the changelog entry. For example, if a PR adds group functionality, we can create a file named `group.add` and write `Add group functionality` in it. + + If one changelog entry is not enough, you may add more. But that might mean you can split it into two PRs. Only use more than one changelog entry if you really need to (for example, when one change in the code fix two different bugs, or when refactoring). \ No newline at end of file From 1c685ea41a771d74a1d4e1769a28911d738591b0 Mon Sep 17 00:00:00 2001 From: feld Date: Thu, 12 Feb 2026 00:34:08 +0000 Subject: [PATCH 19/26] Update README.md fix logo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a5eb238f..982a7249a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + ## About From 3d9ac413af0626bc60176b205eb2f23a8c721ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Tue, 17 Feb 2026 14:00:21 +0100 Subject: [PATCH 20/26] Move avatar_description and header_description fields to the account object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/avatar-description-mastodon-api.change | 1 + docs/development/API/differences_in_mastoapi_responses.md | 2 -- lib/pleroma/web/api_spec/schemas/account.ex | 6 ++++-- lib/pleroma/web/mastodon_api/views/account_view.ex | 2 ++ test/pleroma/web/mastodon_api/views/account_view_test.exs | 4 ++++ 5 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 changelog.d/avatar-description-mastodon-api.change diff --git a/changelog.d/avatar-description-mastodon-api.change b/changelog.d/avatar-description-mastodon-api.change new file mode 100644 index 000000000..6a454c01e --- /dev/null +++ b/changelog.d/avatar-description-mastodon-api.change @@ -0,0 +1 @@ +Move avatar_description and header_description fields to the account object diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index 052b2716b..358d05bf4 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -127,8 +127,6 @@ Has these additional fields under the `pleroma` object: - `notification_settings`: object, can be absent. See `/api/v1/pleroma/notification_settings` for the parameters/keys returned. - `accepts_chat_messages`: boolean, but can be null if we don't have that information about a user - `favicon`: nullable URL string, Favicon image of the user's instance -- `avatar_description`: string, image description for user avatar, defaults to empty string -- `header_description`: string, image description for user banner, defaults to empty string ### Source diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 7d0b83afe..efdced316 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -21,6 +21,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do acct: %Schema{type: :string}, avatar_static: %Schema{type: :string, format: :uri}, avatar: %Schema{type: :string, format: :uri}, + avatar_description: %Schema{type: :string}, bot: %Schema{type: :boolean}, created_at: %Schema{type: :string, format: "date-time"}, display_name: %Schema{type: :string}, @@ -31,6 +32,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do following_count: %Schema{type: :integer}, header_static: %Schema{type: :string, format: :uri}, header: %Schema{type: :string, format: :uri}, + header_description: %Schema{type: :string}, id: FlakeID, locked: %Schema{type: :boolean}, note: %Schema{type: :string, format: :html}, @@ -111,8 +113,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do nullable: true, description: "Favicon image of the user's instance" }, - avatar_description: %Schema{type: :string}, - header_description: %Schema{type: :string} + avatar_description: %Schema{type: :string, deprecated: true}, + header_description: %Schema{type: :string, deprecated: true} } }, source: %Schema{ diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 29c63eb60..5386c5a6c 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -300,8 +300,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do note: user.bio, url: user.uri || user.ap_id, avatar: avatar, + avatar_description: avatar_description, avatar_static: avatar_static, header: header, + header_description: header_description, header_static: header_static, emojis: emojis, fields: user.fields, 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..f34a801c1 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -54,8 +54,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do note: "valid html. a
b
c
d
f '&<>"", url: user.ap_id, avatar: "http://localhost:4001/images/avi.png", + avatar_description: "", avatar_static: "http://localhost:4001/images/avi.png", header: "http://localhost:4001/images/banner.png", + header_description: "", header_static: "http://localhost:4001/images/banner.png", emojis: [ %{ @@ -326,8 +328,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do note: user.bio, url: user.ap_id, avatar: "http://localhost:4001/images/avi.png", + avatar_description: "", avatar_static: "http://localhost:4001/images/avi.png", header: "http://localhost:4001/images/banner.png", + header_description: "", header_static: "http://localhost:4001/images/banner.png", emojis: [], fields: [], From 0b950f62533579960970a80e11d875036444cd18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 18 Feb 2026 13:37:10 +0100 Subject: [PATCH 21/26] comment out stuff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .forgejo/pull_request_template.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.forgejo/pull_request_template.md b/.forgejo/pull_request_template.md index 799da5355..5bb204a14 100644 --- a/.forgejo/pull_request_template.md +++ b/.forgejo/pull_request_template.md @@ -1,10 +1,13 @@ ### Checklist + - [ ] Adding a changelog: In the `changelog.d` directory, create a file named `.`. + From e32ab8aef278ebcef7ecbc9ae67f417b6862cff9 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 14 Oct 2025 22:59:15 +0200 Subject: [PATCH 22/26] DB prune: Check if user follows hashtag with no objects before deletion --- changelog.d/prune-hashtag-follow-3376.fix | 1 + lib/mix/tasks/pleroma/database.ex | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelog.d/prune-hashtag-follow-3376.fix diff --git a/changelog.d/prune-hashtag-follow-3376.fix b/changelog.d/prune-hashtag-follow-3376.fix new file mode 100644 index 000000000..cdb4e9a79 --- /dev/null +++ b/changelog.d/prune-hashtag-follow-3376.fix @@ -0,0 +1 @@ +DB prune: Check if user follows hashtag with no objects before deletion diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index e52b5e0a7..396536827 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -226,7 +226,12 @@ defmodule Mix.Tasks.Pleroma.Database do DELETE FROM hashtags AS ht WHERE NOT EXISTS ( SELECT 1 FROM hashtags_objects hto - WHERE ht.id = hto.hashtag_id) + WHERE ht.id = hto.hashtag_id + ) + AND NOT EXISTS ( + SELECT 1 FROM user_follows_hashtag ufh + WHERE ht.id = ufh.hashtag_id + ) """ |> Repo.query() From ef7be0a1e513841beba15ccce6b6211b7369332a Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 15 Oct 2025 23:14:52 +0200 Subject: [PATCH 23/26] DB prune: Add test for hashtags --- test/mix/tasks/pleroma/database_test.exs | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/mix/tasks/pleroma/database_test.exs b/test/mix/tasks/pleroma/database_test.exs index 19df17b60..5b567325c 100644 --- a/test/mix/tasks/pleroma/database_test.exs +++ b/test/mix/tasks/pleroma/database_test.exs @@ -8,6 +8,7 @@ defmodule Mix.Tasks.Pleroma.DatabaseTest do alias Pleroma.Activity alias Pleroma.Bookmark + alias Pleroma.Hashtag alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User @@ -550,6 +551,39 @@ defmodule Mix.Tasks.Pleroma.DatabaseTest do assert length(activities) == 3 end + + test "it prunes hashtags with no objects associated", %{old_insert_date: old_insert_date} do + user = insert(:user) + + {:ok, hashtag_post_activity} = + CommonAPI.post(user, %{status: "morning #cofe", local: true}) + + hashtag_post_object = Object.normalize(hashtag_post_activity) + + {:ok, hashtag_post2_activity} = + CommonAPI.post(user, %{status: "morning #cawfee", local: true}) + + hashtag_post2_object = Object.normalize(hashtag_post2_activity) + + hashtag_post_object + |> Ecto.Changeset.change(%{updated_at: old_insert_date}) + |> Repo.update!() + + hashtag_post2_object + |> Ecto.Changeset.change(%{updated_at: old_insert_date}) + |> Repo.update!() + + # Test whether hashtags with follow relationships are kept + User.follow_hashtag(user, Hashtag.get_by_name("cofe")) + + assert length(Repo.all(Hashtag)) == 2 + assert length(Repo.all(Object)) == 2 + + Mix.Tasks.Pleroma.Database.run(["prune_objects"]) + assert length(Repo.all(Hashtag)) == 1 + assert length(Repo.all(Object)) == 0 + assert Repo.one(Hashtag) |> Map.fetch!(:name) == "cofe" + end end describe "running update_users_following_followers_counts" do From c392b21db18e416f2af7e917cb1a7f3a6cfa130d Mon Sep 17 00:00:00 2001 From: mkljczk Date: Fri, 27 Feb 2026 09:22:14 +0000 Subject: [PATCH 24/26] Update docs on scrobbles --- docs/development/API/pleroma_api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/development/API/pleroma_api.md b/docs/development/API/pleroma_api.md index 7946ba1f6..b19523bce 100644 --- a/docs/development/API/pleroma_api.md +++ b/docs/development/API/pleroma_api.md @@ -690,6 +690,7 @@ Audio scrobbling in Pleroma is **deprecated**. * `album`: the album of the media playing [optional] * `artist`: the artist of the media playing [optional] * `length`: the length of the media playing [optional] + * `external_link`: a URL referencing the media playing [optional] * Response: the newly created media metadata entity representing the Listen activity # Emoji Reactions From 938ee4cb01c12a4243d3518d8bba7dc817f34071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 27 Feb 2026 12:04:25 +0100 Subject: [PATCH 25/26] mix.exs: use correct override value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/mix-exs-fix.skip | 0 mix.exs | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 changelog.d/mix-exs-fix.skip diff --git a/changelog.d/mix-exs-fix.skip b/changelog.d/mix-exs-fix.skip new file mode 100644 index 000000000..e69de29bb diff --git a/mix.exs b/mix.exs index a4415fddc..0532bffd7 100644 --- a/mix.exs +++ b/mix.exs @@ -154,7 +154,7 @@ defmodule Pleroma.Mixfile do {:gun, "~> 2.2"}, {:finch, "~> 0.15"}, {:jason, "~> 1.2"}, - {:mogrify, "~> 0.9.0", override: "true"}, + {:mogrify, "~> 0.9.0", override: true}, {:ex_aws, "~> 2.1.6"}, {:ex_aws_s3, "~> 2.0"}, {:sweet_xml, "~> 0.7.2"}, From a9b5a28c26324bcb02ed20c39ace80d33a636244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 27 Feb 2026 16:24:10 +0100 Subject: [PATCH 26/26] Do not use Enum.map for side-effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/map-side-effects.skip | 0 lib/mix/tasks/pleroma/openapi_spec.ex | 2 +- lib/pleroma/activity/html.ex | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 changelog.d/map-side-effects.skip diff --git a/changelog.d/map-side-effects.skip b/changelog.d/map-side-effects.skip new file mode 100644 index 000000000..e69de29bb diff --git a/lib/mix/tasks/pleroma/openapi_spec.ex b/lib/mix/tasks/pleroma/openapi_spec.ex index 1ea468476..852e1e9af 100644 --- a/lib/mix/tasks/pleroma/openapi_spec.ex +++ b/lib/mix/tasks/pleroma/openapi_spec.ex @@ -22,7 +22,7 @@ defmodule Mix.Tasks.Pleroma.OpenapiSpec do else {_, errors} -> IO.puts(IO.ANSI.format([:red, :bright, "Spec check failed, errors:"])) - Enum.map(errors, &IO.puts/1) + Enum.each(errors, &IO.puts/1) raise "Spec check failed" end diff --git a/lib/pleroma/activity/html.ex b/lib/pleroma/activity/html.ex index ba284b4d5..c83889c87 100644 --- a/lib/pleroma/activity/html.ex +++ b/lib/pleroma/activity/html.ex @@ -38,7 +38,7 @@ defmodule Pleroma.Activity.HTML do def invalidate_cache_for(activity_id) do keys = get_cache_keys_for(activity_id) - Enum.map(keys, &@cachex.del(:scrubber_cache, &1)) + Enum.each(keys, &@cachex.del(:scrubber_cache, &1)) @cachex.del(:scrubber_management_cache, activity_id) end