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
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: [
--- /dev/null
+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
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},
--- /dev/null
+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
--- /dev/null
+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
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
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(),
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
|> 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