diff --git a/changelog.d/fix-preferred-frontend.fix b/changelog.d/fix-preferred-frontend.fix
new file mode 100644
index 000000000..07bf4d0c6
--- /dev/null
+++ b/changelog.d/fix-preferred-frontend.fix
@@ -0,0 +1 @@
+Fix preferred frontend not working due to InstanceStatic plug prematurely returning HTTP 404
diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex
index 07b33f866..8552f5d0a 100644
--- a/lib/pleroma/web/endpoint.ex
+++ b/lib/pleroma/web/endpoint.ex
@@ -139,6 +139,8 @@ defmodule Pleroma.Web.Endpoint do
from: {:pleroma, "priv/static/adminfe/"}
)
+ plug(Pleroma.Web.Plugs.StaticNotFoundPlug)
+
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
diff --git a/lib/pleroma/web/plugs/ensure_host_matches_plug.ex b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex
index ab2129f9e..960e86d4f 100644
--- a/lib/pleroma/web/plugs/ensure_host_matches_plug.ex
+++ b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex
@@ -4,6 +4,7 @@
defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlug do
@moduledoc "Ensures Host header matches instance"
+ @behaviour Plug
alias Pleroma.Web.Endpoint
diff --git a/lib/pleroma/web/plugs/instance_static.ex b/lib/pleroma/web/plugs/instance_static.ex
index 948188287..d2a674d39 100644
--- a/lib/pleroma/web/plugs/instance_static.ex
+++ b/lib/pleroma/web/plugs/instance_static.ex
@@ -4,7 +4,7 @@
defmodule Pleroma.Web.Plugs.InstanceStatic do
require Pleroma.Constants
- import Plug.Conn, only: [put_resp_header: 3, put_status: 2, send_resp: 3, halt: 1]
+ import Plug.Conn, only: [put_resp_header: 3]
@moduledoc """
This is a shim to call `Plug.Static` but with runtime `from` configuration.
@@ -51,37 +51,10 @@ defmodule Pleroma.Web.Plugs.InstanceStatic do
|> Map.put(:from, from)
|> Map.put(:content_types, false)
- conn =
- conn
- |> set_content_type(conn.request_path)
- |> Plug.Static.call(opts)
+ conn = set_content_type(conn, conn.request_path)
- if conn.halted do
- conn
- else
- path = String.trim_leading(conn.request_path, "/")
-
- if not File.exists?(file_path(path)) do
- conn
- |> put_status(:not_found)
- |> send_404()
- |> halt()
- else
- conn
- end
- end
- end
-
- defp send_404(conn) do
- if String.ends_with?(String.downcase(conn.request_path), ".json") do
- conn
- |> put_resp_header("content-type", "application/json")
- |> send_resp(404, Jason.encode!(%{error: "not found"}))
- else
- conn
- |> put_resp_header("content-type", "text/plain")
- |> send_resp(404, "Not found")
- end
+ # Call Plug.Static with our sanitized content-type
+ Plug.Static.call(conn, opts)
end
defp set_content_type(conn, "/emoji/" <> filepath) do
diff --git a/lib/pleroma/web/plugs/static_not_found_plug.ex b/lib/pleroma/web/plugs/static_not_found_plug.ex
new file mode 100644
index 000000000..e2e7a622c
--- /dev/null
+++ b/lib/pleroma/web/plugs/static_not_found_plug.ex
@@ -0,0 +1,38 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2026 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Plugs.StaticNotFoundPlug do
+ @moduledoc """
+ A small plug that enforces returning 404 on static files when they don't exist.
+ """
+ @behaviour Plug
+
+ require Pleroma.Constants
+ import Plug.Conn, only: [put_resp_header: 3, send_resp: 3, halt: 1]
+
+ def init(opts), do: opts
+
+ for only <- Pleroma.Constants.static_only_files() do
+ def call(%{request_path: "/" <> unquote(only) <> _} = conn, _opts) do
+ send_404(conn)
+ end
+ end
+
+ def call(conn, _) do
+ conn
+ end
+
+ defp send_404(conn) do
+ if String.ends_with?(String.downcase(conn.request_path), ".json") do
+ conn
+ |> put_resp_header("content-type", "application/json")
+ |> send_resp(404, Jason.encode!(%{error: "not found"}))
+ else
+ conn
+ |> put_resp_header("content-type", "text/plain")
+ |> send_resp(404, "Not found")
+ end
+ |> halt()
+ end
+end
diff --git a/test/pleroma/web/plugs/frontend_static_plug_test.exs b/test/pleroma/web/plugs/frontend_static_plug_test.exs
index b7c06eacd..7dc7e52cf 100644
--- a/test/pleroma/web/plugs/frontend_static_plug_test.exs
+++ b/test/pleroma/web/plugs/frontend_static_plug_test.exs
@@ -122,16 +122,27 @@ defmodule Pleroma.Web.Plugs.FrontendStaticPlugTest do
clear_config([:frontends, :pickable], ["#{name}/#{ref}"])
path = "#{@dir}/frontends/#{name}/#{ref}"
+ path2 = "#{@dir}/frontends/#{name}/#{ref}/packs/html"
Pleroma.Backports.mkdir_p!(path)
File.write!("#{path}/index.html", "from frontend plug")
+ Pleroma.Backports.mkdir_p!(path2)
+ File.write!("#{path2}/custom.html", "from frontend plug")
+
index =
conn
|> put_req_cookie("preferred_frontend", "#{name}/#{ref}")
|> get("/")
assert html_response(index, 200) == "from frontend plug"
+
+ custom =
+ conn
+ |> put_req_cookie("preferred_frontend", "#{name}/#{ref}")
+ |> get("/packs/html/custom.html")
+
+ assert html_response(custom, 200) == "from frontend plug"
end
test "only returns content from pickable frontends", %{conn: conn} do
diff --git a/test/pleroma/web/plugs/instance_static_test.exs b/test/pleroma/web/plugs/instance_static_test.exs
index 017c49d1e..1d67e8dc3 100644
--- a/test/pleroma/web/plugs/instance_static_test.exs
+++ b/test/pleroma/web/plugs/instance_static_test.exs
@@ -61,6 +61,28 @@ defmodule Pleroma.Web.Plugs.InstanceStaticTest do
File.write!(@dir <> "/static/kaniini.html", "
rabbit hugs as a service
")
index = get(build_conn(), "/static/kaniini.html")
assert html_response(index, 200) == "rabbit hugs as a service
"
+
+ File.write!(@dir <> "/static/kaniini the bnuuy.html", "rabbit hugs as a service
")
+ index = get(build_conn(), "/static/kaniini%20the%20bnuuy.html")
+ assert html_response(index, 200) == "rabbit hugs as a service
"
+ end
+
+ test "overrides /static/styles files with spaces in URL" do
+ Pleroma.Backports.mkdir_p!(@dir <> "/static/styles")
+ File.write(@dir <> "/static/styles/Redmond DX.iss", "overridden")
+
+ index = get(build_conn(), "/static/styles/Redmond%20DX.iss")
+ assert index.status == 200
+ assert index.resp_body == File.read!(@dir <> "/static/styles/Redmond DX.iss")
+ assert index.halted == true
+ end
+
+ test "serves fallback /static/styles files with spaces in URL" do
+ index = get(build_conn(), "/static/styles/Redmond%20DX.iss")
+
+ assert index.status == 200
+ assert index.resp_body == File.read!("priv/static/static/styles/Redmond DX.iss")
+ assert index.halted == true
end
test "does not sanitize dangerous files in general, as there can be html and javascript files legitimately in this folder" do
@@ -137,47 +159,4 @@ defmodule Pleroma.Web.Plugs.InstanceStaticTest do
# It should be preserved because "image" is in the allowed_mime_types list
assert content_type == "image/jpeg"
end
-
- describe "404s for missing files in static-only paths" do
- test "returns 404 for non-existent static-only JSON files" do
- conn = get(build_conn(), "/static/non-existent.json")
-
- assert conn.status == 404
- assert ["application/json"] = get_resp_header(conn, "content-type")
- assert Jason.decode!(conn.resp_body) == %{"error" => "not found"}
- end
-
- test "returns 404 for non-existent static-only non-JSON files" do
- conn = get(build_conn(), "/static/non-existent.txt")
-
- assert conn.status == 404
- assert conn.resp_body == "Not found"
- assert ["text/plain"] = get_resp_header(conn, "content-type")
- end
-
- test "returns 404 for non-existent .css files" do
- conn = get(build_conn(), "/static/non-existent.css")
-
- assert conn.status == 404
- assert conn.resp_body == "Not found"
- # Verifies that we forced text/plain for the error body, even though the path was .css
- assert ["text/plain"] = get_resp_header(conn, "content-type")
- end
-
- test "returns 404 for non-existent files without an extension" do
- conn = get(build_conn(), "/static/non-existent")
-
- assert conn.status == 404
- assert conn.resp_body == "Not found"
- assert ["text/plain"] = get_resp_header(conn, "content-type")
- end
-
- test "returns 200 (falls through to SPA) for non-static-only paths" do
- # /some-route is NOT in static_only_files, so it should still fall through to the SPA.
- conn = get(build_conn(), "/some-route")
-
- assert conn.status == 200
- assert ["text/html; charset=utf-8"] = get_resp_header(conn, "content-type")
- end
- end
end
diff --git a/test/pleroma/web/plugs/static_not_found_plug_test.exs b/test/pleroma/web/plugs/static_not_found_plug_test.exs
new file mode 100644
index 000000000..a5d063c53
--- /dev/null
+++ b/test/pleroma/web/plugs/static_not_found_plug_test.exs
@@ -0,0 +1,49 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2026 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+defmodule Pleroma.Web.Plugs.StaticNotFoundPlugTest do
+ use Pleroma.Web.ConnCase
+
+ describe "404s for missing files in static-only paths" do
+ test "returns 404 for non-existent static-only JSON files" do
+ conn = get(build_conn(), "/static/non-existent.json")
+
+ assert conn.status == 404
+ assert ["application/json"] = get_resp_header(conn, "content-type")
+ assert Jason.decode!(conn.resp_body) == %{"error" => "not found"}
+ end
+
+ test "returns 404 for non-existent static-only non-JSON files" do
+ conn = get(build_conn(), "/static/non-existent.txt")
+
+ assert conn.status == 404
+ assert conn.resp_body == "Not found"
+ assert ["text/plain"] = get_resp_header(conn, "content-type")
+ end
+
+ test "returns 404 for non-existent .css files" do
+ conn = get(build_conn(), "/static/non-existent.css")
+
+ assert conn.status == 404
+ assert conn.resp_body == "Not found"
+ # Verifies that we forced text/plain for the error body, even though the path was .css
+ assert ["text/plain"] = get_resp_header(conn, "content-type")
+ end
+
+ test "returns 404 for non-existent files without an extension" do
+ conn = get(build_conn(), "/static/non-existent")
+
+ assert conn.status == 404
+ assert conn.resp_body == "Not found"
+ assert ["text/plain"] = get_resp_header(conn, "content-type")
+ end
+
+ test "returns 200 (falls through to SPA) for non-static-only paths" do
+ # /some-route is NOT in static_only_files, so it should still fall through to the SPA.
+ conn = get(build_conn(), "/some-route")
+
+ assert conn.status == 200
+ assert ["text/html; charset=utf-8"] = get_resp_header(conn, "content-type")
+ end
+ end
+end