Add an API endpoint to install a new frontend
authorEgor Kislitsyn <egor@kislitsyn.com>
Tue, 27 Oct 2020 15:20:04 +0000 (19:20 +0400)
committerEgor Kislitsyn <egor@kislitsyn.com>
Tue, 27 Oct 2020 15:20:21 +0000 (19:20 +0400)
config/config.exs
lib/mix/tasks/pleroma/frontend.ex
lib/pleroma/frontend.ex [new file with mode: 0644]
lib/pleroma/web/admin_api/controllers/frontend_controller.ex [new file with mode: 0644]
lib/pleroma/web/admin_api/views/frontend_view.ex [new file with mode: 0644]
lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex [new file with mode: 0644]
lib/pleroma/web/router.ex
lib/pleroma/workers/frontend_installer_worker.ex [new file with mode: 0644]
test/pleroma/frontend_test.exs [new file with mode: 0644]
test/pleroma/web/admin_api/controllers/frontend_controller_test.exs [new file with mode: 0644]

index 124f30a77b3cc6713f3901b007b2f7a10f5e7cd8..2e22a046b950f51ed10123a809fc6d6c8b66a640 100644 (file)
@@ -560,7 +560,8 @@ config :pleroma, Oban,
     background: 5,
     remote_fetcher: 2,
     attachments_cleanup: 5,
-    new_users_digest: 1
+    new_users_digest: 1,
+    frontend_installer: 1
   ],
   plugins: [Oban.Plugins.Pruner],
   crontab: [
index cbce81ab9666919d0f163a36c612d0482892f98a..f15dbc38bd4cbf8f4f750f3751011861dbdbeb02 100644 (file)
@@ -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 (file)
index 0000000..3413d2f
--- /dev/null
@@ -0,0 +1,108 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# 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 (file)
index 0000000..59c69ab
--- /dev/null
@@ -0,0 +1,41 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# 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 (file)
index 0000000..374841d
--- /dev/null
@@ -0,0 +1,21 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# 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 (file)
index 0000000..24d23a4
--- /dev/null
@@ -0,0 +1,77 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# 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
index d2d93998965e8592cd72429abacd96b46a7e88d0..aba505a661f02b936cfea883434bdc07330b3465 100644 (file)
@@ -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 (file)
index 0000000..38688c6
--- /dev/null
@@ -0,0 +1,21 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# 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 (file)
index 0000000..77913b2
--- /dev/null
@@ -0,0 +1,80 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# 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 (file)
index 0000000..461d6e5
--- /dev/null
@@ -0,0 +1,94 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# 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