Merge branch 'preferred-frontend' into 'develop'

Port Akkoma frontend preference code

See merge request pleroma/pleroma!4398
This commit is contained in:
nicole mikołajczyk 2025-12-16 20:54:00 +01:00
commit d41e2fbaaf
14 changed files with 264 additions and 13 deletions

View file

@ -0,0 +1 @@
Allow users to select preferred frontend

View file

@ -3333,6 +3333,12 @@ config :pleroma, :config_description, [
description:
"A map containing available frontends and parameters for their installation.",
children: frontend_options
},
%{
key: :pickable,
type: {:list, :string},
description:
"A list containing all frontends users can pick as their preference, format is :name/:ref, e.g pleroma-fe/stable."
}
]
},

View file

@ -151,7 +151,8 @@ defmodule Pleroma.Web.ApiSpec do
"Suggestions",
"Announcements",
"Remote interaction",
"Others"
"Others",
"Preferred frontends"
]
}
]

View file

@ -0,0 +1,65 @@
defmodule Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
import Pleroma.Web.ApiSpec.Helpers
@spec open_api_operation(atom) :: Operation.t()
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def available_frontends_operation do
%Operation{
tags: ["Preferred frontends"],
summary: "Frontend settings profiles",
description: "List frontend setting profiles",
operationId: "PleromaAPI.FrontendSettingsController.available_frontends",
responses: %{
200 =>
Operation.response("Frontends", "application/json", %Schema{
type: :array,
items: %Schema{
type: :string
}
})
}
}
end
def update_preferred_frontend_operation do
%Operation{
tags: ["Preferred frontends"],
summary: "Update preferred frontend setting",
description: "Store preferred frontend in cookies",
operationId: "PleromaAPI.FrontendSettingsController.update_preferred_frontend",
requestBody:
request_body(
"Frontend",
%Schema{
type: :object,
required: [:frontend_name],
properties: %{
frontend_name: %Schema{
type: :string,
description: "Frontend name"
}
}
},
required: true
),
responses: %{
200 =>
Operation.response("Preferred frontend", "application/json", %Schema{
type: :object,
properties: %{
frontend_name: %Schema{
type: :string,
description: "Frontend name"
}
}
})
}
}
end
end

View file

@ -30,7 +30,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do
end
def redirector(conn, _params, code \\ 200) do
{:ok, index_content} = File.read(index_file_path())
{:ok, index_content} = File.read(index_file_path(conn))
response =
index_content
@ -51,7 +51,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do
end
def redirector_with_meta(conn, params) do
{:ok, index_content} = File.read(index_file_path())
{:ok, index_content} = File.read(index_file_path(conn))
tags = build_tags(conn, params)
preloads = preload_data(conn, params)
@ -69,7 +69,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do
end
def redirector_with_preload(conn, params) do
{:ok, index_content} = File.read(index_file_path())
{:ok, index_content} = File.read(index_file_path(conn))
preloads = preload_data(conn, params)
response =
@ -91,8 +91,10 @@ defmodule Pleroma.Web.Fallback.RedirectController do
|> text("")
end
defp index_file_path do
Pleroma.Web.Plugs.InstanceStatic.file_path("index.html")
defp index_file_path(conn) do
frontend_type = Pleroma.Web.Plugs.FrontendStatic.preferred_or_fallback(conn, :primary)
Pleroma.Web.Plugs.InstanceStatic.file_path("index.html", frontend_type)
end
defp build_tags(conn, params) do

View file

@ -0,0 +1,20 @@
defmodule Pleroma.Web.FrontendSwitcher.FrontendSwitcherController do
use Pleroma.Web, :controller
alias Pleroma.Config
@doc "GET /frontend_switcher"
def switch(conn, _params) do
pickable = Config.get([:frontends, :pickable], [])
conn
|> put_view(Pleroma.Web.FrontendSwitcher.FrontendSwitcherView)
|> render("switch.html", choices: pickable)
end
@doc "POST /frontend_switcher"
def do_switch(conn, params) do
conn
|> put_resp_cookie("preferred_frontend", params["frontend"])
|> html(~s(<meta http-equiv="refresh" content="0; url=/">))
end
end

View file

@ -0,0 +1,5 @@
defmodule Pleroma.Web.FrontendSwitcher.FrontendSwitcherView do
use Pleroma.Web, :view
import Phoenix.HTML.Form
end

View file

@ -0,0 +1,37 @@
defmodule Pleroma.Web.PleromaAPI.FrontendSettingsController do
use Pleroma.Web, :controller
alias Pleroma.Web.Plugs.OAuthScopesPlug
plug(
OAuthScopesPlug,
%{fallback: :proceed_unauthenticated, scopes: []}
when action in [
:available_frontends,
:update_preferred_frontend
]
)
plug(Pleroma.Web.ApiSpec.CastAndValidate)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
@doc "GET /api/v1/pleroma/preferred_frontend/available"
def available_frontends(conn, _params) do
available = Pleroma.Config.get([:frontends, :pickable])
conn
|> json(available)
end
@doc "PUT /api/v1/pleroma/preferred_frontend"
def update_preferred_frontend(
%{body_params: %{frontend_name: preferred_frontend}} = conn,
_params
) do
conn
|> put_resp_cookie("preferred_frontend", preferred_frontend)
|> json(%{frontend_name: preferred_frontend})
end
end

View file

@ -5,17 +5,23 @@
defmodule Pleroma.Web.Plugs.FrontendStatic do
require Pleroma.Constants
@frontend_cookie_name "preferred_frontend"
@moduledoc """
This is a shim to call `Plug.Static` but with runtime `from` configuration`. It dispatches to the different frontends.
"""
@behaviour Plug
def file_path(path, frontend_type \\ :primary) do
if configuration = Pleroma.Config.get([:frontends, frontend_type]) do
instance_static_path = Pleroma.Config.get([:instance, :static_dir], "instance/static")
defp instance_static_path do
Pleroma.Config.get([:instance, :static_dir], "instance/static")
end
def file_path(path, frontend_type \\ :primary)
def file_path(path, frontend_type) when is_atom(frontend_type) do
if configuration = Pleroma.Config.get([:frontends, frontend_type]) do
Path.join([
instance_static_path,
instance_static_path(),
"frontends",
configuration["name"],
configuration["ref"],
@ -26,6 +32,15 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do
end
end
def file_path(path, frontend_type) when is_binary(frontend_type) do
Path.join([
instance_static_path(),
"frontends",
frontend_type,
path
])
end
def init(opts) do
opts
|> Keyword.put(:from, "__unconfigured_frontend_static_plug")
@ -36,7 +51,8 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do
def call(conn, opts) do
with false <- api_route?(conn.path_info),
false <- invalid_path?(conn.path_info),
frontend_type <- Map.get(opts, :frontend_type, :primary),
fallback_frontend_type <- Map.get(opts, :frontend_type, :primary),
frontend_type <- preferred_or_fallback(conn, fallback_frontend_type),
path when not is_nil(path) <- file_path("", frontend_type) do
call_static(conn, opts, path)
else
@ -45,6 +61,31 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do
end
end
def preferred_frontend(conn) do
%{req_cookies: cookies} =
conn
|> Plug.Conn.fetch_cookies()
Map.get(cookies, @frontend_cookie_name)
end
# Only override primary frontend
def preferred_or_fallback(conn, :primary) do
case preferred_frontend(conn) do
nil ->
:primary
frontend ->
if Enum.member?(Pleroma.Config.get([:frontends, :pickable], []), frontend) do
frontend
else
:primary
end
end
end
def preferred_or_fallback(_conn, fallback), do: fallback
defp invalid_path?(list) do
invalid_path?(list, :binary.compile_pattern(["/", "\\", ":", "\0"]))
end

View file

@ -13,11 +13,11 @@ defmodule Pleroma.Web.Plugs.InstanceStatic do
"""
@behaviour Plug
def file_path(path) do
def file_path(path, frontend_type \\ :primary) do
instance_path =
Path.join(Pleroma.Config.get([:instance, :static_dir], "instance/static/"), path)
frontend_path = Pleroma.Web.Plugs.FrontendStatic.file_path(path, :primary)
frontend_path = Pleroma.Web.Plugs.FrontendStatic.file_path(path, frontend_type)
(File.exists?(instance_path) && instance_path) ||
(frontend_path && File.exists?(frontend_path) && frontend_path) ||

View file

@ -561,6 +561,18 @@ defmodule Pleroma.Web.Router do
get("/apps", AppController, :index)
get("/statuses/:id/reactions/:emoji", EmojiReactionController, :index)
get("/statuses/:id/reactions", EmojiReactionController, :index)
get(
"/preferred_frontend/available",
FrontendSettingsController,
:available_frontends
)
put(
"/preferred_frontend",
FrontendSettingsController,
:update_preferred_frontend
)
end
scope "/api/v0/pleroma", Pleroma.Web.PleromaAPI do
@ -906,7 +918,11 @@ defmodule Pleroma.Web.Router do
scope "/", Pleroma.Web do
pipe_through(:browser)
get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe)
get("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :switch)
post("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :do_switch)
end
pipeline :ap_service_actor do

View file

@ -0,0 +1,7 @@
<h2>Switch frontend</h2>
<%= form_for @conn, Routes.frontend_switcher_path(@conn, :do_switch), fn f -> %>
<%= select(f, :frontend, @choices) %>
<%= submit do: "submit" %>
<% end %>

View file

@ -0,0 +1,17 @@
defmodule Pleroma.Web.PleromaAPI.FrontendSettingsControllerTest do
use Pleroma.Web.ConnCase, async: false
describe "PUT /api/v1/pleroma/preferred_frontend" do
test "sets a cookie with selected frontend" do
%{conn: conn} = oauth_access(["read"])
response =
conn
|> put_req_header("content-type", "application/json")
|> put("/api/v1/pleroma/preferred_frontend", %{"frontend_name" => "pleroma-fe/stable"})
json_response_and_validate_schema(response, 200)
assert %{"preferred_frontend" => %{value: "pleroma-fe/stable"}} = response.resp_cookies
end
end
end

View file

@ -97,6 +97,7 @@ defmodule Pleroma.Web.Plugs.FrontendStaticPlugTest do
"users",
"tags",
"mailer",
"frontend_switcher",
"inbox",
"relay",
"internal",
@ -113,4 +114,36 @@ defmodule Pleroma.Web.Plugs.FrontendStaticPlugTest do
assert expected_routes == Pleroma.Web.Router.get_api_routes()
end
describe "preferred frontend cookie handling" do
test "returns preferred frontend file", %{conn: conn} do
name = "test-fe"
ref = "develop"
clear_config([:frontends, :pickable], ["#{name}/#{ref}"])
path = "#{@dir}/frontends/#{name}/#{ref}"
Pleroma.Backports.mkdir_p!(path)
File.write!("#{path}/index.html", "from frontend plug")
index =
conn
|> put_req_cookie("preferred_frontend", "#{name}/#{ref}")
|> get("/")
assert html_response(index, 200) == "from frontend plug"
end
test "only returns content from pickable frontends", %{conn: conn} do
clear_config([:instance, :static_dir], "instance/static")
clear_config([:frontends, :pickable], ["pleroma-fe/develop", "pl-fe/develop"])
config_file =
conn
|> put_req_cookie("preferred_frontend", "../../../config")
|> get("/config.exs")
refute response(config_file, 200) =~ "import Config"
end
end
end