From: floatingghost Date: Thu, 6 Oct 2022 16:22:15 +0000 (+0000) Subject: Backend settings sync (#226) X-Git-Url: https://git.squeep.com/?a=commitdiff_plain;h=c6e63aaf6b647f458ecd0e788ca0adb2113a9524;p=akkoma Backend settings sync (#226) Co-authored-by: FloatingGhost Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/226 --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 104164dec..ece6af0d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## Unreleased +## 2022.10 + +### Added +- Ability to sync frontend profiles between clients, with a name attached +- Status card generation will now use the media summary if it is available ### Changed +- Emoji updated to latest 15.0 draft - **Breaking**: `/api/v1/pleroma/backups` endpoints now requires `read:backups` scope instead of `read:accounts` ### Fixed +- OAuthPlug no longer joins with the database every call and uses the user cache +- Undo activities no longer try to look up by ID, and render correctly - prevent false-errors from meilisearch ## 2022.09 diff --git a/config/config.exs b/config/config.exs index 7fbfb9ad2..d7005770e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -261,7 +261,8 @@ config :pleroma, :instance, password_reset_token_validity: 60 * 60 * 24, profile_directory: true, privileged_staff: false, - local_bubble: [] + local_bubble: [], + max_frontend_settings_json_chars: 100_000 config :pleroma, :welcome, direct_message: [ diff --git a/lib/pleroma/akkoma/frontend_setting_profile.ex b/lib/pleroma/akkoma/frontend_setting_profile.ex new file mode 100644 index 000000000..18208a7dd --- /dev/null +++ b/lib/pleroma/akkoma/frontend_setting_profile.ex @@ -0,0 +1,100 @@ +defmodule Pleroma.Akkoma.FrontendSettingsProfile do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + alias Pleroma.Repo + alias Pleroma.Config + alias Pleroma.User + + @primary_key false + schema "user_frontend_setting_profiles" do + belongs_to(:user, Pleroma.User, primary_key: true, type: FlakeId.Ecto.CompatType) + field(:frontend_name, :string, primary_key: true) + field(:profile_name, :string, primary_key: true) + field(:settings, :map) + field(:version, :integer) + timestamps() + end + + def changeset(%__MODULE__{} = struct, attrs) do + struct + |> cast(attrs, [:user_id, :frontend_name, :profile_name, :settings, :version]) + |> validate_required([:user_id, :frontend_name, :profile_name, :settings, :version]) + |> validate_length(:frontend_name, min: 1, max: 255) + |> validate_length(:profile_name, min: 1, max: 255) + |> validate_version(struct) + |> validate_number(:version, greater_than: 0) + |> validate_settings_length(Config.get([:instance, :max_frontend_settings_json_chars])) + end + + def create_or_update(%User{} = user, frontend_name, profile_name, settings, version) do + struct = + case get_by_user_and_frontend_name_and_profile_name(user, frontend_name, profile_name) do + nil -> + %__MODULE__{} + + %__MODULE__{} = profile -> + profile + end + + struct + |> changeset(%{ + user_id: user.id, + frontend_name: frontend_name, + profile_name: profile_name, + settings: settings, + version: version + }) + |> Repo.insert_or_update() + end + + def get_all_by_user_and_frontend_name(%User{id: user_id}, frontend_name) do + Repo.all( + from(p in __MODULE__, where: p.user_id == ^user_id and p.frontend_name == ^frontend_name) + ) + end + + def get_by_user_and_frontend_name_and_profile_name( + %User{id: user_id}, + frontend_name, + profile_name + ) do + Repo.one( + from(p in __MODULE__, + where: + p.user_id == ^user_id and p.frontend_name == ^frontend_name and + p.profile_name == ^profile_name + ) + ) + end + + def delete_profile(profile) do + Repo.delete(profile) + end + + defp validate_settings_length( + %Ecto.Changeset{changes: %{settings: settings}} = changeset, + max_length + ) do + settings_json = Jason.encode!(settings) + + if String.length(settings_json) > max_length do + add_error(changeset, :settings, "is too long") + else + changeset + end + end + + defp validate_version(changeset, %{version: nil}), do: changeset + + defp validate_version(%Ecto.Changeset{changes: %{version: version}} = changeset, %{ + version: prev_version + }) do + if version != prev_version + 1 do + add_error(changeset, :version, "must be incremented by 1") + else + changeset + end + end +end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index a36c1c330..700cab2b5 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -165,6 +165,8 @@ defmodule Pleroma.User do has_many(:outgoing_relationships, UserRelationship, foreign_key: :source_id) has_many(:incoming_relationships, UserRelationship, foreign_key: :target_id) + has_many(:frontend_profiles, Pleroma.Akkoma.FrontendSettingsProfile) + for {relationship_type, [ {outgoing_relation, outgoing_relation_target}, diff --git a/lib/pleroma/web/akkoma_api/controllers/frontend_settings_controller.ex b/lib/pleroma/web/akkoma_api/controllers/frontend_settings_controller.ex new file mode 100644 index 000000000..c13ff9096 --- /dev/null +++ b/lib/pleroma/web/akkoma_api/controllers/frontend_settings_controller.ex @@ -0,0 +1,96 @@ +defmodule Pleroma.Web.AkkomaAPI.FrontendSettingsController do + use Pleroma.Web, :controller + + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Akkoma.FrontendSettingsProfile + + @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []} + plug( + OAuthScopesPlug, + %{@unauthenticated_access | scopes: ["read:accounts"]} + when action in [ + :list_profiles, + :get_profile + ] + ) + + plug( + OAuthScopesPlug, + %{@unauthenticated_access | scopes: ["write:accounts"]} + when action in [ + :update_profile, + :delete_profile + ] + ) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FrontendSettingsOperation + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "GET /api/v1/akkoma/frontend_settings/:frontend_name/:profile_name" + def get_profile(conn, %{frontend_name: frontend_name, profile_name: profile_name}) do + with %FrontendSettingsProfile{} = profile <- + FrontendSettingsProfile.get_by_user_and_frontend_name_and_profile_name( + conn.assigns.user, + frontend_name, + profile_name + ) do + conn + |> json(%{ + settings: profile.settings, + version: profile.version + }) + else + nil -> {:error, :not_found} + end + end + + @doc "GET /api/v1/akkoma/frontend_settings/:frontend_name" + def list_profiles(conn, %{frontend_name: frontend_name}) do + with profiles <- + FrontendSettingsProfile.get_all_by_user_and_frontend_name( + conn.assigns.user, + frontend_name + ), + data <- + Enum.map(profiles, fn profile -> + %{name: profile.profile_name, version: profile.version} + end) do + json(conn, data) + end + end + + @doc "DELETE /api/v1/akkoma/frontend_settings/:frontend_name/:profile_name" + def delete_profile(conn, %{frontend_name: frontend_name, profile_name: profile_name}) do + with %FrontendSettingsProfile{} = profile <- + FrontendSettingsProfile.get_by_user_and_frontend_name_and_profile_name( + conn.assigns.user, + frontend_name, + profile_name + ), + {:ok, _} <- FrontendSettingsProfile.delete_profile(profile) do + json(conn, %{deleted: "ok"}) + else + nil -> {:error, :not_found} + end + end + + @doc "PUT /api/v1/akkoma/frontend_settings/:frontend_name/:profile_name" + def update_profile(%{body_params: %{settings: settings, version: version}} = conn, %{ + frontend_name: frontend_name, + profile_name: profile_name + }) do + with {:ok, profile} <- + FrontendSettingsProfile.create_or_update( + conn.assigns.user, + frontend_name, + profile_name, + settings, + version + ) do + conn + |> json(profile.settings) + end + end +end diff --git a/lib/pleroma/web/api_spec/operations/frontend_settings_operation.ex b/lib/pleroma/web/api_spec/operations/frontend_settings_operation.ex new file mode 100644 index 000000000..40e81ad55 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/frontend_settings_operation.ex @@ -0,0 +1,133 @@ +defmodule Pleroma.Web.ApiSpec.FrontendSettingsOperation 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 + + @spec list_profiles_operation() :: Operation.t() + def list_profiles_operation() do + %Operation{ + tags: ["Retrieve frontend setting profiles"], + summary: "Frontend Settings Profiles", + description: "List frontend setting profiles", + operationId: "AkkomaAPI.FrontendSettingsController.list_profiles", + parameters: [frontend_name_param()], + security: [%{"oAuth" => ["read:accounts"]}], + responses: %{ + 200 => + Operation.response("Profiles", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string}, + version: %Schema{type: :integer} + } + } + }) + } + } + end + + @spec get_profile_operation() :: Operation.t() + def get_profile_operation() do + %Operation{ + tags: ["Retrieve frontend setting profile"], + summary: "Frontend Settings Profile", + description: "Get frontend setting profile", + operationId: "AkkomaAPI.FrontendSettingsController.get_profile", + security: [%{"oAuth" => ["read:accounts"]}], + parameters: [frontend_name_param(), profile_name_param()], + responses: %{ + 200 => + Operation.response("Profile", "application/json", %Schema{ + type: :object, + properties: %{ + "version" => %Schema{type: :integer}, + "settings" => %Schema{type: :object, additionalProperties: true} + } + }), + 404 => Operation.response("Not Found", "application/json", %Schema{type: :object}) + } + } + end + + @spec delete_profile_operation() :: Operation.t() + def delete_profile_operation() do + %Operation{ + tags: ["Delete frontend setting profile"], + summary: "Delete frontend Settings Profile", + description: "Delete frontend setting profile", + operationId: "AkkomaAPI.FrontendSettingsController.delete_profile", + security: [%{"oAuth" => ["write:accounts"]}], + parameters: [frontend_name_param(), profile_name_param()], + responses: %{ + 200 => Operation.response("Empty", "application/json", %Schema{type: :object}), + 404 => Operation.response("Not Found", "application/json", %Schema{type: :object}) + } + } + end + + @spec update_profile_operation() :: Operation.t() + def update_profile_operation() do + %Operation{ + tags: ["Update frontend setting profile"], + summary: "Frontend Settings Profile", + description: "Update frontend setting profile", + operationId: "AkkomaAPI.FrontendSettingsController.update_profile_operation", + security: [%{"oAuth" => ["write:accounts"]}], + parameters: [frontend_name_param(), profile_name_param()], + requestBody: profile_body_param(), + responses: %{ + 200 => Operation.response("Settings", "application/json", %Schema{type: :object}), + 422 => Operation.response("Invalid", "application/json", %Schema{type: :object}) + } + } + end + + def frontend_name_param do + Operation.parameter(:frontend_name, :path, :string, "Frontend name", + example: "pleroma-fe", + required: true + ) + end + + def profile_name_param do + Operation.parameter(:profile_name, :path, :string, "Profile name", + example: "mobile", + required: true + ) + end + + def profile_body_param do + request_body( + "Settings", + %Schema{ + title: "Frontend Setting Profile", + type: :object, + required: [:version, :settings], + properties: %{ + version: %Schema{ + type: :integer, + description: "Version of the profile, must increment by 1 each time", + example: 1 + }, + settings: %Schema{ + type: :object, + description: "Settings of the profile", + example: %{ + theme: "dark", + locale: "en" + } + } + } + }, + required: true + ) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index f722d94f7..838599c4d 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -466,6 +466,26 @@ defmodule Pleroma.Web.Router do scope "/api/v1/akkoma", Pleroma.Web.AkkomaAPI do pipe_through(:authenticated_api) get("/translation/languages", TranslationController, :languages) + + get("/frontend_settings/:frontend_name", FrontendSettingsController, :list_profiles) + + get( + "/frontend_settings/:frontend_name/:profile_name", + FrontendSettingsController, + :get_profile + ) + + put( + "/frontend_settings/:frontend_name/:profile_name", + FrontendSettingsController, + :update_profile + ) + + delete( + "/frontend_settings/:frontend_name/:profile_name", + FrontendSettingsController, + :delete_profile + ) end scope "/api/v1", Pleroma.Web.MastodonAPI do diff --git a/mix.exs b/mix.exs index 19e6fd045..c7e66b158 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do def project do [ app: :pleroma, - version: version("3.2.0"), + version: version("3.3.0"), elixir: "~> 1.12", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), diff --git a/priv/repo/migrations/20220911195347_add_user_frontend_profiles.exs b/priv/repo/migrations/20220911195347_add_user_frontend_profiles.exs new file mode 100644 index 000000000..c1c449719 --- /dev/null +++ b/priv/repo/migrations/20220911195347_add_user_frontend_profiles.exs @@ -0,0 +1,29 @@ +defmodule Pleroma.Repo.Migrations.AddUserFrontendProfiles do + use Ecto.Migration + + def up do + create_if_not_exists table("user_frontend_setting_profiles", primary_key: false) do + add(:user_id, references(:users, type: :uuid, on_delete: :delete_all), primary_key: true) + add(:frontend_name, :string, primary_key: true) + add(:profile_name, :string, primary_key: true) + add(:version, :integer) + add(:settings, :map) + timestamps() + end + + create_if_not_exists(index(:user_frontend_setting_profiles, [:user_id, :frontend_name])) + + create_if_not_exists( + unique_index(:user_frontend_setting_profiles, [:user_id, :frontend_name, :profile_name]) + ) + end + + def down do + drop_if_exists(table("user_frontend_setting_profiles")) + drop_if_exists(index(:user_frontend_setting_profiles, [:user_id, :frontend_name])) + + drop_if_exists( + unique_index(:user_frontend_setting_profiles, [:user_id, :frontend_name, :profile_name]) + ) + end +end diff --git a/test/pleroma/akkoma/frontend_setting_profile_test.exs b/test/pleroma/akkoma/frontend_setting_profile_test.exs new file mode 100644 index 000000000..4bb1139a8 --- /dev/null +++ b/test/pleroma/akkoma/frontend_setting_profile_test.exs @@ -0,0 +1,196 @@ +defmodule Pleroma.Akkoma.FrontendSettingsProfileTest do + use Pleroma.DataCase, async: true + use Oban.Testing, repo: Pleroma.Repo + alias Pleroma.Akkoma.FrontendSettingsProfile + + import Pleroma.Factory + + describe "changeset/2" do + test "valid" do + user = insert(:user) + frontend_name = "test" + profile_name = "test" + settings = %{"test" => "test"} + struct = %FrontendSettingsProfile{} + + attrs = %{ + user_id: user.id, + frontend_name: frontend_name, + profile_name: profile_name, + settings: settings, + version: 1 + } + + assert %{valid?: true} = FrontendSettingsProfile.changeset(struct, attrs) + end + + test "when settings is too long" do + clear_config([:instance, :max_frontend_settings_json_chars], 10) + user = insert(:user) + frontend_name = "test" + profile_name = "test" + settings = %{"verylong" => "verylongoops"} + struct = %FrontendSettingsProfile{} + + attrs = %{ + user_id: user.id, + frontend_name: frontend_name, + profile_name: profile_name, + settings: settings, + version: 1 + } + + assert %{valid?: false, errors: [settings: {"is too long", _}]} = + FrontendSettingsProfile.changeset(struct, attrs) + end + + test "when frontend name is too short" do + user = insert(:user) + frontend_name = "" + profile_name = "test" + settings = %{"test" => "test"} + struct = %FrontendSettingsProfile{} + + attrs = %{ + user_id: user.id, + frontend_name: frontend_name, + profile_name: profile_name, + settings: settings, + version: 1 + } + + assert %{valid?: false, errors: [frontend_name: {"can't be blank", _}]} = + FrontendSettingsProfile.changeset(struct, attrs) + end + + test "when profile name is too short" do + user = insert(:user) + frontend_name = "test" + profile_name = "" + settings = %{"test" => "test"} + struct = %FrontendSettingsProfile{} + + attrs = %{ + user_id: user.id, + frontend_name: frontend_name, + profile_name: profile_name, + settings: settings, + version: 1 + } + + assert %{valid?: false, errors: [profile_name: {"can't be blank", _}]} = + FrontendSettingsProfile.changeset(struct, attrs) + end + + test "when version is negative" do + user = insert(:user) + frontend_name = "test" + profile_name = "test" + settings = %{"test" => "test"} + struct = %FrontendSettingsProfile{} + + attrs = %{ + user_id: user.id, + frontend_name: frontend_name, + profile_name: profile_name, + settings: settings, + version: -1 + } + + assert %{valid?: false, errors: [version: {"must be greater than %{number}", _}]} = + FrontendSettingsProfile.changeset(struct, attrs) + end + end + + describe "create_or_update/2" do + test "it should create a new record" do + user = insert(:user) + frontend_name = "test" + profile_name = "test" + settings = %{"test" => "test"} + + assert {:ok, %FrontendSettingsProfile{}} = + FrontendSettingsProfile.create_or_update( + user, + frontend_name, + profile_name, + settings, + 1 + ) + end + + test "it should update a record" do + user = insert(:user) + frontend_name = "test" + profile_name = "test" + + insert(:frontend_setting_profile, + user: user, + frontend_name: frontend_name, + profile_name: profile_name, + settings: %{"test" => "test"}, + version: 1 + ) + + settings = %{"test" => "test2"} + + assert {:ok, %FrontendSettingsProfile{settings: ^settings}} = + FrontendSettingsProfile.create_or_update( + user, + frontend_name, + profile_name, + settings, + 2 + ) + end + end + + describe "get_all_by_user_and_frontend_name/2" do + test "it should return all records" do + user = insert(:user) + frontend_name = "test" + + insert(:frontend_setting_profile, + user: user, + frontend_name: frontend_name, + profile_name: "profileA", + settings: %{"test" => "test"}, + version: 1 + ) + + insert(:frontend_setting_profile, + user: user, + frontend_name: frontend_name, + profile_name: "profileB", + settings: %{"test" => "test"}, + version: 1 + ) + + assert [%FrontendSettingsProfile{profile_name: "profileA"}, %{profile_name: "profileB"}] = + FrontendSettingsProfile.get_all_by_user_and_frontend_name(user, frontend_name) + end + end + + describe "get_by_user_and_frontend_name_and_profile_name/3" do + test "it should return a record" do + user = insert(:user) + frontend_name = "test" + profile_name = "profileA" + + insert(:frontend_setting_profile, + user: user, + frontend_name: frontend_name, + profile_name: profile_name, + settings: %{"test" => "test"}, + version: 1 + ) + + assert %FrontendSettingsProfile{profile_name: "profileA"} = + FrontendSettingsProfile.get_by_user_and_frontend_name_and_profile_name( + user, + frontend_name, + profile_name + ) + end + end +end diff --git a/test/pleroma/web/akkoma_api/frontend_settings_controller_test.exs b/test/pleroma/web/akkoma_api/frontend_settings_controller_test.exs new file mode 100644 index 000000000..4909ef3a7 --- /dev/null +++ b/test/pleroma/web/akkoma_api/frontend_settings_controller_test.exs @@ -0,0 +1,122 @@ +defmodule Pleroma.Web.AkkomaAPI.FrontendSettingsControllerTest do + use Pleroma.Web.ConnCase, async: true + + import Pleroma.Factory + alias Pleroma.Akkoma.FrontendSettingsProfile + + describe "GET /api/v1/akkoma/frontend_settings/:frontend_name" do + test "it returns a list of profiles" do + %{conn: conn, user: user} = oauth_access(["read"]) + + insert(:frontend_setting_profile, user: user, frontend_name: "test", profile_name: "test1") + insert(:frontend_setting_profile, user: user, frontend_name: "test", profile_name: "test2") + + response = + conn + |> get("/api/v1/akkoma/frontend_settings/test") + |> json_response_and_validate_schema(200) + + assert response == [ + %{"name" => "test1", "version" => 1}, + %{"name" => "test2", "version" => 1} + ] + end + end + + describe "GET /api/v1/akkoma/frontend_settings/:frontend_name/:profile_name" do + test "it returns 404 if not found" do + %{conn: conn} = oauth_access(["read"]) + + conn + |> get("/api/v1/akkoma/frontend_settings/unknown_frontend/unknown_profile") + |> json_response_and_validate_schema(404) + end + + test "it returns 200 if found" do + %{conn: conn, user: user} = oauth_access(["read"]) + + insert(:frontend_setting_profile, + user: user, + frontend_name: "test", + profile_name: "test1", + settings: %{"test" => "test"} + ) + + response = + conn + |> get("/api/v1/akkoma/frontend_settings/test/test1") + |> json_response_and_validate_schema(200) + + assert response == %{"settings" => %{"test" => "test"}, "version" => 1} + end + end + + describe "PUT /api/v1/akkoma/frontend_settings/:frontend_name/:profile_name" do + test "puts a config" do + %{conn: conn, user: user} = oauth_access(["write"]) + settings = %{"test" => "test2"} + + response = + conn + |> put_req_header("content-type", "application/json") + |> put("/api/v1/akkoma/frontend_settings/test/test1", %{ + "settings" => settings, + "version" => 1 + }) + |> json_response_and_validate_schema(200) + + assert response == settings + + assert %FrontendSettingsProfile{settings: ^settings} = + FrontendSettingsProfile.get_by_user_and_frontend_name_and_profile_name( + user, + "test", + "test1" + ) + end + + test "refuses to overwrite a newer config" do + %{conn: conn, user: user} = oauth_access(["write"]) + + insert(:frontend_setting_profile, + user: user, + frontend_name: "test", + profile_name: "test1", + settings: %{"test" => "test"}, + version: 2 + ) + + conn + |> put_req_header("content-type", "application/json") + |> put("/api/v1/akkoma/frontend_settings/test/test1", %{ + "settings" => %{"test" => "test2"}, + "version" => 1 + }) + |> json_response_and_validate_schema(422) + end + end + + describe "DELETE /api/v1/akkoma/frontend_settings/:frontend_name/:profile_name" do + test "deletes a config" do + %{conn: conn, user: user} = oauth_access(["write"]) + + insert(:frontend_setting_profile, + user: user, + frontend_name: "test", + profile_name: "test1", + settings: %{"test" => "test"}, + version: 2 + ) + + conn + |> delete("/api/v1/akkoma/frontend_settings/test/test1") + |> json_response_and_validate_schema(200) + + assert FrontendSettingsProfile.get_by_user_and_frontend_name_and_profile_name( + user, + "test", + "test1" + ) == nil + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index efcd8039e..54d385bc4 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -663,4 +663,15 @@ defmodule Pleroma.Factory do |> Map.merge(params) |> Pleroma.Announcement.add_rendered_properties() end + + def frontend_setting_profile_factory(params \\ %{}) do + %Pleroma.Akkoma.FrontendSettingsProfile{ + user: build(:user), + frontend_name: "akkoma-fe", + profile_name: "default", + settings: %{"test" => "test"}, + version: 1 + } + |> Map.merge(params) + end end