diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe1114c02..616f9deeb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Pleroma API: Add `idempotency_key` to the chat message entity that can be used for optimistic message sending.
- Pleroma API: (`GET /api/v1/pleroma/federation_status`) Add a way to get a list of unreachable instances.
- Mastodon API: User and conversation mutes can now auto-expire if `expires_in` parameter was given while adding the mute.
+- Admin API: An endpoint to manage frontends
diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md
index f7b5bcae7..19ac6a65f 100644
--- a/docs/API/admin_api.md
+++ b/docs/API/admin_api.md
@@ -1499,3 +1499,66 @@ Returns the content of the document
"url": "https://example.com/instance/panel.html"
}
```
+
+## `GET /api/pleroma/admin/frontends
+
+### List available frontends
+
+- Response:
+
+```json
+[
+ {
+ "build_url": "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build",
+ "git": "https://git.pleroma.social/pleroma/fedi-fe",
+ "installed": true,
+ "name": "fedi-fe",
+ "ref": "master"
+ },
+ {
+ "build_url": "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build",
+ "git": "https://git.pleroma.social/lambadalambda/kenoma",
+ "installed": false,
+ "name": "kenoma",
+ "ref": "master"
+ }
+]
+```
+
+## `POST /api/pleroma/admin/frontends/install`
+
+### Install a frontend
+
+- Params:
+ - `name`: frontend name, required
+ - `ref`: frontend ref
+ - `file`: path to a frontend zip file
+ - `build_url`: build URL
+ - `build_dir`: build directory
+
+- Response:
+
+```json
+[
+ {
+ "build_url": "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build",
+ "git": "https://git.pleroma.social/pleroma/fedi-fe",
+ "installed": true,
+ "name": "fedi-fe",
+ "ref": "master"
+ },
+ {
+ "build_url": "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build",
+ "git": "https://git.pleroma.social/lambadalambda/kenoma",
+ "installed": false,
+ "name": "kenoma",
+ "ref": "master"
+ }
+]
+```
+
+```json
+{
+ "error": "Could not install frontend"
+}
+```
diff --git a/lib/mix/tasks/pleroma/frontend.ex b/lib/mix/tasks/pleroma/frontend.ex
index cbce81ab9..f15dbc38b 100644
--- a/lib/mix/tasks/pleroma/frontend.ex
+++ b/lib/mix/tasks/pleroma/frontend.ex
@@ -17,8 +17,6 @@ defmodule Mix.Tasks.Pleroma.Frontend do
end
def run(["install", frontend | args]) do
- log_level = Logger.level()
- Logger.configure(level: :warn)
start_pleroma()
{options, [], []} =
@@ -33,109 +31,6 @@ defmodule Mix.Tasks.Pleroma.Frontend do
]
)
- instance_static_dir =
- with nil <- options[:static_dir] do
- Pleroma.Config.get!([:instance, :static_dir])
- end
-
- cmd_frontend_info = %{
- "name" => frontend,
- "ref" => options[:ref],
- "build_url" => options[:build_url],
- "build_dir" => options[:build_dir]
- }
-
- config_frontend_info = Pleroma.Config.get([:frontends, :available, frontend], %{})
-
- frontend_info =
- Map.merge(config_frontend_info, cmd_frontend_info, fn _key, config, cmd ->
- # This only overrides things that are actually set
- cmd || config
- end)
-
- ref = frontend_info["ref"]
-
- unless ref do
- raise "No ref given or configured"
- end
-
- dest =
- Path.join([
- instance_static_dir,
- "frontends",
- frontend,
- ref
- ])
-
- fe_label = "#{frontend} (#{ref})"
-
- tmp_dir = Path.join([instance_static_dir, "frontends", "tmp"])
-
- with {_, :ok} <-
- {:download_or_unzip, download_or_unzip(frontend_info, tmp_dir, options[:file])},
- shell_info("Installing #{fe_label} to #{dest}"),
- :ok <- install_frontend(frontend_info, tmp_dir, dest) do
- File.rm_rf!(tmp_dir)
- shell_info("Frontend #{fe_label} installed to #{dest}")
-
- Logger.configure(level: log_level)
- else
- {:download_or_unzip, _} ->
- shell_info("Could not download or unzip the frontend")
-
- _e ->
- shell_info("Could not install the frontend")
- end
- end
-
- defp download_or_unzip(frontend_info, temp_dir, file) do
- if file do
- with {:ok, zip} <- File.read(Path.expand(file)) do
- unzip(zip, temp_dir)
- end
- else
- download_build(frontend_info, temp_dir)
- end
- end
-
- def unzip(zip, dest) do
- with {:ok, unzipped} <- :zip.unzip(zip, [:memory]) do
- File.rm_rf!(dest)
- File.mkdir_p!(dest)
-
- Enum.each(unzipped, fn {filename, data} ->
- path = filename
-
- new_file_path = Path.join(dest, path)
-
- new_file_path
- |> Path.dirname()
- |> File.mkdir_p!()
-
- File.write!(new_file_path, data)
- end)
-
- :ok
- end
- end
-
- defp download_build(frontend_info, dest) do
- shell_info("Downloading pre-built bundle for #{frontend_info["name"]}")
- url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"])
-
- with {:ok, %{status: 200, body: zip_body}} <-
- Pleroma.HTTP.get(url, [], pool: :media, recv_timeout: 120_000) do
- unzip(zip_body, dest)
- else
- e -> {:error, e}
- end
- end
-
- defp install_frontend(frontend_info, source, dest) do
- from = frontend_info["build_dir"] || "dist"
- File.rm_rf!(dest)
- File.mkdir_p!(dest)
- File.cp_r!(Path.join([source, from]), dest)
- :ok
+ Pleroma.Frontend.install(frontend, options)
end
end
diff --git a/lib/pleroma/frontend.ex b/lib/pleroma/frontend.ex
new file mode 100644
index 000000000..bf935a728
--- /dev/null
+++ b/lib/pleroma/frontend.ex
@@ -0,0 +1,110 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Frontend do
+ alias Pleroma.Config
+
+ require Logger
+
+ def install(name, opts \\ []) do
+ frontend_info = %{
+ "ref" => opts[:ref],
+ "build_url" => opts[:build_url],
+ "build_dir" => opts[:build_dir]
+ }
+
+ frontend_info =
+ [:frontends, :available, name]
+ |> Config.get(%{})
+ |> Map.merge(frontend_info, fn _key, config, cmd ->
+ # This only overrides things that are actually set
+ cmd || config
+ end)
+
+ ref = frontend_info["ref"]
+
+ unless ref do
+ raise "No ref given or configured"
+ end
+
+ dest = Path.join([dir(), name, ref])
+
+ label = "#{name} (#{ref})"
+ tmp_dir = Path.join(dir(), "tmp")
+
+ with {_, :ok} <-
+ {:download_or_unzip, download_or_unzip(frontend_info, tmp_dir, opts[:file])},
+ Logger.info("Installing #{label} to #{dest}"),
+ :ok <- install_frontend(frontend_info, tmp_dir, dest) do
+ File.rm_rf!(tmp_dir)
+ Logger.info("Frontend #{label} installed to #{dest}")
+ else
+ {:download_or_unzip, _} ->
+ Logger.info("Could not download or unzip the frontend")
+ {:error, "Could not download or unzip the frontend"}
+
+ _e ->
+ Logger.info("Could not install the frontend")
+ {:error, "Could not install the frontend"}
+ end
+ end
+
+ def dir(opts \\ []) do
+ if is_nil(opts[:static_dir]) do
+ Pleroma.Config.get!([:instance, :static_dir])
+ else
+ opts[:static_dir]
+ end
+ |> Path.join("frontends")
+ end
+
+ defp download_or_unzip(frontend_info, temp_dir, nil),
+ do: download_build(frontend_info, temp_dir)
+
+ defp download_or_unzip(_frontend_info, temp_dir, file) do
+ with {:ok, zip} <- File.read(Path.expand(file)) do
+ unzip(zip, temp_dir)
+ end
+ end
+
+ def unzip(zip, dest) do
+ with {:ok, unzipped} <- :zip.unzip(zip, [:memory]) do
+ File.rm_rf!(dest)
+ File.mkdir_p!(dest)
+
+ Enum.each(unzipped, fn {filename, data} ->
+ path = filename
+
+ new_file_path = Path.join(dest, path)
+
+ new_file_path
+ |> Path.dirname()
+ |> File.mkdir_p!()
+
+ File.write!(new_file_path, data)
+ end)
+ end
+ end
+
+ defp download_build(frontend_info, dest) do
+ Logger.info("Downloading pre-built bundle for #{frontend_info["name"]}")
+ url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"])
+
+ with {:ok, %{status: 200, body: zip_body}} <-
+ Pleroma.HTTP.get(url, [], pool: :media, recv_timeout: 120_000) do
+ unzip(zip_body, dest)
+ else
+ {:error, e} -> {:error, e}
+ e -> {:error, e}
+ end
+ end
+
+ defp install_frontend(frontend_info, source, dest) do
+ from = frontend_info["build_dir"] || "dist"
+ File.rm_rf!(dest)
+ File.mkdir_p!(dest)
+ File.cp_r!(Path.join([source, from]), dest)
+ :ok
+ end
+end
diff --git a/lib/pleroma/web/admin_api/controllers/frontend_controller.ex b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex
new file mode 100644
index 000000000..fac3522b8
--- /dev/null
+++ b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex
@@ -0,0 +1,40 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.AdminAPI.FrontendController do
+ use Pleroma.Web, :controller
+
+ alias Pleroma.Config
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ plug(OAuthScopesPlug, %{scopes: ["write"], admin: true} when action == :install)
+ plug(OAuthScopesPlug, %{scopes: ["read"], admin: true} when action == :index)
+ action_fallback(Pleroma.Web.AdminAPI.FallbackController)
+
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.FrontendOperation
+
+ def index(conn, _params) do
+ installed = installed()
+
+ frontends =
+ [:frontends, :available]
+ |> Config.get([])
+ |> Enum.map(fn {name, desc} ->
+ Map.put(desc, "installed", name in installed)
+ end)
+
+ render(conn, "index.json", frontends: frontends)
+ end
+
+ def install(%{body_params: params} = conn, _params) do
+ with :ok <- Pleroma.Frontend.install(params.name, Map.delete(params, :name)) do
+ index(conn, %{})
+ end
+ end
+
+ defp installed do
+ File.ls!(Pleroma.Frontend.dir())
+ end
+end
diff --git a/lib/pleroma/web/admin_api/views/frontend_view.ex b/lib/pleroma/web/admin_api/views/frontend_view.ex
new file mode 100644
index 000000000..374841d0b
--- /dev/null
+++ b/lib/pleroma/web/admin_api/views/frontend_view.ex
@@ -0,0 +1,21 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.AdminAPI.FrontendView do
+ use Pleroma.Web, :view
+
+ def render("index.json", %{frontends: frontends}) do
+ render_many(frontends, __MODULE__, "show.json")
+ end
+
+ def render("show.json", %{frontend: frontend}) do
+ %{
+ name: frontend["name"],
+ git: frontend["git"],
+ build_url: frontend["build_url"],
+ ref: frontend["ref"],
+ installed: frontend["installed"]
+ }
+ end
+end
diff --git a/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex
new file mode 100644
index 000000000..96d4cdee7
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex
@@ -0,0 +1,85 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Admin.FrontendOperation do
+ alias OpenApiSpex.Operation
+ alias OpenApiSpex.Schema
+ alias Pleroma.Web.ApiSpec.Schemas.ApiError
+
+ import Pleroma.Web.ApiSpec.Helpers
+
+ def open_api_operation(action) do
+ operation = String.to_existing_atom("#{action}_operation")
+ apply(__MODULE__, operation, [])
+ end
+
+ def index_operation do
+ %Operation{
+ tags: ["Admin", "Reports"],
+ summary: "Get a list of available frontends",
+ operationId: "AdminAPI.FrontendController.index",
+ security: [%{"oAuth" => ["read"]}],
+ responses: %{
+ 200 => Operation.response("Response", "application/json", list_of_frontends()),
+ 403 => Operation.response("Forbidden", "application/json", ApiError)
+ }
+ }
+ end
+
+ def install_operation do
+ %Operation{
+ tags: ["Admin", "Reports"],
+ summary: "Install a frontend",
+ operationId: "AdminAPI.FrontendController.install",
+ security: [%{"oAuth" => ["read"]}],
+ requestBody: request_body("Parameters", install_request(), required: true),
+ responses: %{
+ 200 => Operation.response("Response", "application/json", list_of_frontends()),
+ 403 => Operation.response("Forbidden", "application/json", ApiError),
+ 400 => Operation.response("Error", "application/json", ApiError)
+ }
+ }
+ end
+
+ defp list_of_frontends do
+ %Schema{
+ type: :array,
+ items: %Schema{
+ type: :object,
+ properties: %{
+ name: %Schema{type: :string},
+ git: %Schema{type: :string, format: :uri, nullable: true},
+ build_url: %Schema{type: :string, format: :uri, nullable: true},
+ ref: %Schema{type: :string},
+ installed: %Schema{type: :boolean}
+ }
+ }
+ }
+ end
+
+ defp install_request do
+ %Schema{
+ title: "FrontendInstallRequest",
+ type: :object,
+ required: [:name],
+ properties: %{
+ name: %Schema{
+ type: :string
+ },
+ ref: %Schema{
+ type: :string
+ },
+ file: %Schema{
+ type: :string
+ },
+ build_url: %Schema{
+ type: :string
+ },
+ build_dir: %Schema{
+ type: :string
+ }
+ }
+ }
+ end
+end
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index 0f0538182..75a885377 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -244,6 +244,9 @@ defmodule Pleroma.Web.Router do
get("/chats/:id/messages", ChatController, :messages)
delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
+ get("/frontends", FrontendController, :index)
+ post("/frontends/install", FrontendController, :install)
+
post("/backups", AdminAPIController, :create_backup)
end
diff --git a/test/pleroma/frontend_test.exs b/test/pleroma/frontend_test.exs
new file mode 100644
index 000000000..223625857
--- /dev/null
+++ b/test/pleroma/frontend_test.exs
@@ -0,0 +1,72 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.FrontendTest do
+ use Pleroma.DataCase
+ alias Pleroma.Frontend
+
+ @dir "test/frontend_static_test"
+
+ setup do
+ File.mkdir_p!(@dir)
+ clear_config([:instance, :static_dir], @dir)
+
+ on_exit(fn ->
+ File.rm_rf(@dir)
+ end)
+ end
+
+ test "it downloads and unzips a known frontend" do
+ clear_config([:frontends, :available], %{
+ "pleroma" => %{
+ "ref" => "fantasy",
+ "name" => "pleroma",
+ "build_url" => "http://gensokyo.2hu/builds/${ref}"
+ }
+ })
+
+ Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/builds/fantasy"} ->
+ %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend_dist.zip")}
+ end)
+
+ Frontend.install("pleroma")
+
+ assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"]))
+ end
+
+ test "it also works given a file" do
+ clear_config([:frontends, :available], %{
+ "pleroma" => %{
+ "ref" => "fantasy",
+ "name" => "pleroma",
+ "build_dir" => ""
+ }
+ })
+
+ folder = Path.join([@dir, "frontends", "pleroma", "fantasy"])
+ previously_existing = Path.join([folder, "temp"])
+ File.mkdir_p!(folder)
+ File.write!(previously_existing, "yey")
+ assert File.exists?(previously_existing)
+
+ Frontend.install("pleroma", file: "test/fixtures/tesla_mock/frontend.zip")
+
+ assert File.exists?(Path.join([folder, "test.txt"]))
+ refute File.exists?(previously_existing)
+ end
+
+ test "it downloads and unzips unknown frontends" do
+ Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/madeup.zip"} ->
+ %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend.zip")}
+ end)
+
+ Frontend.install("unknown",
+ ref: "baka",
+ build_url: "http://gensokyo.2hu/madeup.zip",
+ build_dir: ""
+ )
+
+ assert File.exists?(Path.join([@dir, "frontends", "unknown", "baka", "test.txt"]))
+ end
+end
diff --git a/test/pleroma/web/admin_api/controllers/frontend_controller_test.exs b/test/pleroma/web/admin_api/controllers/frontend_controller_test.exs
new file mode 100644
index 000000000..94873f6db
--- /dev/null
+++ b/test/pleroma/web/admin_api/controllers/frontend_controller_test.exs
@@ -0,0 +1,141 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.AdminAPI.FrontendControllerTest do
+ use Pleroma.Web.ConnCase
+
+ import Pleroma.Factory
+
+ alias Pleroma.Config
+
+ @dir "test/frontend_static_test"
+
+ setup do
+ clear_config([:instance, :static_dir], @dir)
+ File.mkdir_p!(Pleroma.Frontend.dir())
+
+ on_exit(fn ->
+ File.rm_rf(@dir)
+ end)
+
+ admin = insert(:user, is_admin: true)
+ token = insert(:oauth_admin_token, user: admin)
+
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> assign(:token, token)
+
+ {:ok, %{admin: admin, token: token, conn: conn}}
+ end
+
+ describe "GET /api/pleroma/admin/frontends" do
+ test "it lists available frontends", %{conn: conn} do
+ response =
+ conn
+ |> get("/api/pleroma/admin/frontends")
+ |> json_response_and_validate_schema(:ok)
+
+ assert Enum.map(response, & &1["name"]) ==
+ Enum.map(Config.get([:frontends, :available]), fn {_, map} -> map["name"] end)
+
+ refute Enum.any?(response, fn frontend -> frontend["installed"] == true end)
+ end
+ end
+
+ describe "POST /api/pleroma/admin/frontends/install" do
+ test "from available frontends", %{conn: conn} do
+ clear_config([:frontends, :available], %{
+ "pleroma" => %{
+ "ref" => "fantasy",
+ "name" => "pleroma",
+ "build_url" => "http://gensokyo.2hu/builds/${ref}"
+ }
+ })
+
+ Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/builds/fantasy"} ->
+ %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend_dist.zip")}
+ end)
+
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/pleroma/admin/frontends/install", %{name: "pleroma"})
+ |> json_response_and_validate_schema(:ok)
+
+ assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"]))
+
+ response =
+ conn
+ |> get("/api/pleroma/admin/frontends")
+ |> json_response_and_validate_schema(:ok)
+
+ assert response == [
+ %{
+ "build_url" => "http://gensokyo.2hu/builds/${ref}",
+ "git" => nil,
+ "installed" => true,
+ "name" => "pleroma",
+ "ref" => "fantasy"
+ }
+ ]
+ end
+
+ test "from a file", %{conn: conn} do
+ clear_config([:frontends, :available], %{
+ "pleroma" => %{
+ "ref" => "fantasy",
+ "name" => "pleroma",
+ "build_dir" => ""
+ }
+ })
+
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/pleroma/admin/frontends/install", %{
+ name: "pleroma",
+ file: "test/fixtures/tesla_mock/frontend.zip"
+ })
+ |> json_response_and_validate_schema(:ok)
+
+ assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"]))
+ end
+
+ test "from an URL", %{conn: conn} do
+ Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/madeup.zip"} ->
+ %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend.zip")}
+ end)
+
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/pleroma/admin/frontends/install", %{
+ name: "unknown",
+ ref: "baka",
+ build_url: "http://gensokyo.2hu/madeup.zip",
+ build_dir: ""
+ })
+ |> json_response_and_validate_schema(:ok)
+
+ assert File.exists?(Path.join([@dir, "frontends", "unknown", "baka", "test.txt"]))
+ end
+
+ test "failing returns an error", %{conn: conn} do
+ Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/madeup.zip"} ->
+ %Tesla.Env{status: 404, body: ""}
+ end)
+
+ result =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/pleroma/admin/frontends/install", %{
+ name: "unknown",
+ ref: "baka",
+ build_url: "http://gensokyo.2hu/madeup.zip",
+ build_dir: ""
+ })
+ |> json_response_and_validate_schema(400)
+
+ assert result == %{"error" => "Could not download or unzip the frontend"}
+ end
+ end
+end