From d7af67012f64d09bb50259188473c1c94418b3a2 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 00:06:07 -0500 Subject: [PATCH 01/16] Implement first pass of announcement admin api CCBUG: https://git.pleroma.social/pleroma/pleroma/-/issues/2836 CCBUG: https://git.pleroma.social/pleroma/pleroma/-/issues/1470 --- lib/pleroma/announcement.ex | 51 ++++++++ .../controllers/announcement_controller.ex | 60 ++++++++++ .../web/admin_api/views/announcement_view.ex | 20 ++++ .../admin/announcement_operation.ex | 109 ++++++++++++++++++ .../web/api_spec/schemas/announcement.ex | 22 ++++ lib/pleroma/web/router.ex | 5 + .../20220308012601_create_announcements.exs | 23 ++++ .../announcement_controller_test.exs | 94 +++++++++++++++ test/support/factory.ex | 8 ++ 9 files changed, 392 insertions(+) create mode 100644 lib/pleroma/announcement.ex create mode 100644 lib/pleroma/web/admin_api/controllers/announcement_controller.ex create mode 100644 lib/pleroma/web/admin_api/views/announcement_view.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex create mode 100644 lib/pleroma/web/api_spec/schemas/announcement.ex create mode 100644 priv/repo/migrations/20220308012601_create_announcements.exs create mode 100644 test/pleroma/web/admin_api/controllers/announcement_controller_test.exs diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex new file mode 100644 index 000000000..8cf917c9d --- /dev/null +++ b/lib/pleroma/announcement.ex @@ -0,0 +1,51 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Announcement do + use Ecto.Schema + + import Ecto.Changeset, only: [cast: 3, validate_required: 2] + + alias Pleroma.Repo + + @type t :: %__MODULE__{} + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + + schema "announcements" do + field(:data, :map) + + timestamps() + end + + def change(struct, params \\ %{}) do + struct + |> cast(params, [:data]) + |> validate_required([:data]) + end + + def add(params) do + changeset = change(%__MODULE__{}, params) + + Repo.insert(changeset) + end + + def list_all do + __MODULE__ + |> Repo.all() + end + + def get_by_id(id) do + Repo.get_by(__MODULE__, id: id) + end + + def delete_by_id(id) do + with announcement when not is_nil(announcement) <- get_by_id(id), + {:ok, _} <- Repo.delete(announcement) do + :ok + else + _ -> + :error + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/announcement_controller.ex b/lib/pleroma/web/admin_api/controllers/announcement_controller.ex new file mode 100644 index 000000000..803408057 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/announcement_controller.ex @@ -0,0 +1,60 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.AnnouncementController do + use Pleroma.Web, :controller + + alias Pleroma.Announcement + alias Pleroma.Web.ControllerHelper + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["admin:write"]} when action in [:create, :delete]) + plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action in [:index, :show]) + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.AnnouncementOperation + + def index(conn, _params) do + announcements = Announcement.list_all() + + render(conn, "index.json", announcements: announcements) + end + + def show(conn, %{id: id} = _params) do + announcement = Announcement.get_by_id(id) + + if is_nil(announcement) do + {:error, :not_found} + else + render(conn, "show.json", announcement: announcement) + end + end + + def create(%{body_params: %{content: content}} = conn, _params) do + add_params = %{ + data: %{ + "content" => content + } + } + + with {:ok, announcement} <- Announcement.add(add_params) do + render(conn, "show.json", announcement: announcement) + else + _ -> + {:error, 400} + end + end + + def delete(conn, %{id: id} = _params) do + case Announcement.delete_by_id(id) do + :ok -> + conn + |> ControllerHelper.json_response(:ok, %{}) + + _ -> + {:error, :not_found} + end + end +end diff --git a/lib/pleroma/web/admin_api/views/announcement_view.ex b/lib/pleroma/web/admin_api/views/announcement_view.ex new file mode 100644 index 000000000..5ad9a454a --- /dev/null +++ b/lib/pleroma/web/admin_api/views/announcement_view.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.AnnouncementView do + use Pleroma.Web, :view + + def render("index.json", %{announcements: announcements}) do + render_many(announcements, __MODULE__, "show.json") + end + + def render("show.json", %{announcement: announcement}) do + %{ + id: announcement.id, + content: announcement.data["content"], + published_at: announcement.inserted_at, + updated_at: announcement.updated_at + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex b/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex new file mode 100644 index 000000000..e4212dd06 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex @@ -0,0 +1,109 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.AnnouncementOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Announcement + 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: ["Announcement managment"], + summary: "Retrieve a list of announcements", + operationId: "AdminAPI.AnnouncementController.index", + security: [%{"oAuth" => ["admin:read"]}], + responses: %{ + 200 => Operation.response("Response", "application/json", list_of_announcements()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Announcement managment"], + summary: "Display one announcement", + operationId: "AdminAPI.AnnouncementController.show", + security: [%{"oAuth" => ["admin:read"]}], + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "announcement id" + ) + | admin_api_params() + ], + responses: %{ + 200 => Operation.response("Response", "application/json", Announcement), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Announcement managment"], + summary: "Delete one announcement", + operationId: "AdminAPI.AnnouncementController.delete", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "announcement id" + ) + | admin_api_params() + ], + responses: %{ + 200 => Operation.response("Response", "application/json", %Schema{type: :object}), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def create_operation do + %Operation{ + tags: ["Announcement managment"], + summary: "Create one announcement", + operationId: "AdminAPI.AnnouncementController.create", + security: [%{"oAuth" => ["admin:write"]}], + requestBody: request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Response", "application/json", Announcement), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def create_request do + %Schema{ + title: "AnnouncementCreateRequest", + type: :object, + required: [:content], + properties: %{ + content: %Schema{type: :string} + } + } + end + + def list_of_announcements do + %Schema{ + type: :array, + items: Announcement + } + end +end diff --git a/lib/pleroma/web/api_spec/schemas/announcement.ex b/lib/pleroma/web/api_spec/schemas/announcement.ex new file mode 100644 index 000000000..26f93c730 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/announcement.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Announcement do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Account", + description: "Response schema for an account", + type: :object, + properties: %{ + id: FlakeID, + content: %Schema{type: :string}, + published_at: %Schema{type: :string, format: "date-time"}, + updated_at: %Schema{type: :string, format: "date-time"} + } + }) +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index ceb6c3cfd..e63c51943 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -229,6 +229,11 @@ defmodule Pleroma.Web.Router do post("/frontends/install", FrontendController, :install) post("/backups", AdminAPIController, :create_backup) + + get("/announcements", AnnouncementController, :index) + post("/announcements", AnnouncementController, :create) + get("/announcements/:id", AnnouncementController, :show) + delete("/announcements/:id", AnnouncementController, :delete) end # AdminAPI: admins and mods (staff) can perform these actions (if enabled by config) diff --git a/priv/repo/migrations/20220308012601_create_announcements.exs b/priv/repo/migrations/20220308012601_create_announcements.exs new file mode 100644 index 000000000..e7c3b10a3 --- /dev/null +++ b/priv/repo/migrations/20220308012601_create_announcements.exs @@ -0,0 +1,23 @@ +defmodule Pleroma.Repo.Migrations.CreateAnnouncements do + use Ecto.Migration + + def change do + create_if_not_exists table(:announcements, primary_key: false) do + add(:id, :uuid, primary_key: true) + add(:data, :map) + + timestamps() + end + + create_if_not_exists table(:announcement_read_relationships) do + add(:user_id, references(:users, type: :uuid, on_delete: :delete_all)) + add(:announcement_id, references(:announcements, type: :uuid, on_delete: :delete_all)) + + timestamps(updated_at: false) + end + + create_if_not_exists( + unique_index(:announcement_read_relationships, [:user_id, :announcement_id]) + ) + end +end diff --git a/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs b/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs new file mode 100644 index 000000000..d667526c3 --- /dev/null +++ b/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs @@ -0,0 +1,94 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.AnnouncementControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + setup do + 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/v1/pleroma/admin/announcements" do + test "it lists all announcements", %{conn: conn} do + %{id: id} = insert(:announcement) + + response = + conn + |> get("/api/v1/pleroma/admin/announcements") + |> json_response_and_validate_schema(:ok) + + assert [%{"id" => ^id}] = response + end + end + + describe "GET /api/v1/pleroma/admin/announcements/:id" do + test "it displays one announcement", %{conn: conn} do + %{id: id} = insert(:announcement) + + response = + conn + |> get("/api/v1/pleroma/admin/announcements/#{id}") + |> json_response_and_validate_schema(:ok) + + assert %{"id" => ^id} = response + end + + test "it returns not found for non-existent id", %{conn: conn} do + %{id: id} = insert(:announcement) + + _response = + conn + |> get("/api/v1/pleroma/admin/announcements/#{id}xxx") + |> json_response_and_validate_schema(:not_found) + end + end + + describe "DELETE /api/v1/pleroma/admin/announcements/:id" do + test "it deletes specified announcement", %{conn: conn} do + %{id: id} = insert(:announcement) + + _response = + conn + |> delete("/api/v1/pleroma/admin/announcements/#{id}") + |> json_response_and_validate_schema(:ok) + end + + test "it returns not found for non-existent id", %{conn: conn} do + %{id: id} = insert(:announcement) + + _response = + conn + |> get("/api/v1/pleroma/admin/announcements/#{id}xxx") + |> json_response_and_validate_schema(:not_found) + + assert %{id: ^id} = Pleroma.Announcement.get_by_id(id) + end + end + + describe "POST /api/v1/pleroma/admin/announcements" do + test "it creates an announcement", %{conn: conn} do + content = "test post announcement api" + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/admin/announcements", %{ + "content" => content + }) + |> json_response_and_validate_schema(:ok) + + assert %{"content" => ^content} = response + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 09456debf..85ea49cb2 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -627,4 +627,12 @@ defmodule Pleroma.Factory do context: ["home"] } end + + def announcement_factory do + %Pleroma.Announcement{ + data: %{ + "content" => "test announcement" + } + } + end end From c867d232505189d907e72d7501817e8a1f287f1c Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 09:35:35 -0500 Subject: [PATCH 02/16] Fill properties of announcements from Mastodon API spec --- lib/pleroma/announcement.ex | 32 +++++++++++++++++++ .../web/admin_api/views/announcement_view.ex | 7 +--- .../web/api_spec/schemas/announcement.ex | 19 +++++++++-- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex index 8cf917c9d..968136d16 100644 --- a/lib/pleroma/announcement.ex +++ b/lib/pleroma/announcement.ex @@ -48,4 +48,36 @@ defmodule Pleroma.Announcement do :error end end + + def read_by?(_announcement, _user) do + false + end + + def render_json(announcement, opts \\ []) do + extra_params = + case Keyword.fetch(opts, :for) do + {:ok, user} -> + %{read: read_by?(announcement, user)} + _ -> + %{} + end + + base = %{ + id: announcement.id, + content: announcement.data["content"], + starts_at: :null, + ends_at: :null, + all_day: false, + published_at: announcement.inserted_at, + updated_at: announcement.updated_at, + mentions: [], + statuses: [], + tags: [], + emojis: [], + reactions: [] + } + + base + |> Map.merge(extra_params) + end end diff --git a/lib/pleroma/web/admin_api/views/announcement_view.ex b/lib/pleroma/web/admin_api/views/announcement_view.ex index 5ad9a454a..8fbdc338a 100644 --- a/lib/pleroma/web/admin_api/views/announcement_view.ex +++ b/lib/pleroma/web/admin_api/views/announcement_view.ex @@ -10,11 +10,6 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementView do end def render("show.json", %{announcement: announcement}) do - %{ - id: announcement.id, - content: announcement.data["content"], - published_at: announcement.inserted_at, - updated_at: announcement.updated_at - } + Pleroma.Announcement.render_json(announcement) end end diff --git a/lib/pleroma/web/api_spec/schemas/announcement.ex b/lib/pleroma/web/api_spec/schemas/announcement.ex index 26f93c730..433437aac 100644 --- a/lib/pleroma/web/api_spec/schemas/announcement.ex +++ b/lib/pleroma/web/api_spec/schemas/announcement.ex @@ -9,14 +9,27 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Announcement do require OpenApiSpex OpenApiSpex.schema(%{ - title: "Account", - description: "Response schema for an account", + title: "Announcement", + description: "Response schema for an announcement", type: :object, properties: %{ id: FlakeID, content: %Schema{type: :string}, + starts_at: %Schema{ + oneOf: [%Schema{type: :null}, %Schema{type: :string, format: "date-time"}] + }, + ends_at: %Schema{ + oneOf: [%Schema{type: :null}, %Schema{type: :string, format: "date-time"}] + }, + all_day: %Schema{type: :boolean}, published_at: %Schema{type: :string, format: "date-time"}, - updated_at: %Schema{type: :string, format: "date-time"} + updated_at: %Schema{type: :string, format: "date-time"}, + read: %Schema{type: :boolean}, + mentions: %Schema{type: :array}, + statuses: %Schema{type: :array}, + tags: %Schema{type: :array}, + emojis: %Schema{type: :array}, + reactions: %Schema{type: :array} } }) end From 5169ad8f143d613f3fa179f88e2be6a16445df41 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 12:07:21 -0500 Subject: [PATCH 03/16] Implement announcement read relationships --- lib/pleroma/announcement.ex | 12 ++- lib/pleroma/announcement_read_relationship.ex | 55 +++++++++++++ .../operations/announcement_operation.ex | 77 +++++++++++++++++++ .../controllers/announcement_controller.ex | 61 +++++++++++++++ .../mastodon_api/views/announcement_view.ex | 15 ++++ lib/pleroma/web/router.ex | 5 ++ .../announcement_read_relationship_test.exs | 40 ++++++++++ .../announcement_controller_test.exs | 54 +++++++++++++ 8 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 lib/pleroma/announcement_read_relationship.ex create mode 100644 lib/pleroma/web/api_spec/operations/announcement_operation.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/views/announcement_view.ex create mode 100644 test/pleroma/announcement_read_relationship_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex index 968136d16..b0e787fe0 100644 --- a/lib/pleroma/announcement.ex +++ b/lib/pleroma/announcement.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Announcement do import Ecto.Changeset, only: [cast: 3, validate_required: 2] + alias Pleroma.AnnouncementReadRelationship alias Pleroma.Repo @type t :: %__MODULE__{} @@ -49,15 +50,20 @@ defmodule Pleroma.Announcement do end end - def read_by?(_announcement, _user) do - false + def read_by?(announcement, user) do + AnnouncementReadRelationship.exists?(user, announcement) + end + + def mark_read_by(announcement, user) do + AnnouncementReadRelationship.mark_read(user, announcement) end def render_json(announcement, opts \\ []) do extra_params = case Keyword.fetch(opts, :for) do - {:ok, user} -> + {:ok, user} when not is_nil(user) -> %{read: read_by?(announcement, user)} + _ -> %{} end diff --git a/lib/pleroma/announcement_read_relationship.ex b/lib/pleroma/announcement_read_relationship.ex new file mode 100644 index 000000000..9b64404ce --- /dev/null +++ b/lib/pleroma/announcement_read_relationship.ex @@ -0,0 +1,55 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.AnnouncementReadRelationship do + use Ecto.Schema + + import Ecto.Changeset + + alias FlakeId.Ecto.CompatType + alias Pleroma.Announcement + alias Pleroma.Repo + alias Pleroma.User + + @type t :: %__MODULE__{} + + schema "announcement_read_relationships" do + belongs_to(:user, User, type: CompatType) + belongs_to(:announcement, Announcement, type: CompatType) + + timestamps(updated_at: false) + end + + def mark_read(user, announcement) do + %__MODULE__{} + |> cast(%{user_id: user.id, announcement_id: announcement.id}, [:user_id, :announcement_id]) + |> validate_required([:user_id, :announcement_id]) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:announcement_id) + |> unique_constraint([:user_id, :announcement_id]) + |> Repo.insert() + end + + def mark_unread(user, announcement) do + with relationship <- get(user, announcement), + {:exists, true} <- {:exists, not is_nil(relationship)}, + {:ok, _} <- Repo.delete(relationship) do + :ok + else + {:exists, false} -> + :ok + + _ -> + :error + end + end + + def get(user, announcement) do + Repo.get_by(__MODULE__, user_id: user.id, announcement_id: announcement.id) + end + + def exists?(user, announcement) do + not is_nil(get(user, announcement)) + end +end diff --git a/lib/pleroma/web/api_spec/operations/announcement_operation.ex b/lib/pleroma/web/api_spec/operations/announcement_operation.ex new file mode 100644 index 000000000..f99b0c263 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/announcement_operation.ex @@ -0,0 +1,77 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.AnnouncementOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Announcement + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Announcement"], + summary: "Retrieve a list of announcements", + operationId: "MastodonAPI.AnnouncementController.index", + responses: %{ + 200 => Operation.response("Response", "application/json", list_of_announcements()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Announcement"], + summary: "Display one announcement", + operationId: "MastodonAPI.AnnouncementController.show", + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "announcement id" + ) + ], + responses: %{ + 200 => Operation.response("Response", "application/json", Announcement), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def mark_read_operation do + %Operation{ + tags: ["Announcement"], + summary: "Mark one announcement as read", + operationId: "MastodonAPI.AnnouncementController.mark_read", + security: [%{"oAuth" => ["write:accounts"]}], + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "announcement id" + ) + ], + responses: %{ + 200 => Operation.response("Response", "application/json", Announcement), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def list_of_announcements do + %Schema{ + type: :array, + items: Announcement + } + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex new file mode 100644 index 000000000..e5fcd3066 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex @@ -0,0 +1,61 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AnnouncementController do + use Pleroma.Web, :controller + + # import Pleroma.Web.ControllerHelper, + # only: [ + # json_response: 3 + # ] + + alias Pleroma.Announcement + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + # MastodonAPI specs do not have oauth requirements for showing + # announcements, but we have "private instance" options. When that + # is set, require read:accounts scope, symmetric to write:accounts + # for `mark_read`. + plug( + OAuthScopesPlug, + %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} + when action in [:show, :index] + ) + + # Same as in MastodonAPI specs + plug( + OAuthScopesPlug, + %{fallback: :proceed_unauthenticated, scopes: ["write:accounts"]} + when action in [:mark_read] + ) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AnnouncementOperation + + @doc "GET /api/v1/announcements" + def index(%{assigns: %{user: user}} = conn, _params) do + render(conn, "index.json", announcements: all_visible(), user: user) + end + + def index(conn, _params) do + render(conn, "index.json", announcements: all_visible(), user: nil) + end + + defp all_visible do + Announcement.list_all() + end + + @doc "POST /api/v1/announcements/:id/dismiss" + def mark_read(_conn, _params) do + {:error, :not_found} + end + + @doc "POST /api/v1/announcements/:id" + def show(_conn, _params) do + {:error, :not_found} + end +end diff --git a/lib/pleroma/web/mastodon_api/views/announcement_view.ex b/lib/pleroma/web/mastodon_api/views/announcement_view.ex new file mode 100644 index 000000000..93fdfb1f1 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/announcement_view.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AnnouncementView do + use Pleroma.Web, :view + + def render("index.json", %{announcements: announcements, user: user}) do + render_many(announcements, __MODULE__, "show.json", user: user) + end + + def render("show.json", %{announcement: announcement, user: user}) do + Pleroma.Announcement.render_json(announcement, for: user) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e63c51943..5be9891b1 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -580,6 +580,8 @@ defmodule Pleroma.Web.Router do get("/timelines/home", TimelineController, :home) get("/timelines/direct", TimelineController, :direct) get("/timelines/list/:list_id", TimelineController, :list) + + post("/announcements/:id/dismiss", AnnouncementController, :mark_read) end scope "/api/v1", Pleroma.Web.MastodonAPI do @@ -624,6 +626,9 @@ defmodule Pleroma.Web.Router do get("/polls/:id", PollController, :show) get("/directory", DirectoryController, :index) + + get("/announcements", AnnouncementController, :index) + get("/announcements/:id", AnnouncementController, :show) end scope "/api/v2", Pleroma.Web.MastodonAPI do diff --git a/test/pleroma/announcement_read_relationship_test.exs b/test/pleroma/announcement_read_relationship_test.exs new file mode 100644 index 000000000..5fd4ffbef --- /dev/null +++ b/test/pleroma/announcement_read_relationship_test.exs @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.AnnouncementReadRelationshipTest do + alias Pleroma.AnnouncementReadRelationship + + use Pleroma.DataCase, async: true + + import Pleroma.Factory + + setup do + {:ok, user: insert(:user), announcement: insert(:announcement)} + end + + describe "mark_read/2" do + test "should insert relationship", %{user: user, announcement: announcement} do + {:ok, _} = AnnouncementReadRelationship.mark_read(user, announcement) + + assert AnnouncementReadRelationship.exists?(user, announcement) + end + end + + describe "mark_unread/2" do + test "should delete relationship", %{user: user, announcement: announcement} do + {:ok, _} = AnnouncementReadRelationship.mark_read(user, announcement) + + assert :ok = AnnouncementReadRelationship.mark_unread(user, announcement) + refute AnnouncementReadRelationship.exists?(user, announcement) + end + + test "should not fail if relationship does not exist", %{ + user: user, + announcement: announcement + } do + assert :ok = AnnouncementReadRelationship.mark_unread(user, announcement) + refute AnnouncementReadRelationship.exists?(user, announcement) + end + end +end diff --git a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs new file mode 100644 index 000000000..134db5fcc --- /dev/null +++ b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + alias Pleroma.AnnouncementReadRelationship + + describe "GET /api/v1/announcements" do + test "it lists all announcements" do + %{id: id} = insert(:announcement) + + response = + build_conn() + |> get("/api/v1/announcements") + |> json_response_and_validate_schema(:ok) + + assert [%{"id" => ^id}] = response + refute Map.has_key?(Enum.at(response, 0), "read") + end + + test "when authenticated, also expose read property" do + %{id: id} = insert(:announcement) + + %{conn: conn} = oauth_access(["read:accounts"]) + + response = + conn + |> get("/api/v1/announcements") + |> json_response_and_validate_schema(:ok) + + assert [%{"id" => ^id, "read" => false}] = response + end + + test "when authenticated and announcement is read by user" do + %{id: id} = announcement = insert(:announcement) + user = insert(:user) + + AnnouncementReadRelationship.mark_read(user, announcement) + + %{conn: conn} = oauth_access(["read:accounts"], user: user) + + response = + conn + |> get("/api/v1/announcements") + |> json_response_and_validate_schema(:ok) + + assert [%{"id" => ^id, "read" => true}] = response + end + end +end From aa1fff279e98c6d16095538382413d3b870cee2b Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 16:19:35 -0500 Subject: [PATCH 04/16] Implement GET /api/v1/announcements/:id --- .../controllers/announcement_controller.ex | 17 +++++- .../announcement_controller_test.exs | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex index e5fcd3066..528d14adc 100644 --- a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex @@ -55,7 +55,20 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementController do end @doc "POST /api/v1/announcements/:id" - def show(_conn, _params) do - {:error, :not_found} + def show(%{assigns: %{user: user}} = conn, %{id: id} = _params) do + render_announcement_by_id(conn, id, user) + end + + def show(conn, %{id: id} = _params) do + render_announcement_by_id(conn, id) + end + + def render_announcement_by_id(conn, id, user \\ nil) do + with announcement when not is_nil(announcement) <- Announcement.get_by_id(id) do + render(conn, "show.json", announcement: announcement, user: user) + else + _ -> + {:error, :not_found} + end end end diff --git a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs index 134db5fcc..090a2706b 100644 --- a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs @@ -51,4 +51,56 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do assert [%{"id" => ^id, "read" => true}] = response end end + + describe "GET /api/v1/announcements/:id" do + test "it shows one announcement" do + %{id: id} = insert(:announcement) + + response = + build_conn() + |> get("/api/v1/announcements/#{id}") + |> json_response_and_validate_schema(:ok) + + assert %{"id" => ^id} = response + refute Map.has_key?(response, "read") + end + + test "it gives 404 for non-existent announcements" do + %{id: id} = insert(:announcement) + + _response = + build_conn() + |> get("/api/v1/announcements/#{id}xxx") + |> json_response_and_validate_schema(:not_found) + end + + test "when authenticated, also expose read property" do + %{id: id} = insert(:announcement) + + %{conn: conn} = oauth_access(["read:accounts"]) + + response = + conn + |> get("/api/v1/announcements/#{id}") + |> json_response_and_validate_schema(:ok) + + assert %{"id" => ^id, "read" => false} = response + end + + test "when authenticated and announcement is read by user" do + %{id: id} = announcement = insert(:announcement) + user = insert(:user) + + AnnouncementReadRelationship.mark_read(user, announcement) + + %{conn: conn} = oauth_access(["read:accounts"], user: user) + + response = + conn + |> get("/api/v1/announcements/#{id}") + |> json_response_and_validate_schema(:ok) + + assert %{"id" => ^id, "read" => true} = response + end + end end From 2b39b36e490fc1222d8f3d737cb26e62af294e03 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 16:59:20 -0500 Subject: [PATCH 05/16] Implement POST /api/v1/announcements/:id/dismiss --- .../operations/announcement_operation.ex | 2 +- .../controllers/announcement_controller.ex | 18 ++++--- .../announcement_controller_test.exs | 50 ++++++++++++++++++- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/announcement_operation.ex b/lib/pleroma/web/api_spec/operations/announcement_operation.ex index f99b0c263..c33b9e135 100644 --- a/lib/pleroma/web/api_spec/operations/announcement_operation.ex +++ b/lib/pleroma/web/api_spec/operations/announcement_operation.ex @@ -61,7 +61,7 @@ defmodule Pleroma.Web.ApiSpec.AnnouncementOperation do ) ], responses: %{ - 200 => Operation.response("Response", "application/json", Announcement), + 200 => Operation.response("Response", "application/json", %Schema{type: :object}), 403 => Operation.response("Forbidden", "application/json", ApiError), 404 => Operation.response("Not Found", "application/json", ApiError) } diff --git a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex index 528d14adc..c51c38d5e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex @@ -5,10 +5,10 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementController do use Pleroma.Web, :controller - # import Pleroma.Web.ControllerHelper, - # only: [ - # json_response: 3 - # ] + import Pleroma.Web.ControllerHelper, + only: [ + json_response: 3 + ] alias Pleroma.Announcement alias Pleroma.Web.Plugs.OAuthScopesPlug @@ -50,8 +50,14 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementController do end @doc "POST /api/v1/announcements/:id/dismiss" - def mark_read(_conn, _params) do - {:error, :not_found} + def mark_read(%{assigns: %{user: user}} = conn, %{id: id} = _params) do + with announcement when not is_nil(announcement) <- Announcement.get_by_id(id), + {:ok, _} <- Announcement.mark_read_by(announcement, user) do + json_response(conn, :ok, %{}) + else + _ -> + {:error, :not_found} + end end @doc "POST /api/v1/announcements/:id" diff --git a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs index 090a2706b..aa056be6e 100644 --- a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs @@ -2,11 +2,12 @@ # Copyright © 2017-2022 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do +defmodule Pleroma.Web.MastodonAPI.AnnouncementControllerTest do use Pleroma.Web.ConnCase import Pleroma.Factory + alias Pleroma.Announcement alias Pleroma.AnnouncementReadRelationship describe "GET /api/v1/announcements" do @@ -103,4 +104,51 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do assert %{"id" => ^id, "read" => true} = response end end + + describe "POST /api/v1/announcements/:id/dismiss" do + setup do: oauth_access(["write:accounts"]) + + test "it requires auth", %{conn: conn} do + %{id: id} = insert(:announcement) + + _response = + conn + |> assign(:token, nil) + |> post("/api/v1/announcements/#{id}/dismiss") + |> json_response_and_validate_schema(:forbidden) + end + + test "it requires write:accounts oauth scope" do + %{id: id} = insert(:announcement) + + %{conn: conn} = oauth_access(["read:accounts"]) + + _response = + conn + |> post("/api/v1/announcements/#{id}/dismiss") + |> json_response_and_validate_schema(:forbidden) + end + + test "it gives 404 for non-existent announcements", %{conn: conn} do + %{id: id} = insert(:announcement) + + _response = + conn + |> post("/api/v1/announcements/#{id}xxx/dismiss") + |> json_response_and_validate_schema(:not_found) + end + + test "it marks announcement as read", %{user: user, conn: conn} do + %{id: id} = announcement = insert(:announcement) + + refute Announcement.read_by?(announcement, user) + + _response = + conn + |> post("/api/v1/announcements/#{id}/dismiss") + |> json_response_and_validate_schema(:ok) + + assert Announcement.read_by?(announcement, user) + end + end end From 009817c9ee95f131d6a06e52c06c662ec95a38d4 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 17:00:49 -0500 Subject: [PATCH 06/16] Correct docstring for AnnouncementController.show --- .../web/mastodon_api/controllers/announcement_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex index c51c38d5e..f3ec78265 100644 --- a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex @@ -60,7 +60,7 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementController do end end - @doc "POST /api/v1/announcements/:id" + @doc "GET /api/v1/announcements/:id" def show(%{assigns: %{user: user}} = conn, %{id: id} = _params) do render_announcement_by_id(conn, id, user) end From fcf3c9057ec1c49a0c9a400985c1dde4a5d7e9db Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 18:21:20 -0500 Subject: [PATCH 07/16] Implement visibility filtering for announcements --- lib/pleroma/announcement.ex | 34 +++++++++ .../20220308012601_create_announcements.exs | 2 + test/pleroma/announcement_test.exs | 71 +++++++++++++++++++ test/support/factory.ex | 7 +- 4 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 test/pleroma/announcement_test.exs diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex index b0e787fe0..b3c88b441 100644 --- a/lib/pleroma/announcement.ex +++ b/lib/pleroma/announcement.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Announcement do use Ecto.Schema import Ecto.Changeset, only: [cast: 3, validate_required: 2] + import Ecto.Query alias Pleroma.AnnouncementReadRelationship alias Pleroma.Repo @@ -15,16 +16,36 @@ defmodule Pleroma.Announcement do schema "announcements" do field(:data, :map) + field(:starts_at, :naive_datetime) + field(:ends_at, :naive_datetime) timestamps() end def change(struct, params \\ %{}) do struct + |> validate_params() |> cast(params, [:data]) |> validate_required([:data]) end + defp validate_params(params) do + base_struct = %{ + "content" => "", + "all_day" => false + } + + merged_data = + Map.merge(base_struct, params.data) + |> Map.take(["content", "all_day"]) + + %{ + data: merged_data, + starts_at: Map.get(params, "starts_at"), + ends_at: Map.get(params, "ends_at") + } + end + def add(params) do changeset = change(%__MODULE__{}, params) @@ -86,4 +107,17 @@ defmodule Pleroma.Announcement do base |> Map.merge(extra_params) end + + # "visible" means: + # starts_at < time < ends_at + def list_all_visible_when(time) do + __MODULE__ + |> where([a], is_nil(a.starts_at) or a.starts_at < ^time) + |> where([a], is_nil(a.ends_at) or a.ends_at > ^time) + |> Repo.all() + end + + def list_all_visible do + list_all_visible_when(NaiveDateTime.utc_now()) + end end diff --git a/priv/repo/migrations/20220308012601_create_announcements.exs b/priv/repo/migrations/20220308012601_create_announcements.exs index e7c3b10a3..9f273fda9 100644 --- a/priv/repo/migrations/20220308012601_create_announcements.exs +++ b/priv/repo/migrations/20220308012601_create_announcements.exs @@ -5,6 +5,8 @@ defmodule Pleroma.Repo.Migrations.CreateAnnouncements do create_if_not_exists table(:announcements, primary_key: false) do add(:id, :uuid, primary_key: true) add(:data, :map) + add(:starts_at, :naive_datetime) + add(:ends_at, :naive_datetime) timestamps() end diff --git a/test/pleroma/announcement_test.exs b/test/pleroma/announcement_test.exs new file mode 100644 index 000000000..aa00f804b --- /dev/null +++ b/test/pleroma/announcement_test.exs @@ -0,0 +1,71 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.AnnouncementTest do + alias Pleroma.Announcement + + use Pleroma.DataCase, async: true + + import Pleroma.Factory + + describe "list_all_visible_when/1" do + setup do: {:ok, time: NaiveDateTime.utc_now()} + + test "with no start or end time", %{time: time} do + _announcement = insert(:announcement) + + assert [_] = Announcement.list_all_visible_when(time) + end + + test "with start time before current", %{time: time} do + before_now = NaiveDateTime.add(time, -10, :second) + + _announcement = insert(:announcement, %{starts_at: before_now}) + + assert [_] = Announcement.list_all_visible_when(time) + end + + test "with start time after current", %{time: time} do + after_now = NaiveDateTime.add(time, 10, :second) + + _announcement = insert(:announcement, %{starts_at: after_now}) + + assert [] = Announcement.list_all_visible_when(time) + end + + test "with end time after current", %{time: time} do + after_now = NaiveDateTime.add(time, 10, :second) + + _announcement = insert(:announcement, %{ends_at: after_now}) + + assert [_] = Announcement.list_all_visible_when(time) + end + + test "with end time before current", %{time: time} do + before_now = NaiveDateTime.add(time, -10, :second) + + _announcement = insert(:announcement, %{ends_at: before_now}) + + assert [] = Announcement.list_all_visible_when(time) + end + + test "with both start and end time", %{time: time} do + before_now = NaiveDateTime.add(time, -10, :second) + after_now = NaiveDateTime.add(time, 10, :second) + + _announcement = insert(:announcement, %{starts_at: before_now, ends_at: after_now}) + + assert [_] = Announcement.list_all_visible_when(time) + end + + test "with both start and end time, current not in the range", %{time: time} do + before_now = NaiveDateTime.add(time, -10, :second) + after_now = NaiveDateTime.add(time, 10, :second) + + _announcement = insert(:announcement, %{starts_at: after_now, ends_at: before_now}) + + assert [] = Announcement.list_all_visible_when(time) + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 85ea49cb2..620102bbb 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -628,11 +628,10 @@ defmodule Pleroma.Factory do } end - def announcement_factory do + def announcement_factory(params \\ %{}, data \\ %{}) do %Pleroma.Announcement{ - data: %{ - "content" => "test announcement" - } + data: Map.merge(%{"content" => "test announcement"}, data) } + |> Map.merge(params) end end From cf8334dbc153eed7d65febb4ae4fd3c03bb106b2 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 19:12:01 -0500 Subject: [PATCH 08/16] Add starts_at, ends_at and all_day parameters --- lib/pleroma/announcement.ex | 16 ++++++---------- .../controllers/announcement_controller.ex | 15 +++++++++------ .../operations/admin/announcement_operation.ex | 5 ++++- .../web/api_spec/schemas/announcement.ex | 8 ++++++-- .../announcement_controller_test.exs | 18 ++++++++++++++++-- test/support/factory.ex | 2 +- 6 files changed, 42 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex index b3c88b441..85500751e 100644 --- a/lib/pleroma/announcement.ex +++ b/lib/pleroma/announcement.ex @@ -24,8 +24,7 @@ defmodule Pleroma.Announcement do def change(struct, params \\ %{}) do struct - |> validate_params() - |> cast(params, [:data]) + |> cast(validate_params(params), [:data, :starts_at, :ends_at]) |> validate_required([:data]) end @@ -39,11 +38,8 @@ defmodule Pleroma.Announcement do Map.merge(base_struct, params.data) |> Map.take(["content", "all_day"]) - %{ - data: merged_data, - starts_at: Map.get(params, "starts_at"), - ends_at: Map.get(params, "ends_at") - } + params + |> Map.merge(%{data: merged_data}) end def add(params) do @@ -92,9 +88,9 @@ defmodule Pleroma.Announcement do base = %{ id: announcement.id, content: announcement.data["content"], - starts_at: :null, - ends_at: :null, - all_day: false, + starts_at: announcement.starts_at, + ends_at: announcement.ends_at, + all_day: announcement.data["all_day"], published_at: announcement.inserted_at, updated_at: announcement.updated_at, mentions: [], diff --git a/lib/pleroma/web/admin_api/controllers/announcement_controller.ex b/lib/pleroma/web/admin_api/controllers/announcement_controller.ex index 803408057..ad94e2642 100644 --- a/lib/pleroma/web/admin_api/controllers/announcement_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/announcement_controller.ex @@ -32,12 +32,15 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementController do end end - def create(%{body_params: %{content: content}} = conn, _params) do - add_params = %{ - data: %{ - "content" => content - } - } + def create(%{body_params: params} = conn, _params) do + data = + %{} + |> Pleroma.Maps.put_if_present("content", params, &Map.fetch(&1, :content)) + |> Pleroma.Maps.put_if_present("all_day", params, &Map.fetch(&1, :all_day)) + + add_params = + params + |> Map.merge(%{data: data}) with {:ok, announcement} <- Announcement.add(add_params) do render(conn, "show.json", announcement: announcement) diff --git a/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex b/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex index e4212dd06..8179a0e7b 100644 --- a/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex @@ -95,7 +95,10 @@ defmodule Pleroma.Web.ApiSpec.Admin.AnnouncementOperation do type: :object, required: [:content], properties: %{ - content: %Schema{type: :string} + content: %Schema{type: :string}, + starts_at: %Schema{type: :string, format: "date-time"}, + ends_at: %Schema{type: :string, format: "date-time"}, + all_day: %Schema{type: :boolean} } } end diff --git a/lib/pleroma/web/api_spec/schemas/announcement.ex b/lib/pleroma/web/api_spec/schemas/announcement.ex index 433437aac..094fd7c68 100644 --- a/lib/pleroma/web/api_spec/schemas/announcement.ex +++ b/lib/pleroma/web/api_spec/schemas/announcement.ex @@ -16,10 +16,14 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Announcement do id: FlakeID, content: %Schema{type: :string}, starts_at: %Schema{ - oneOf: [%Schema{type: :null}, %Schema{type: :string, format: "date-time"}] + type: :string, + format: "date-time", + nullable: true }, ends_at: %Schema{ - oneOf: [%Schema{type: :null}, %Schema{type: :string, format: "date-time"}] + type: :string, + format: "date-time", + nullable: true }, all_day: %Schema{type: :boolean}, published_at: %Schema{type: :string, format: "date-time"}, diff --git a/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs b/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs index d667526c3..5c9d50120 100644 --- a/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs @@ -80,15 +80,29 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementControllerTest do test "it creates an announcement", %{conn: conn} do content = "test post announcement api" + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + starts_at = NaiveDateTime.add(now, -10, :second) + ends_at = NaiveDateTime.add(now, 10, :second) + response = conn |> put_req_header("content-type", "application/json") |> post("/api/v1/pleroma/admin/announcements", %{ - "content" => content + "content" => content, + "starts_at" => NaiveDateTime.to_iso8601(starts_at), + "ends_at" => NaiveDateTime.to_iso8601(ends_at), + "all_day" => true }) |> json_response_and_validate_schema(:ok) - assert %{"content" => ^content} = response + assert %{"content" => ^content, "all_day" => true} = response + + announcement = Pleroma.Announcement.get_by_id(response["id"]) + + assert not is_nil(announcement) + + assert NaiveDateTime.compare(announcement.starts_at, starts_at) == :eq + assert NaiveDateTime.compare(announcement.ends_at, ends_at) == :eq end end end diff --git a/test/support/factory.ex b/test/support/factory.ex index 620102bbb..64b0049ac 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -630,7 +630,7 @@ defmodule Pleroma.Factory do def announcement_factory(params \\ %{}, data \\ %{}) do %Pleroma.Announcement{ - data: Map.merge(%{"content" => "test announcement"}, data) + data: Map.merge(%{"content" => "test announcement", "all_day" => false}, data) } |> Map.merge(params) end From d569694ae91fa20654e5639989d3eec0ca3c8a54 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 19:17:49 -0500 Subject: [PATCH 09/16] Show only visible announcements in MastodonAPI --- .../controllers/announcement_controller.ex | 2 +- .../announcement_controller_test.exs | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex index f3ec78265..a2179f934 100644 --- a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex @@ -46,7 +46,7 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementController do end defp all_visible do - Announcement.list_all() + Announcement.list_all_visible() end @doc "POST /api/v1/announcements/:id/dismiss" diff --git a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs index aa056be6e..e9ee1141c 100644 --- a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs @@ -23,6 +23,30 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementControllerTest do refute Map.has_key?(Enum.at(response, 0), "read") end + test "it does not list announcements starting after current time" do + time = NaiveDateTime.utc_now() |> NaiveDateTime.add(999999, :second) + insert(:announcement, starts_at: time) + + response = + build_conn() + |> get("/api/v1/announcements") + |> json_response_and_validate_schema(:ok) + + assert [] = response + end + + test "it does not list announcements ending before current time" do + time = NaiveDateTime.utc_now() |> NaiveDateTime.add(-999999, :second) + insert(:announcement, ends_at: time) + + response = + build_conn() + |> get("/api/v1/announcements") + |> json_response_and_validate_schema(:ok) + + assert [] = response + end + test "when authenticated, also expose read property" do %{id: id} = insert(:announcement) From 881179ec725c3b71868fdcba983fdedd295e5125 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 19:22:28 -0500 Subject: [PATCH 10/16] Remove GET /api/v1/announcements/:id --- .../operations/announcement_operation.ex | 21 ------- .../controllers/announcement_controller.ex | 18 ------ lib/pleroma/web/router.ex | 1 - .../announcement_controller_test.exs | 56 +------------------ 4 files changed, 2 insertions(+), 94 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/announcement_operation.ex b/lib/pleroma/web/api_spec/operations/announcement_operation.ex index c33b9e135..a66073b8b 100644 --- a/lib/pleroma/web/api_spec/operations/announcement_operation.ex +++ b/lib/pleroma/web/api_spec/operations/announcement_operation.ex @@ -25,27 +25,6 @@ defmodule Pleroma.Web.ApiSpec.AnnouncementOperation do } end - def show_operation do - %Operation{ - tags: ["Announcement"], - summary: "Display one announcement", - operationId: "MastodonAPI.AnnouncementController.show", - parameters: [ - Operation.parameter( - :id, - :path, - :string, - "announcement id" - ) - ], - responses: %{ - 200 => Operation.response("Response", "application/json", Announcement), - 403 => Operation.response("Forbidden", "application/json", ApiError), - 404 => Operation.response("Not Found", "application/json", ApiError) - } - } - end - def mark_read_operation do %Operation{ tags: ["Announcement"], diff --git a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex index a2179f934..e6da7892c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex @@ -59,22 +59,4 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementController do {:error, :not_found} end end - - @doc "GET /api/v1/announcements/:id" - def show(%{assigns: %{user: user}} = conn, %{id: id} = _params) do - render_announcement_by_id(conn, id, user) - end - - def show(conn, %{id: id} = _params) do - render_announcement_by_id(conn, id) - end - - def render_announcement_by_id(conn, id, user \\ nil) do - with announcement when not is_nil(announcement) <- Announcement.get_by_id(id) do - render(conn, "show.json", announcement: announcement, user: user) - else - _ -> - {:error, :not_found} - end - end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 5be9891b1..51a9dec6b 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -628,7 +628,6 @@ defmodule Pleroma.Web.Router do get("/directory", DirectoryController, :index) get("/announcements", AnnouncementController, :index) - get("/announcements/:id", AnnouncementController, :show) end scope "/api/v2", Pleroma.Web.MastodonAPI do diff --git a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs index e9ee1141c..1c748064c 100644 --- a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs @@ -24,7 +24,7 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementControllerTest do end test "it does not list announcements starting after current time" do - time = NaiveDateTime.utc_now() |> NaiveDateTime.add(999999, :second) + time = NaiveDateTime.utc_now() |> NaiveDateTime.add(999_999, :second) insert(:announcement, starts_at: time) response = @@ -36,7 +36,7 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementControllerTest do end test "it does not list announcements ending before current time" do - time = NaiveDateTime.utc_now() |> NaiveDateTime.add(-999999, :second) + time = NaiveDateTime.utc_now() |> NaiveDateTime.add(-999_999, :second) insert(:announcement, ends_at: time) response = @@ -77,58 +77,6 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementControllerTest do end end - describe "GET /api/v1/announcements/:id" do - test "it shows one announcement" do - %{id: id} = insert(:announcement) - - response = - build_conn() - |> get("/api/v1/announcements/#{id}") - |> json_response_and_validate_schema(:ok) - - assert %{"id" => ^id} = response - refute Map.has_key?(response, "read") - end - - test "it gives 404 for non-existent announcements" do - %{id: id} = insert(:announcement) - - _response = - build_conn() - |> get("/api/v1/announcements/#{id}xxx") - |> json_response_and_validate_schema(:not_found) - end - - test "when authenticated, also expose read property" do - %{id: id} = insert(:announcement) - - %{conn: conn} = oauth_access(["read:accounts"]) - - response = - conn - |> get("/api/v1/announcements/#{id}") - |> json_response_and_validate_schema(:ok) - - assert %{"id" => ^id, "read" => false} = response - end - - test "when authenticated and announcement is read by user" do - %{id: id} = announcement = insert(:announcement) - user = insert(:user) - - AnnouncementReadRelationship.mark_read(user, announcement) - - %{conn: conn} = oauth_access(["read:accounts"], user: user) - - response = - conn - |> get("/api/v1/announcements/#{id}") - |> json_response_and_validate_schema(:ok) - - assert %{"id" => ^id, "read" => true} = response - end - end - describe "POST /api/v1/announcements/:id/dismiss" do setup do: oauth_access(["write:accounts"]) From 11a1996bf5f283099fd9ecbb5c64e051ec46a5df Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 20:55:41 -0500 Subject: [PATCH 11/16] Implement update announcement admin api --- lib/pleroma/announcement.ex | 22 +++-- .../controllers/announcement_controller.ex | 37 ++++++--- .../admin/announcement_operation.ex | 49 ++++++++++-- lib/pleroma/web/router.ex | 1 + .../announcement_controller_test.exs | 80 ++++++++++++++++++- test/support/factory.ex | 6 +- 6 files changed, 169 insertions(+), 26 deletions(-) diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex index 85500751e..8c15d5bdf 100644 --- a/lib/pleroma/announcement.ex +++ b/lib/pleroma/announcement.ex @@ -24,18 +24,20 @@ defmodule Pleroma.Announcement do def change(struct, params \\ %{}) do struct - |> cast(validate_params(params), [:data, :starts_at, :ends_at]) + |> cast(validate_params(struct, params), [:data, :starts_at, :ends_at]) |> validate_required([:data]) end - defp validate_params(params) do - base_struct = %{ - "content" => "", - "all_day" => false - } + defp validate_params(struct, params) do + base_data = + %{ + "content" => "", + "all_day" => false + } + |> Map.merge((struct && struct.data) || %{}) merged_data = - Map.merge(base_struct, params.data) + Map.merge(base_data, params.data) |> Map.take(["content", "all_day"]) params @@ -48,6 +50,12 @@ defmodule Pleroma.Announcement do Repo.insert(changeset) end + def update(announcement, params) do + changeset = change(announcement, params) + + Repo.update(changeset) + end + def list_all do __MODULE__ |> Repo.all() diff --git a/lib/pleroma/web/admin_api/controllers/announcement_controller.ex b/lib/pleroma/web/admin_api/controllers/announcement_controller.ex index ad94e2642..6195e582a 100644 --- a/lib/pleroma/web/admin_api/controllers/announcement_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/announcement_controller.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementController do alias Pleroma.Web.Plugs.OAuthScopesPlug plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(OAuthScopesPlug, %{scopes: ["admin:write"]} when action in [:create, :delete]) + plug(OAuthScopesPlug, %{scopes: ["admin:write"]} when action in [:create, :delete, :change]) plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action in [:index, :show]) action_fallback(Pleroma.Web.AdminAPI.FallbackController) @@ -33,16 +33,7 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementController do end def create(%{body_params: params} = conn, _params) do - data = - %{} - |> Pleroma.Maps.put_if_present("content", params, &Map.fetch(&1, :content)) - |> Pleroma.Maps.put_if_present("all_day", params, &Map.fetch(&1, :all_day)) - - add_params = - params - |> Map.merge(%{data: data}) - - with {:ok, announcement} <- Announcement.add(add_params) do + with {:ok, announcement} <- Announcement.add(change_params(params)) do render(conn, "show.json", announcement: announcement) else _ -> @@ -50,6 +41,30 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementController do end end + def change_params(orig_params) do + data = + %{} + |> Pleroma.Maps.put_if_present("content", orig_params, &Map.fetch(&1, :content)) + |> Pleroma.Maps.put_if_present("all_day", orig_params, &Map.fetch(&1, :all_day)) + + orig_params + |> Map.merge(%{data: data}) + end + + def change(%{body_params: params} = conn, %{id: id} = _params) do + with announcement <- Announcement.get_by_id(id), + {:exists, true} <- {:exists, not is_nil(announcement)}, + {:ok, announcement} <- Announcement.update(announcement, change_params(params)) do + render(conn, "show.json", announcement: announcement) + else + {:exists, false} -> + {:error, :not_found} + + _ -> + {:error, 400} + end + end + def delete(conn, %{id: id} = _params) do case Announcement.delete_by_id(id) do :ok -> diff --git a/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex b/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex index 8179a0e7b..cdf04d357 100644 --- a/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex @@ -89,17 +89,54 @@ defmodule Pleroma.Web.ApiSpec.Admin.AnnouncementOperation do } end + def change_operation do + %Operation{ + tags: ["Announcement managment"], + summary: "Change one announcement", + operationId: "AdminAPI.AnnouncementController.change", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "announcement id" + ) + | admin_api_params() + ], + requestBody: request_body("Parameters", change_request(), required: true), + responses: %{ + 200 => Operation.response("Response", "application/json", Announcement), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp create_or_change_props do + %{ + content: %Schema{type: :string}, + starts_at: %Schema{type: :string, format: "date-time", nullable: true}, + ends_at: %Schema{type: :string, format: "date-time", nullable: true}, + all_day: %Schema{type: :boolean} + } + end + def create_request do %Schema{ title: "AnnouncementCreateRequest", type: :object, required: [:content], - properties: %{ - content: %Schema{type: :string}, - starts_at: %Schema{type: :string, format: "date-time"}, - ends_at: %Schema{type: :string, format: "date-time"}, - all_day: %Schema{type: :boolean} - } + properties: create_or_change_props() + } + end + + def change_request do + %Schema{ + title: "AnnouncementChangeRequest", + type: :object, + properties: create_or_change_props() } end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 51a9dec6b..af56494a2 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -233,6 +233,7 @@ defmodule Pleroma.Web.Router do get("/announcements", AnnouncementController, :index) post("/announcements", AnnouncementController, :create) get("/announcements/:id", AnnouncementController, :show) + patch("/announcements/:id", AnnouncementController, :change) delete("/announcements/:id", AnnouncementController, :delete) end diff --git a/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs b/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs index 5c9d50120..c6b2163d0 100644 --- a/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs @@ -69,13 +69,91 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementControllerTest do _response = conn - |> get("/api/v1/pleroma/admin/announcements/#{id}xxx") + |> delete("/api/v1/pleroma/admin/announcements/#{id}xxx") |> json_response_and_validate_schema(:not_found) assert %{id: ^id} = Pleroma.Announcement.get_by_id(id) end end + describe "PATCH /api/v1/pleroma/admin/announcements/:id" do + test "it returns not found for non-existent id", %{conn: conn} do + %{id: id} = insert(:announcement) + + _response = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/pleroma/admin/announcements/#{id}xxx", %{}) + |> json_response_and_validate_schema(:not_found) + + assert %{id: ^id} = Pleroma.Announcement.get_by_id(id) + end + + test "it updates a field", %{conn: conn} do + %{id: id} = insert(:announcement) + + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + starts_at = NaiveDateTime.add(now, -10, :second) + + _response = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/pleroma/admin/announcements/#{id}", %{ + starts_at: NaiveDateTime.to_iso8601(starts_at) + }) + |> json_response_and_validate_schema(:ok) + + new = Pleroma.Announcement.get_by_id(id) + + assert NaiveDateTime.compare(new.starts_at, starts_at) == :eq + end + + test "it updates a data field", %{conn: conn} do + %{id: id} = announcement = insert(:announcement, data: %{"all_day" => true}) + + assert announcement.data["all_day"] == true + + new_content = "new content" + + response = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/pleroma/admin/announcements/#{id}", %{ + content: new_content + }) + |> json_response_and_validate_schema(:ok) + + assert response["content"] == new_content + assert response["all_day"] == true + + new = Pleroma.Announcement.get_by_id(id) + + assert new.data["content"] == new_content + assert new.data["all_day"] == true + end + + test "it nullifies a nullable field", %{conn: conn} do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + starts_at = NaiveDateTime.add(now, -10, :second) + + %{id: id} = insert(:announcement, starts_at: starts_at) + + response = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/pleroma/admin/announcements/#{id}", %{ + starts_at: nil + }) + |> json_response_and_validate_schema(:ok) + + assert response["starts_at"] == nil + + new = Pleroma.Announcement.get_by_id(id) + + assert new.starts_at == nil + end + end + describe "POST /api/v1/pleroma/admin/announcements" do test "it creates an announcement", %{conn: conn} do content = "test post announcement api" diff --git a/test/support/factory.ex b/test/support/factory.ex index 64b0049ac..d28a56345 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -628,7 +628,11 @@ defmodule Pleroma.Factory do } end - def announcement_factory(params \\ %{}, data \\ %{}) do + def announcement_factory(params \\ %{}) do + data = Map.get(params, :data, %{}) + + {_, params} = Map.pop(params, :data) + %Pleroma.Announcement{ data: Map.merge(%{"content" => "test announcement", "all_day" => false}, data) } From eb1a29640f2d7c7d3daca0626b2beb623903c9cd Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 21:26:05 -0500 Subject: [PATCH 12/16] Add pagination to AdminAPI.AnnouncementController.index --- lib/pleroma/announcement.ex | 7 +++ .../controllers/announcement_controller.ex | 9 +++- .../admin/announcement_operation.ex | 16 +++++++ .../announcement_controller_test.exs | 48 +++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex index 8c15d5bdf..6c11eff7e 100644 --- a/lib/pleroma/announcement.ex +++ b/lib/pleroma/announcement.ex @@ -61,6 +61,13 @@ defmodule Pleroma.Announcement do |> Repo.all() end + def list_paginated(%{limit: limited_number, offset: offset_number}) do + __MODULE__ + |> limit(^limited_number) + |> offset(^offset_number) + |> Repo.all() + end + def get_by_id(id) do Repo.get_by(__MODULE__, id: id) end diff --git a/lib/pleroma/web/admin_api/controllers/announcement_controller.ex b/lib/pleroma/web/admin_api/controllers/announcement_controller.ex index 6195e582a..6ad5fc12c 100644 --- a/lib/pleroma/web/admin_api/controllers/announcement_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/announcement_controller.ex @@ -16,8 +16,13 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.AnnouncementOperation - def index(conn, _params) do - announcements = Announcement.list_all() + defp default_limit, do: 20 + + def index(conn, params) do + limit = Map.get(params, :limit, default_limit()) + offset = Map.get(params, :offset, 0) + + announcements = Announcement.list_paginated(%{limit: limit, offset: offset}) render(conn, "index.json", announcements: announcements) end diff --git a/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex b/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex index cdf04d357..58a039e72 100644 --- a/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex @@ -21,8 +21,24 @@ defmodule Pleroma.Web.ApiSpec.Admin.AnnouncementOperation do summary: "Retrieve a list of announcements", operationId: "AdminAPI.AnnouncementController.index", security: [%{"oAuth" => ["admin:read"]}], + parameters: [ + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, minimum: 1}, + "the maximum number of announcements to return" + ), + Operation.parameter( + :offset, + :query, + %Schema{type: :integer, minimum: 0}, + "the offset of the first announcement to return" + ) + | admin_api_params() + ], responses: %{ 200 => Operation.response("Response", "application/json", list_of_announcements()), + 400 => Operation.response("Forbidden", "application/json", ApiError), 403 => Operation.response("Forbidden", "application/json", ApiError) } } diff --git a/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs b/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs index c6b2163d0..56da1c6aa 100644 --- a/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs @@ -30,6 +30,54 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementControllerTest do assert [%{"id" => ^id}] = response end + + test "it paginates announcements", %{conn: conn} do + _announcements = Enum.map(0..20, fn _ -> insert(:announcement) end) + + response = + conn + |> get("/api/v1/pleroma/admin/announcements") + |> json_response_and_validate_schema(:ok) + + assert length(response) == 20 + end + + test "it paginates announcements with custom params", %{conn: conn} do + announcements = Enum.map(0..20, fn _ -> insert(:announcement) end) + + response = + conn + |> get("/api/v1/pleroma/admin/announcements", limit: 5, offset: 7) + |> json_response_and_validate_schema(:ok) + + assert length(response) == 5 + assert Enum.at(response, 0)["id"] == Enum.at(announcements, 7).id + end + + test "it returns empty list with out-of-bounds offset", %{conn: conn} do + _announcements = Enum.map(0..20, fn _ -> insert(:announcement) end) + + response = + conn + |> get("/api/v1/pleroma/admin/announcements", offset: 21) + |> json_response_and_validate_schema(:ok) + + assert [] = response + end + + test "it rejects invalid pagination params", %{conn: conn} do + conn + |> get("/api/v1/pleroma/admin/announcements", limit: 0) + |> json_response_and_validate_schema(400) + + conn + |> get("/api/v1/pleroma/admin/announcements", limit: -1) + |> json_response_and_validate_schema(400) + + conn + |> get("/api/v1/pleroma/admin/announcements", offset: -1) + |> json_response_and_validate_schema(400) + end end describe "GET /api/v1/pleroma/admin/announcements/:id" do From 0e0a1758f7f4526d0bec005f8fb94a15620b2500 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 21:48:41 -0500 Subject: [PATCH 13/16] Add doc for Admin Announcement APIs --- docs/development/API/admin_api.md | 114 ++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/docs/development/API/admin_api.md b/docs/development/API/admin_api.md index f14081893..c46f83839 100644 --- a/docs/development/API/admin_api.md +++ b/docs/development/API/admin_api.md @@ -1636,3 +1636,117 @@ Returns the content of the document "error": "Could not install frontend" } ``` + +## `GET /api/v1/pleroma/admin/announcements` + +### List announcements + +- Params: `offset`, `limit` + +- Response: JSON, list of announcements + +```json +[ + { + "id": "AHDp0GBdRn1EPN5HN2", + "content": "some content", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-03-09T02:13:05", + "reactions": [], + "statuses": [], + "tags": [], + "emojis": [], + "updated_at": "2022-03-09T02:13:05" + } +] +``` + +Note that this differs from the Mastodon API variant: Mastodon API only returns *active* announcements, while this returns all. + +## `GET /api/v1/pleroma/admin/announcements/:id` + +### Display one announcement + +- Response: JSON, one announcement + +```json +{ + "id": "AHDp0GBdRn1EPN5HN2", + "content": "some content", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-03-09T02:13:05", + "reactions": [], + "statuses": [], + "tags": [], + "emojis": [], + "updated_at": "2022-03-09T02:13:05" +} +``` + +## `POST /api/v1/pleroma/admin/announcements` + +### Create an announcement + +- Params: + - `content`: string, required, announcement content + - `starts_at`: datetime, optional, default to null, the time when the announcement will become active (displayed to users); if it is null, the announcement will be active immediately + - `ends_at`: datetime, optional, default to null, the time when the announcement will become inactive (no longer displayed to users); if it is null, the announcement will be active until an admin deletes it + - `all_day`: boolean, optional, default to false, tells the client whether to only display dates for `starts_at` and `ends_at` + +- Response: JSON, created announcement + +```json +{ + "id": "AHDp0GBdRn1EPN5HN2", + "content": "some content", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-03-09T02:13:05", + "reactions": [], + "statuses": [], + "tags": [], + "emojis": [], + "updated_at": "2022-03-09T02:13:05" +} +``` + +## `PATCH /api/v1/pleroma/admin/announcements/:id` + +### Change an announcement + +- Params: same as `POST /api/v1/pleroma/admin/announcements`, except no param is required. + +- Updates the announcement according to params. Missing params are kept as-is. + +- Response: JSON, updated announcement + +```json +{ + "id": "AHDp0GBdRn1EPN5HN2", + "content": "some content", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-03-09T02:13:05", + "reactions": [], + "statuses": [], + "tags": [], + "emojis": [], + "updated_at": "2022-03-09T02:13:05" +} +``` + +## `DELETE /api/v1/pleroma/admin/announcements/:id` + +### Delete an announcement + +- Response: JSON, empty object + +```json +{} +``` From ebcda5265b9c82be26eae65b5ab39629a525c3fa Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 8 Mar 2022 23:00:51 -0500 Subject: [PATCH 14/16] Format announcements into html --- lib/pleroma/announcement.ex | 30 +++++++++++++++++-- .../web/admin_api/views/announcement_view.ex | 2 +- .../web/api_spec/schemas/announcement.ex | 8 ++++- .../20220308012601_create_announcements.exs | 1 + test/pleroma/announcement_test.exs | 27 +++++++++++++++++ test/support/factory.ex | 1 + 6 files changed, 65 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex index 6c11eff7e..ad372629d 100644 --- a/lib/pleroma/announcement.ex +++ b/lib/pleroma/announcement.ex @@ -18,13 +18,14 @@ defmodule Pleroma.Announcement do field(:data, :map) field(:starts_at, :naive_datetime) field(:ends_at, :naive_datetime) + field(:rendered, :map) timestamps() end def change(struct, params \\ %{}) do struct - |> cast(validate_params(struct, params), [:data, :starts_at, :ends_at]) + |> cast(validate_params(struct, params), [:data, :starts_at, :ends_at, :rendered]) |> validate_required([:data]) end @@ -42,6 +43,21 @@ defmodule Pleroma.Announcement do params |> Map.merge(%{data: merged_data}) + |> add_rendered_properties() + end + + def add_rendered_properties(params) do + {content_html, _, _} = + Pleroma.Web.CommonAPI.Utils.format_input(params.data["content"], "text/plain", + mentions_format: :full + ) + + rendered = %{ + "content" => content_html + } + + params + |> Map.put(:rendered, rendered) end def add(params) do @@ -100,9 +116,18 @@ defmodule Pleroma.Announcement do %{} end + admin_extra_params = + case Keyword.fetch(opts, :admin) do + {:ok, true} -> + %{pleroma: %{raw_content: announcement.data["content"]}} + + _ -> + %{} + end + base = %{ id: announcement.id, - content: announcement.data["content"], + content: announcement.rendered["content"], starts_at: announcement.starts_at, ends_at: announcement.ends_at, all_day: announcement.data["all_day"], @@ -117,6 +142,7 @@ defmodule Pleroma.Announcement do base |> Map.merge(extra_params) + |> Map.merge(admin_extra_params) end # "visible" means: diff --git a/lib/pleroma/web/admin_api/views/announcement_view.ex b/lib/pleroma/web/admin_api/views/announcement_view.ex index 8fbdc338a..a35bd60cf 100644 --- a/lib/pleroma/web/admin_api/views/announcement_view.ex +++ b/lib/pleroma/web/admin_api/views/announcement_view.ex @@ -10,6 +10,6 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementView do end def render("show.json", %{announcement: announcement}) do - Pleroma.Announcement.render_json(announcement) + Pleroma.Announcement.render_json(announcement, admin: true) end end diff --git a/lib/pleroma/web/api_spec/schemas/announcement.ex b/lib/pleroma/web/api_spec/schemas/announcement.ex index 094fd7c68..67d129ef6 100644 --- a/lib/pleroma/web/api_spec/schemas/announcement.ex +++ b/lib/pleroma/web/api_spec/schemas/announcement.ex @@ -33,7 +33,13 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Announcement do statuses: %Schema{type: :array}, tags: %Schema{type: :array}, emojis: %Schema{type: :array}, - reactions: %Schema{type: :array} + reactions: %Schema{type: :array}, + pleroma: %Schema{ + type: :object, + properties: %{ + raw_content: %Schema{type: :string} + } + } } }) end diff --git a/priv/repo/migrations/20220308012601_create_announcements.exs b/priv/repo/migrations/20220308012601_create_announcements.exs index 9f273fda9..01c9ce041 100644 --- a/priv/repo/migrations/20220308012601_create_announcements.exs +++ b/priv/repo/migrations/20220308012601_create_announcements.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Repo.Migrations.CreateAnnouncements do add(:data, :map) add(:starts_at, :naive_datetime) add(:ends_at, :naive_datetime) + add(:rendered, :map) timestamps() end diff --git a/test/pleroma/announcement_test.exs b/test/pleroma/announcement_test.exs index aa00f804b..a007c3718 100644 --- a/test/pleroma/announcement_test.exs +++ b/test/pleroma/announcement_test.exs @@ -68,4 +68,31 @@ defmodule Pleroma.AnnouncementTest do assert [] = Announcement.list_all_visible_when(time) end end + + describe "announcements formatting" do + test "it formats links" do + raw = "something on https://pleroma.social ." + announcement = insert(:announcement, %{data: %{"content" => raw}}) + + assert announcement.rendered["content"] =~ ~r( raw}}) + + assert announcement.rendered["content"] =~ ~r( raw}}) + + assert announcement.rendered["content"] =~ ~r( "test announcement", "all_day" => false}, data) } |> Map.merge(params) + |> Pleroma.Announcement.add_rendered_properties() end end From 0c78ab4a88d59358a0a5e24a76cbb4cdb2c2d402 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Fri, 18 Mar 2022 09:36:13 -0400 Subject: [PATCH 15/16] Use utc_datetime in db schema --- lib/pleroma/announcement.ex | 8 ++-- .../announcement_controller_test.exs | 47 +++++++++++++++++++ .../announcement_controller_test.exs | 24 ++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex index ad372629d..d97c5e728 100644 --- a/lib/pleroma/announcement.ex +++ b/lib/pleroma/announcement.ex @@ -16,11 +16,11 @@ defmodule Pleroma.Announcement do schema "announcements" do field(:data, :map) - field(:starts_at, :naive_datetime) - field(:ends_at, :naive_datetime) + field(:starts_at, :utc_datetime) + field(:ends_at, :utc_datetime) field(:rendered, :map) - timestamps() + timestamps(type: :utc_datetime) end def change(struct, params \\ %{}) do @@ -155,6 +155,6 @@ defmodule Pleroma.Announcement do end def list_all_visible do - list_all_visible_when(NaiveDateTime.utc_now()) + list_all_visible_when(DateTime.now("Etc/UTC") |> elem(1)) end end diff --git a/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs b/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs index 56da1c6aa..5b8148c05 100644 --- a/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/announcement_controller_test.exs @@ -156,6 +156,25 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementControllerTest do assert NaiveDateTime.compare(new.starts_at, starts_at) == :eq end + test "it updates with time with utc timezone", %{conn: conn} do + %{id: id} = insert(:announcement) + + now = DateTime.now("Etc/UTC") |> elem(1) |> DateTime.truncate(:second) + starts_at = DateTime.add(now, -10, :second) + + _response = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/pleroma/admin/announcements/#{id}", %{ + starts_at: DateTime.to_iso8601(starts_at) + }) + |> json_response_and_validate_schema(:ok) + + new = Pleroma.Announcement.get_by_id(id) + + assert DateTime.compare(new.starts_at, starts_at) == :eq + end + test "it updates a data field", %{conn: conn} do %{id: id} = announcement = insert(:announcement, data: %{"all_day" => true}) @@ -230,5 +249,33 @@ defmodule Pleroma.Web.AdminAPI.AnnouncementControllerTest do assert NaiveDateTime.compare(announcement.starts_at, starts_at) == :eq assert NaiveDateTime.compare(announcement.ends_at, ends_at) == :eq end + + test "creating with time with utc timezones", %{conn: conn} do + content = "test post announcement api" + + now = DateTime.now("Etc/UTC") |> elem(1) |> DateTime.truncate(:second) + starts_at = DateTime.add(now, -10, :second) + ends_at = DateTime.add(now, 10, :second) + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/admin/announcements", %{ + "content" => content, + "starts_at" => DateTime.to_iso8601(starts_at), + "ends_at" => DateTime.to_iso8601(ends_at), + "all_day" => true + }) + |> json_response_and_validate_schema(:ok) + + assert %{"content" => ^content, "all_day" => true} = response + + announcement = Pleroma.Announcement.get_by_id(response["id"]) + + assert not is_nil(announcement) + + assert DateTime.compare(announcement.starts_at, starts_at) == :eq + assert DateTime.compare(announcement.ends_at, ends_at) == :eq + end end end diff --git a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs index 1c748064c..3957cc3ed 100644 --- a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs @@ -23,6 +23,30 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementControllerTest do refute Map.has_key?(Enum.at(response, 0), "read") end + test "it returns time with utc timezone" do + start_time = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(-999_999, :second) + |> NaiveDateTime.truncate(:second) + + end_time = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(999_999, :second) + |> NaiveDateTime.truncate(:second) + + %{id: id} = insert(:announcement, %{starts_at: start_time, ends_at: end_time}) + + response = + build_conn() + |> get("/api/v1/announcements") + |> json_response_and_validate_schema(:ok) + + assert [%{"id" => ^id}] = [announcement] = response + + assert String.ends_with?(announcement["starts_at"], "Z") + assert String.ends_with?(announcement["ends_at"], "Z") + end + test "it does not list announcements starting after current time" do time = NaiveDateTime.utc_now() |> NaiveDateTime.add(999_999, :second) insert(:announcement, starts_at: time) From 7d1dae3befbecbeeb72768afe4f5a23a59ba4f05 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 2 Apr 2022 02:25:13 -0400 Subject: [PATCH 16/16] Restrict mastodon api announcements to logged-in users only --- .../operations/announcement_operation.ex | 1 + .../controllers/announcement_controller.ex | 12 +++--- lib/pleroma/web/router.ex | 3 +- .../announcement_controller_test.exs | 43 +++++++++++++------ 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/announcement_operation.ex b/lib/pleroma/web/api_spec/operations/announcement_operation.ex index a66073b8b..71be0002a 100644 --- a/lib/pleroma/web/api_spec/operations/announcement_operation.ex +++ b/lib/pleroma/web/api_spec/operations/announcement_operation.ex @@ -18,6 +18,7 @@ defmodule Pleroma.Web.ApiSpec.AnnouncementOperation do tags: ["Announcement"], summary: "Retrieve a list of announcements", operationId: "MastodonAPI.AnnouncementController.index", + security: [%{"oAuth" => []}], responses: %{ 200 => Operation.response("Response", "application/json", list_of_announcements()), 403 => Operation.response("Forbidden", "application/json", ApiError) diff --git a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex index e6da7892c..080af96d5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex @@ -15,20 +15,18 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementController do plug(Pleroma.Web.ApiSpec.CastAndValidate) - # MastodonAPI specs do not have oauth requirements for showing - # announcements, but we have "private instance" options. When that - # is set, require read:accounts scope, symmetric to write:accounts - # for `mark_read`. + # Mastodon docs say this only requires a user token, no scopes needed + # As the op `|` requires at least one scope to be present, we use `&` here. plug( OAuthScopesPlug, - %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} - when action in [:show, :index] + %{scopes: [], op: :&} + when action in [:index] ) # Same as in MastodonAPI specs plug( OAuthScopesPlug, - %{fallback: :proceed_unauthenticated, scopes: ["write:accounts"]} + %{scopes: ["write:accounts"]} when action in [:mark_read] ) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index af56494a2..7bbc20275 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -582,6 +582,7 @@ defmodule Pleroma.Web.Router do get("/timelines/direct", TimelineController, :direct) get("/timelines/list/:list_id", TimelineController, :list) + get("/announcements", AnnouncementController, :index) post("/announcements/:id/dismiss", AnnouncementController, :mark_read) end @@ -627,8 +628,6 @@ defmodule Pleroma.Web.Router do get("/polls/:id", PollController, :show) get("/directory", DirectoryController, :index) - - get("/announcements", AnnouncementController, :index) end scope "/api/v2", Pleroma.Web.MastodonAPI do diff --git a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs index 3957cc3ed..60c9978c2 100644 --- a/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs @@ -11,19 +11,40 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementControllerTest do alias Pleroma.AnnouncementReadRelationship describe "GET /api/v1/announcements" do - test "it lists all announcements" do + setup do + %{conn: conn} = oauth_access([]) + {:ok, conn: conn} + end + + test "it does not allow guests", %{conn: conn} do + _response = + conn + |> assign(:token, nil) + |> get("/api/v1/announcements") + |> json_response_and_validate_schema(:forbidden) + end + + test "it allows users with scopes" do + %{conn: conn} = oauth_access(["read:accounts"]) + + _response = + conn + |> get("/api/v1/announcements") + |> json_response_and_validate_schema(:ok) + end + + test "it lists all announcements", %{conn: conn} do %{id: id} = insert(:announcement) response = - build_conn() + conn |> get("/api/v1/announcements") |> json_response_and_validate_schema(:ok) assert [%{"id" => ^id}] = response - refute Map.has_key?(Enum.at(response, 0), "read") end - test "it returns time with utc timezone" do + test "it returns time with utc timezone", %{conn: conn} do start_time = NaiveDateTime.utc_now() |> NaiveDateTime.add(-999_999, :second) @@ -37,7 +58,7 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementControllerTest do %{id: id} = insert(:announcement, %{starts_at: start_time, ends_at: end_time}) response = - build_conn() + conn |> get("/api/v1/announcements") |> json_response_and_validate_schema(:ok) @@ -47,35 +68,33 @@ defmodule Pleroma.Web.MastodonAPI.AnnouncementControllerTest do assert String.ends_with?(announcement["ends_at"], "Z") end - test "it does not list announcements starting after current time" do + test "it does not list announcements starting after current time", %{conn: conn} do time = NaiveDateTime.utc_now() |> NaiveDateTime.add(999_999, :second) insert(:announcement, starts_at: time) response = - build_conn() + conn |> get("/api/v1/announcements") |> json_response_and_validate_schema(:ok) assert [] = response end - test "it does not list announcements ending before current time" do + test "it does not list announcements ending before current time", %{conn: conn} do time = NaiveDateTime.utc_now() |> NaiveDateTime.add(-999_999, :second) insert(:announcement, ends_at: time) response = - build_conn() + conn |> get("/api/v1/announcements") |> json_response_and_validate_schema(:ok) assert [] = response end - test "when authenticated, also expose read property" do + test "when authenticated, also expose read property", %{conn: conn} do %{id: id} = insert(:announcement) - %{conn: conn} = oauth_access(["read:accounts"]) - response = conn |> get("/api/v1/announcements")