diff --git a/config/config.exs b/config/config.exs
index 124f30a77..2e22a046b 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -560,7 +560,8 @@
background: 5,
remote_fetcher: 2,
attachments_cleanup: 5,
- new_users_digest: 1
+ new_users_digest: 1,
+ frontend_installer: 1
],
plugins: [Oban.Plugins.Pruner],
crontab: [
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 @@ def run(["install", "none" | _args]) do
end
def run(["install", frontend | args]) do
- log_level = Logger.level()
- Logger.configure(level: :warn)
start_pleroma()
{options, [], []} =
@@ -33,109 +31,6 @@ def run(["install", frontend | args]) 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..3413d2fba
--- /dev/null
+++ b/lib/pleroma/frontend.ex
@@ -0,0 +1,108 @@
+# 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
+ cmd_frontend_info = %{
+ "ref" => opts[:ref],
+ "build_url" => opts[:build_url],
+ "build_dir" => opts[:build_dir]
+ }
+
+ config_frontend_info = Config.get([:frontends, :available, name], %{})
+
+ 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([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")
+
+ _e ->
+ Logger.info("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..59c69aba1
--- /dev/null
+++ b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex
@@ -0,0 +1,41 @@
+# 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
+ alias Pleroma.Workers.FrontendInstallerWorker
+
+ 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
+ FrontendInstallerWorker.install(params.name, Map.delete(params, :name))
+
+ index(conn, %{})
+ 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..24d23a4e0
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex
@@ -0,0 +1,77 @@
+# 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)
+ }
+ }
+ 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},
+ 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,
+ nullable: false
+ },
+ ref: %Schema{
+ type: :string,
+ nullable: false
+ }
+ }
+ }
+ end
+end
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index d2d939989..aba505a66 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -223,6 +223,9 @@ defmodule Pleroma.Web.Router do
get("/chats/:id", ChatController, :show)
get("/chats/:id/messages", ChatController, :messages)
delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
+
+ get("/frontends", FrontendController, :index)
+ post("/frontends", FrontendController, :install)
end
scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do
diff --git a/lib/pleroma/workers/frontend_installer_worker.ex b/lib/pleroma/workers/frontend_installer_worker.ex
new file mode 100644
index 000000000..38688c63b
--- /dev/null
+++ b/lib/pleroma/workers/frontend_installer_worker.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.Workers.FrontendInstallerWorker do
+ use Oban.Worker, queue: :frontend_installer, max_attempts: 1
+
+ alias Oban.Job
+ alias Pleroma.Frontend
+
+ def install(name, opts \\ []) do
+ %{"name" => name, "opts" => Map.new(opts)}
+ |> new()
+ |> Oban.insert()
+ end
+
+ def perform(%Job{args: %{"name" => name, "opts" => opts}}) do
+ opts = Keyword.new(opts, fn {key, value} -> {String.to_existing_atom(key), value} end)
+ Frontend.install(name, opts)
+ end
+end
diff --git a/test/pleroma/frontend_test.exs b/test/pleroma/frontend_test.exs
new file mode 100644
index 000000000..77913b223
--- /dev/null
+++ b/test/pleroma/frontend_test.exs
@@ -0,0 +1,80 @@
+# 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
+
+ import ExUnit.CaptureIO, only: [capture_io: 1]
+
+ @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)
+
+ capture_io(fn ->
+ Frontend.install("pleroma")
+ end)
+
+ 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)
+
+ capture_io(fn ->
+ Frontend.install("pleroma", file: "test/fixtures/tesla_mock/frontend.zip")
+ end)
+
+ 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)
+
+ capture_io(fn ->
+ Frontend.install("unknown",
+ ref: "baka",
+ build_url: "http://gensokyo.2hu/madeup.zip",
+ build_dir: ""
+ )
+ end)
+
+ 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..461d6e5c9
--- /dev/null
+++ b/test/pleroma/web/admin_api/controllers/frontend_controller_test.exs
@@ -0,0 +1,94 @@
+# 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
+ use Oban.Testing, repo: Pleroma.Repo
+
+ import Pleroma.Factory
+
+ alias Pleroma.Config
+ alias Pleroma.Tests.ObanHelpers
+ alias Pleroma.Workers.FrontendInstallerWorker
+
+ @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" do
+ test "it installs a frontend", %{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", %{name: "pleroma"})
+ |> json_response_and_validate_schema(:ok)
+
+ assert_enqueued(
+ worker: FrontendInstallerWorker,
+ args: %{"name" => "pleroma", "opts" => %{}}
+ )
+
+ ObanHelpers.perform(all_enqueued(worker: FrontendInstallerWorker))
+
+ 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
+ end
+end