diff options
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | docs/API/admin_api.md | 63 | ||||
| -rw-r--r-- | lib/mix/tasks/pleroma/frontend.ex | 107 | ||||
| -rw-r--r-- | lib/pleroma/frontend.ex | 110 | ||||
| -rw-r--r-- | lib/pleroma/web/admin_api/controllers/frontend_controller.ex | 40 | ||||
| -rw-r--r-- | lib/pleroma/web/admin_api/views/frontend_view.ex | 21 | ||||
| -rw-r--r-- | lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex | 85 | ||||
| -rw-r--r-- | lib/pleroma/web/router.ex | 3 | ||||
| -rw-r--r-- | test/pleroma/frontend_test.exs | 72 | ||||
| -rw-r--r-- | test/pleroma/web/admin_api/controllers/frontend_controller_test.exs | 141 | 
10 files changed, 537 insertions, 106 deletions
| 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  </details> 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 <https://pleroma.social/> +# 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 <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 + +  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 <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 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 <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), +        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 <https://pleroma.social/> +# 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 <https://pleroma.social/> +# 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 | 
