From 0e0931a3c23d8c966aef8ded80e82da6f3c4205e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 19 Sep 2025 16:55:40 +0200 Subject: [PATCH 1/8] MastoAPI: /api/v1/admin/accounts, /api/v1/admin/reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- changelog.d/mastodon-admin.add | 1 + lib/pleroma/moderation_log.ex | 10 + lib/pleroma/user.ex | 6 + lib/pleroma/user/query.ex | 19 +- lib/pleroma/web/api_spec.ex | 7 + .../mastodon_admin/account_operation.ex | 258 ++++++++++++++++++ .../mastodon_admin/report_operation.ex | 150 ++++++++++ .../admin/controllers/account_controller.ex | 220 +++++++++++++++ .../admin/controllers/report_controller.ex | 107 ++++++++ .../mastodon_api/admin/views/account_view.ex | 64 +++++ .../mastodon_api/admin/views/report_view.ex | 56 ++++ lib/pleroma/web/router.ex | 40 +++ .../controllers/account_controller_test.exs | 175 ++++++++++++ .../controllers/report_controller_test.exs | 118 ++++++++ 14 files changed, 1230 insertions(+), 1 deletion(-) create mode 100644 changelog.d/mastodon-admin.add create mode 100644 lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex create mode 100644 lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/admin/views/account_view.ex create mode 100644 lib/pleroma/web/mastodon_api/admin/views/report_view.ex create mode 100644 test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs diff --git a/changelog.d/mastodon-admin.add b/changelog.d/mastodon-admin.add new file mode 100644 index 000000000..64f2ca987 --- /dev/null +++ b/changelog.d/mastodon-admin.add @@ -0,0 +1 @@ +Implement basics of Mastodon admin API \ No newline at end of file diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index 90219312c..260c139ec 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -346,6 +346,16 @@ defmodule Pleroma.ModerationLog do "@#{actor_nickname} approved users: #{users_to_nicknames_string(users)}" end + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "reject", + "subject" => users + } + }) do + "@#{actor_nickname} rejected users: #{users_to_nicknames_string(users)}" + end + def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 75da41da9..4b421f389 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1964,6 +1964,12 @@ defmodule Pleroma.User do def approve(%User{} = user), do: {:ok, user} + def reject(%User{is_approved: false} = user) do + delete(user) + end + + def reject(%User{} = _user), do: {:error, "User is approved"} + def confirm(users) when is_list(users) do Repo.transaction(fn -> Enum.map(users, fn user -> diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index cd9586452..b877b52b2 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -63,7 +63,8 @@ defmodule Pleroma.User.Query do limit: pos_integer(), actor_types: [String.t()], birthday_day: pos_integer(), - birthday_month: pos_integer() + birthday_month: pos_integer(), + staff: boolean() } | map() @@ -179,6 +180,10 @@ defmodule Pleroma.User.Query do defp compose_query({:external, _}, query), do: location_query(query, false) + defp compose_query({:active, false}, query) do + where(query, [u], u.is_active == false or u.is_approved == false or u.is_confirmed == false) + end + defp compose_query({:active, _}, query) do where(query, [u], u.is_active == true) |> where([u], u.is_approved == true) @@ -202,6 +207,10 @@ defmodule Pleroma.User.Query do where(query, [u], u.is_confirmed != ^bool) end + defp compose_query({:need_approval, false}, query) do + where(query, [u], u.is_approved == true) + end + defp compose_query({:need_approval, _}, query) do where(query, [u], u.is_approved == false) end @@ -285,6 +294,14 @@ defmodule Pleroma.User.Query do |> where([u], fragment("date_part('month', ?)", u.birthday) == ^month) end + defp compose_query({:staff, false}, query) do + where(query, [u], u.is_admin == false and u.is_moderator == false) + end + + defp compose_query({:staff, _}, query) do + where(query, [u], u.is_admin == true or u.is_moderator == true) + end + defp compose_query(_unsupported_param, query), do: query defp location_query(query, local) do diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index 5006167ea..ff6363603 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -108,6 +108,13 @@ defmodule Pleroma.Web.ApiSpec do "Announcement management" ] }, + %{ + "name" => "Administration (Mastodon API)", + "tags" => [ + "User administration (Mastodon API)", + "Report management (Mastodon API)" + ] + }, %{"name" => "Applications", "tags" => ["Applications", "Push subscriptions"]}, %{ "name" => "Current account", diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex new file mode 100644 index 000000000..23dae0306 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex @@ -0,0 +1,258 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + 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: ["User administration (Mastodon API)"], + summary: "View accounts by criteria", + operationId: "MastodonAdmin.AccountController.index", + description: "View accounts matching certain criteria for filtering, up to 40 at a time.", + security: [%{"oAuth" => ["admin:read:accounts"]}], + parameters: + [ + Operation.parameter(:local, :query, :boolean, "Filter for local accounts?"), + Operation.parameter(:remote, :query, :boolean, "Filter for remote accounts?"), + Operation.parameter( + :by_domain, + :query, + :string, + "Filter by the given domain (not implemented yet)" + ), + Operation.parameter( + :active, + :query, + :boolean, + "Filter for currently active accounts??" + ), + Operation.parameter( + :pending, + :query, + :boolean, + "Filter for currently pending accounts?" + ), + Operation.parameter( + :disabled, + :query, + :boolean, + "Filter for currently disabled accounts?" + ), + Operation.parameter( + :sensitized, + :query, + :boolean, + "Filter for currently sensitized accounts? (not implemented yet)" + ), + Operation.parameter( + :silenced, + :query, + :boolean, + "Filter for currently silenced accounts? (not implemented yet)" + ), + Operation.parameter( + :suspended, + :query, + :boolean, + "Filter for currently suspended accounts? (not implemented yet)" + ), + Operation.parameter(:username, :query, :string, "Username to search for"), + Operation.parameter(:display_name, :query, :string, "Display name to search for"), + Operation.parameter(:email, :query, :string, "Lookup a user with this email"), + Operation.parameter( + :ip, + :query, + :string, + "Lookup users by this IP address (not implemented yet)" + ), + Operation.parameter(:staff, :query, :boolean, "Filter for staff accounts?") + ] ++ + pagination_params(), + responses: %{ + 200 => + Operation.response("Account", "application/json", %Schema{ + title: "ArrayOfAccounts", + type: :array, + items: account() + }), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["User administration (Mastodon API)"], + summary: "View a specific account", + operationId: "MastodonAdmin.AccountController.show", + description: "View admin-level information about the given account.", + security: [%{"oAuth" => ["admin:read:accounts"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + responses: %{ + 200 => Operation.response("Account", "application/json", account()), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def account_action_operation do + %Operation{ + tags: ["User administration (Mastodon API)"], + summary: "Perform an action against an account", + operationId: "MastodonAdmin.AccountController.account_action", + description: + "Perform an action against an account and log this action in the moderation history.", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + type: %Schema{ + type: :string, + enum: ["none", "disable", "sensitive", "silence", "suspend"] + }, + report_id: %Schema{ + type: :string, + nullable: true, + description: "ID of an associated report that caused this action to be taken" + } + } + }, + required: true + ), + responses: %{ + 204 => no_content_response(), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["User administration (Mastodon API)"], + summary: "Delete a specific account", + operationId: "MastodonAdmin.AccountController.delete", + description: "Delete the given account.", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + responses: %{ + 200 => Operation.response("Account", "application/json", account()), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def enable_operation do + %Operation{ + tags: ["User administration (Mastodon API)"], + summary: "Re-enable account", + operationId: "MastodonAdmin.AccountController.enable", + description: "Re-enable a local account whose login is currently disabled.", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + responses: %{ + 200 => Operation.response("Account", "application/json", account()), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def approve_operation do + %Operation{ + tags: ["User administration (Mastodon API)"], + summary: "Approve pending account", + operationId: "MastodonAdmin.AccountController.approve", + description: "Approve the given local account if it is currently pending approval.", + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + responses: %{ + 200 => Operation.response("Account", "application/json", account()), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def reject_operation do + %Operation{ + tags: ["User administration (Mastodon API)"], + summary: "Reject pending account", + operationId: "MastodonAdmin.AccountController.reject", + description: "Reject the given local account if it is currently pending approval.", + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + responses: %{ + 200 => Operation.response("Account", "application/json", account()), + 400 => Operation.response("Error", "application/json", ApiError), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def account do + %Schema{ + title: "Admin::Account", + description: "Admin-level information about a given account.", + type: :object, + properties: %{ + id: FlakeID, + username: %Schema{type: :string}, + domain: %Schema{type: :string, nullable: true}, + created_at: %Schema{type: :string, format: "date-time"}, + email: %Schema{type: :string, format: "email", nullable: true}, + ip: %Schema{type: :string, nullable: true}, + ips: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + ip: %Schema{type: :string}, + used_at: %Schema{type: :string, format: "date-time"} + } + } + }, + locale: %Schema{type: :string, format: "date-time", nullable: true}, + invite_request: %Schema{type: :string, format: "date-time", nullable: true}, + role: %Schema{type: :string, nullable: true}, + confirmed: %Schema{type: :boolean}, + approved: %Schema{type: :boolean}, + disabled: %Schema{type: :boolean}, + silenced: %Schema{type: :boolean, nullable: true}, + suspended: %Schema{type: :boolean, nullable: true}, + account: Account, + created_by_application_id: %Schema{type: :string, nullable: true}, + invited_by_account_id: %Schema{type: :string, nullable: true} + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex new file mode 100644 index 000000000..274ce3bd9 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex @@ -0,0 +1,150 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + + 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: ["Report management (Mastodon API)"], + summary: "View all reports", + operationId: "MastodonAdmin.ReportController.index", + description: + "View all reports. Pagination may be done with HTTP Link header in the response.", + security: [%{"oAuth" => ["admin:read:reports"]}], + parameters: + [ + Operation.parameter(:resolved, :query, :boolean, "Filter for resolved reports"), + Operation.parameter(:account_id, :query, :string, "Filter by author account id"), + Operation.parameter( + :target_account_id, + :query, + :string, + "Filter by report target account id (not implemented)" + ) + ] ++ + pagination_params(), + responses: %{ + 200 => + Operation.response("Reports", "application/json", %Schema{ + title: "ArrayOfReports", + type: :array, + items: report() + }), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Report management (Mastodon API)"], + summary: "View a single report", + operationId: "MastodonAdmin.ReportController.show", + description: "View information about the report with the given ID.", + security: [%{"oAuth" => ["admin:read:reports"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the report") + ], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def resolve_operation do + %Operation{ + tags: ["Report management (Mastodon API)"], + summary: "Mark as resolved", + operationId: "MastodonAdmin.ReportController.resolve", + description: "Mark a report as resolved with no further action taken.", + security: [%{"oAuth" => ["admin:write:reports"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the report") + ], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 400 => Operation.response("Error", "application/json", ApiError), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def reopen_operation do + %Operation{ + tags: ["Report management (Mastodon API)"], + summary: "Re-open report", + operationId: "MastodonAdmin.ReportController.reopen", + description: "Reopen a currently closed report.", + security: [%{"oAuth" => ["admin:write:reports"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the report") + ], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 400 => Operation.response("Error", "application/json", ApiError), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp report do + %Schema{ + title: "Admin::Report", + type: :object, + properties: %{ + id: FlakeID, + action_taken: %Schema{type: :boolean}, + action_taken_at: %Schema{type: :string, format: "date-time", nullable: true}, + category: %Schema{type: :string, enum: ["spam", "violation", "other"]}, + comment: %Schema{type: :string, nullable: true}, + forwarded: %Schema{type: :boolean, nullable: true}, + created_at: %Schema{type: :string, format: "date-time"}, + updated_at: %Schema{type: :string, format: "date-time"}, + account: AccountOperation.account(), + target_account: AccountOperation.account(), + assigned_account: %Schema{ + oneOf: [ + AccountOperation.account(), + %Schema{type: :null} + ] + }, + action_taken_by_account: %Schema{ + oneOf: [ + AccountOperation.account(), + %Schema{type: :null} + ] + }, + statuses: %Schema{ + type: :array, + items: Status + }, + rules: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :integer}, + text: %Schema{type: :string} + } + } + } + } + } + end +end diff --git a/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex new file mode 100644 index 000000000..a1eac7fe3 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex @@ -0,0 +1,220 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [ + add_link_headers: 2, + assign_account_by_id: 2, + json_response: 3 + ] + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.Pagination + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Plugs.OAuthScopesPlug + + @filter_params ~W( + local external active needing_approval deactivated nickname name email staff + ) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:read:accounts"]} + when action in [:index, :show] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:accounts"]} + when action in [ + :delete, + :enable, + :account_action, + :approve, + :reject + ] + ) + + plug( + :assign_account_by_id + when action in [ + :show, + :delete, + :enable, + :account_action, + :approve, + :reject + ] + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation + + def index(conn, params) do + users = + params + |> build_criteria() + |> User.Query.build() + |> Pagination.fetch_paginated(params) + + conn + |> add_link_headers(users) + |> render("index.json", users: users) + end + + def show(%{assigns: %{user: _admin, account: user}} = conn, _params) do + render(conn, "show.json", user: user) + end + + def account_action( + %{assigns: %{user: admin, account: user}, body_params: %{type: type} = body_params} = + conn, + _params + ) do + {:ok, _user} = handle_account_action(user, admin, type) + + resolve_report(admin, body_params) + + json_response(conn, :no_content, "") + end + + def delete(%{assigns: %{user: admin, account: user}} = conn, _params) do + {:ok, delete_data, _} = Builder.delete(admin, user.ap_id) + Pipeline.common_pipeline(delete_data, local: true) + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "delete" + }) + + render(conn, "show.json", user: user) + end + + def enable(%{assigns: %{user: admin, account: user}} = conn, _params) do + {:ok, user} = User.set_activation(user, true) + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "activate" + }) + + render(conn, "show.json", user: user) + end + + def approve(%{assigns: %{user: admin, account: user}} = conn, _params) do + {:ok, user} = User.approve(user) + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "approve" + }) + + render(conn, "show.json", user: user) + end + + def reject(%{assigns: %{user: admin, account: user}} = conn, _params) do + with {:ok, _} <- User.reject(user) do + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "reject" + }) + + render(conn, "show.json", user: user) + else + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end + end + + defp handle_account_action(%User{local: true} = user, admin, "disable") do + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "deactivate" + }) + + User.set_activation(user, false) + end + + defp handle_account_action(user, _admin, _type) do + {:ok, user} + end + + defp build_criteria(params) do + %{} + |> maybe_filter_local(params) + |> maybe_filter_external(params) + |> maybe_filter_active(params) + |> maybe_filter_needing_approval(params) + |> maybe_filter_deactivated(params) + |> maybe_filter_nickname(params) + |> maybe_filter_name(params) + |> maybe_filter_email(params) + |> maybe_filter_staff(params) + end + + defp maybe_filter_local(criteria, %{local: true} = _params), + do: Map.put(criteria, :local, true) + + defp maybe_filter_local(criteria, %{local: false} = _params), + do: Map.put(criteria, :external, true) + + defp maybe_filter_external(criteria, %{remote: true} = _params), + do: Map.put(criteria, :external, true) + + defp maybe_filter_external(criteria, %{remote: false} = _params), + do: Map.put(criteria, :local, true) + + defp maybe_filter_active(criteria, %{active: active} = _params), + do: Map.put(criteria, :active, active) + + defp maybe_filter_needing_approval(criteria, %{pending: need_approval} = _params), + do: Map.put(criteria, :need_approval, need_approval) + + defp maybe_filter_deactivated(criteria, %{disabled: deactivated} = _params), + do: Map.put(criteria, :deactivated, deactivated) + + defp maybe_filter_nickname(criteria, %{username: nickname} = _params), + do: Map.put(criteria, :nickname, nickname) + + defp maybe_filter_name(criteria, %{display_name: name} = _params), + do: Map.put(criteria, :name, name) + + defp maybe_filter_email(criteria, %{email: email} = _params), + do: Map.put(criteria, :email, email) + + defp maybe_filter_staff(criteria, %{staff: staff} = _params), + do: Map.put(criteria, :staff, staff) + + for filter_param <- @filter_params do + defp unquote(:"maybe_filter_#{filter_param}")(criteria, _params), do: criteria + end + + defp resolve_report(admin, %{report_id: id}) do + with {:ok, activity} <- CommonAPI.update_report_state(id, "resolved"), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity, + subject_actor: report.user_actor + }) + end + end + + defp resolve_report(_admin, _params) do + end +end diff --git a/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex new file mode 100644 index 000000000..217a97cc9 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex @@ -0,0 +1,107 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [ + add_link_headers: 2, + json_response: 3 + ] + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.Pagination + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug(OAuthScopesPlug, %{scopes: ["admin:read:reports"]} when action in [:index, :show]) + + plug(OAuthScopesPlug, %{scopes: ["admin:write:reports"]} when action in [:resolve, :reopen]) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation + + def index(conn, params) do + opts = + %{} + |> Map.put(:type, "Flag") + |> Map.put(:skip_preload, true) + |> Map.put(:preload_report_notes, true) + |> Map.put(:total, true) + |> restrict_state(params) + |> restrict_actor(params) + + reports = + ActivityPub.fetch_activities_query([], opts) + |> Pagination.fetch_paginated(params) + + conn + |> add_link_headers(reports) + |> render("index.json", reports: reports) + end + + def show(conn, %{id: id}) do + with %Activity{} = report <- Activity.get_report(id) do + render(conn, "show.json", Report.extract_report_info(report)) + else + _ -> {:error, :not_found} + end + end + + def resolve(%{assigns: %{user: admin}} = conn, %{id: id}) do + with {:ok, activity} <- CommonAPI.update_report_state(id, "resolved"), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity, + subject_actor: report.user_actor + }) + + render(conn, "show.json", Report.extract_report_info(activity)) + else + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end + end + + def reopen(%{assigns: %{user: admin}} = conn, %{id: id}) do + with {:ok, activity} <- CommonAPI.update_report_state(id, "open"), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity, + subject_actor: report.user_actor + }) + + render(conn, "show.json", Report.extract_report_info(report)) + else + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end + end + + defp restrict_state(opts, %{resolved: true}), do: Map.put(opts, :state, "resolved") + + defp restrict_state(opts, %{resolved: false}), do: Map.put(opts, :state, "open") + + defp restrict_state(opts, _params), do: opts + + defp restrict_actor(opts, %{account_id: actor}) do + with %User{ap_id: ap_id} <- User.get_by_id(actor) do + Map.put(opts, :actor_id, ap_id) + else + _ -> Map.put(opts, :actor_id, actor) + end + end + + defp restrict_actor(opts, _params), do: opts +end diff --git a/lib/pleroma/web/mastodon_api/admin/views/account_view.ex b/lib/pleroma/web/mastodon_api/admin/views/account_view.ex new file mode 100644 index 000000000..20ccfd51c --- /dev/null +++ b/lib/pleroma/web/mastodon_api/admin/views/account_view.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.AccountView do + use Pleroma.Web, :view + + alias Pleroma.User + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI + + def render("index.json", %{users: users}) do + render_many(users, __MODULE__, "show.json", as: :user) + end + + def render("show.json", %{user: user}) do + account = + MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true}) + + %{ + id: user.id, + username: username_from_nickname(user.nickname), + domain: domain_from_nickname(user.nickname), + created_at: Utils.to_masto_date(user.inserted_at), + email: user.email, + ip: nil, + ips: [], + locale: nil, + invite_request: user.registration_reason, + role: role(user), + confirmed: user.is_confirmed, + approved: user.is_approved, + disabled: !user.is_active, + silenced: nil, + suspended: nil, + account: account + } + end + + defp username_from_nickname(string) when is_binary(string) do + hd(String.split(string, "@")) + end + + defp username_from_nickname(_), do: nil + + defp domain_from_nickname(string) when is_binary(string) do + String.split(string, "@") + |> Enum.at(1, nil) + end + + defp domain_from_nickname(_), do: nil + + defp role(%User{is_admin: true}) do + "admin" + end + + defp role(%User{is_moderator: true}) do + "moderator" + end + + defp role(_user) do + nil + end +end diff --git a/lib/pleroma/web/mastodon_api/admin/views/report_view.ex b/lib/pleroma/web/mastodon_api/admin/views/report_view.ex new file mode 100644 index 000000000..664f58b03 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/admin/views/report_view.ex @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.ReportView do + use Pleroma.Web, :view + + alias Pleroma.HTML + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.Admin.AccountView + alias Pleroma.Web.MastodonAPI.StatusView + + def render("index.json", %{reports: reports}) do + reports + |> Enum.map(&Report.extract_report_info/1) + |> Enum.map(&render(__MODULE__, "show.json", &1)) + end + + def render("show.json", %{ + report: report, + user: account, + account: target_account, + statuses: statuses + }) do + created_at = Utils.to_masto_date(report.data["published"]) + + content = + unless is_nil(report.data["content"]) do + HTML.filter_tags(report.data["content"]) + else + nil + end + + %{ + id: report.id, + action_taken: report.data["state"] != "open", + action_taken_at: nil, + category: "other", + comment: content, + forwarded: nil, + created_at: created_at, + updated_at: created_at, + account: AccountView.render("show.json", %{user: account}), + target_account: AccountView.render("show.json", %{user: target_account}), + assigned_account: nil, + action_taken_by_account: nil, + statuses: + StatusView.render("index.json", %{ + activities: statuses, + as: :activity + }), + rules: [] + } + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 20ac1c67b..a8ba84036 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -418,6 +418,46 @@ defmodule Pleroma.Web.Router do delete("/chats/:id/messages/:message_id", ChatController, :delete_message) end + # Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role) + scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do + pipe_through([:require_privileged_role_users_read]) + + get("/accounts", AccountController, :index) + get("/accounts/:id", AccountController, :show) + end + + # Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role) + scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do + pipe_through(:require_privileged_role_users_delete) + + delete("/accounts/:id", AccountController, :delete) + end + + # Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role) + scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do + pipe_through([:require_privileged_role_users_manage_activation_state]) + + post("/accounts/:id/action", AccountController, :account_action) + post("/accounts/:id/enable", AccountController, :enable) + end + + # Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role) + scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do + pipe_through([:require_privileged_role_users_manage_invites]) + post("/accounts/:id/approve", AccountController, :approve) + post("/accounts/:id/reject", AccountController, :reject) + end + + # Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role) + scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do + pipe_through([:require_privileged_role_reports_manage_reports]) + + get("/reports", ReportController, :index) + get("/reports/:id", ReportController, :show) + post("/reports/:id/resolve", ReportController, :resolve) + post("/reports/:id/reopen", ReportController, :reopen) + end + # AdminAPI: admins and mods (staff) can perform these actions (if privileged by role) scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do pipe_through(:require_privileged_role_emoji_manage_emoji) diff --git a/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs new file mode 100644 index 000000000..7280691d4 --- /dev/null +++ b/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs @@ -0,0 +1,175 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.AccountControllerTest do + use Pleroma.Web.ConnCase + use Oban.Testing, repo: Pleroma.Repo + + import Pleroma.Factory + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + + :ok + end + + 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/admin/accounts" do + test "search by display name", %{conn: conn} do + %{id: id} = insert(:user, name: "Display name") + insert(:user, name: "Other name") + + assert [%{"id" => ^id}] = + conn + |> get("/api/v1/admin/accounts?display_name=Display") + |> json_response_and_validate_schema(200) + end + end + + describe "GET /api/v1/admin/accounts/:id" do + test "show admin-level information", %{conn: conn} do + %{id: id} = + insert(:user, + email: "email@example.com", + is_confirmed: false, + is_moderator: true + ) + + assert %{ + "id" => ^id, + "email" => "email@example.com", + "confirmed" => false, + "role" => "moderator" + } = + conn + |> get("/api/v1/admin/accounts/#{id}") + |> json_response_and_validate_schema(200) + end + end + + describe "DELETE /api/v1/admin/accounts/:id" do + test "delete account", %{conn: conn} do + %{id: id} = user = insert(:user) + + conn + |> delete("/api/v1/admin/accounts/#{id}") + |> json_response_and_validate_schema(200) + + user = Repo.reload!(user) + + assert %{is_active: false} = user + end + end + + describe "POST /api/v1/admin/accounts/:id/action" do + test "disable account", %{conn: conn} do + %{id: id} = user = insert(:user) + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/admin/accounts/#{id}/action", %{ + "type" => "disable" + }) + |> json_response_and_validate_schema(204) + + user = Repo.reload!(user) + + assert %{is_active: false} = user + end + + test "perform action with assigned report", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + + {:ok, %{id: report_id} = report} = + CommonAPI.report(reporter, %{ + account_id: target_user.id + }) + + %{id: id} = insert(:user) + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/admin/accounts/#{id}/action", %{ + "type" => "none", + "report_id" => report_id + }) + |> json_response_and_validate_schema(204) + + report = Repo.reload!(report) + + assert %{data: %{"state" => "resolved"}} = report + end + end + + describe "POST /api/v1/admin/accounts/:id/enable" do + test "enable account", %{conn: conn} do + %{id: id} = user = insert(:user) + User.set_activation(user, false) + + conn + |> post("/api/v1/admin/accounts/#{id}/enable") + |> json_response_and_validate_schema(200) + + user = Repo.reload!(user) + + assert %{is_active: true} = user + end + end + + describe "POST /api/v1/admin/accounts/:id/approve" do + test "approve account", %{conn: conn} do + %{id: id} = user = insert(:user, is_approved: false) + + conn + |> post("/api/v1/admin/accounts/#{id}/approve") + |> json_response_and_validate_schema(200) + + user = Repo.reload!(user) + + assert %{is_approved: true} = user + end + end + + describe "POST /api/v1/admin/accounts/:id/rejct" do + test "reject account", %{conn: conn} do + %{id: id} = user = insert(:user, is_approved: false) + + conn + |> post("/api/v1/admin/accounts/#{id}/reject") + |> json_response_and_validate_schema(200) + + user = Repo.reload!(user) + + assert %{is_active: false} = user + end + + test "do not allow rejecting already accepted accounts", %{conn: conn} do + %{id: id} = user = insert(:user, is_approved: true) + + assert %{"error" => "User is approved"} == + conn + |> post("/api/v1/admin/accounts/#{id}/reject") + |> json_response_and_validate_schema(400) + + user = Repo.reload!(user) + + assert %{is_approved: true} = user + end + end +end diff --git a/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs b/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs new file mode 100644 index 000000000..a0cc5f459 --- /dev/null +++ b/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs @@ -0,0 +1,118 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do + use Pleroma.Web.ConnCase + use Oban.Testing, repo: Pleroma.Repo + + import Pleroma.Factory + + alias Pleroma.Web.CommonAPI + + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + + :ok + end + + 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/admin/reports" do + test "get reports by state", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id1}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "this user uses an app called Hitler Tusky", + status_ids: [activity.id] + }) + + {:ok, %{id: report_id2}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + status_ids: [activity.id] + }) + + CommonAPI.update_report_state(report_id2, "resolved") + + assert [%{"id" => ^report_id1}] = + conn + |> get("/api/v1/admin/reports?resolved=false") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^report_id2}] = + conn + |> get("/api/v1/admin/reports?resolved=true") + |> json_response_and_validate_schema(200) + end + end + + describe "GET /api/v1/admin/reports/:id" do + test "get report by id", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + status_ids: [activity.id] + }) + + assert %{"id" => ^report_id} = + conn + |> get("/api/v1/admin/reports/#{report_id}") + |> json_response_and_validate_schema(200) + end + end + + describe "POST /api/v1/admin/reports/:id/resolve" do + test "resolve a report", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + status_ids: [activity.id] + }) + + assert %{"id" => ^report_id, "action_taken" => true} = + conn + |> post("/api/v1/admin/reports/#{report_id}/resolve") + |> json_response_and_validate_schema(200) + end + end + + describe "POST /api/v1/admin/reports/:id/reopen" do + test "reopen a report", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + status_ids: [activity.id] + }) + + CommonAPI.update_report_state(report_id, "resolved") + + assert %{"id" => ^report_id, "action_taken" => false} = + conn + |> post("/api/v1/admin/reports/#{report_id}/reopen") + |> json_response_and_validate_schema(200) + end + end +end From 2c14121389d0dadd0609695b941881eb5d93cbae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 19 Sep 2025 16:59:36 +0200 Subject: [PATCH 2/8] MastoAPI: /api/v2/admin/accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- lib/pleroma/user/query.ex | 5 + .../mastodon_admin/account_operation.ex | 118 +++++++++++++++--- .../mastodon_admin/report_operation.ex | 12 +- .../admin/controllers/account_controller.ex | 48 ++++++- lib/pleroma/web/router.ex | 7 ++ 5 files changed, 164 insertions(+), 26 deletions(-) diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index b877b52b2..33f19c454 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -302,6 +302,11 @@ defmodule Pleroma.User.Query do where(query, [u], u.is_admin == true or u.is_moderator == true) end + defp compose_query({:domain, domain}, query) do + query + |> where([u], like(u.nickname, ^"%@#{domain}")) + end + defp compose_query(_unsupported_param, query), do: query defp location_query(query, local) do diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex index 23dae0306..19ccf97fb 100644 --- a/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex @@ -19,7 +19,7 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do def index_operation do %Operation{ tags: ["User administration (Mastodon API)"], - summary: "View accounts by criteria", + summary: "View accounts by criteria (v1)", operationId: "MastodonAdmin.AccountController.index", description: "View accounts matching certain criteria for filtering, up to 40 at a time.", security: [%{"oAuth" => ["admin:read:accounts"]}], @@ -27,12 +27,6 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do [ Operation.parameter(:local, :query, :boolean, "Filter for local accounts?"), Operation.parameter(:remote, :query, :boolean, "Filter for remote accounts?"), - Operation.parameter( - :by_domain, - :query, - :string, - "Filter by the given domain (not implemented yet)" - ), Operation.parameter( :active, :query, @@ -51,12 +45,6 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do :boolean, "Filter for currently disabled accounts?" ), - Operation.parameter( - :sensitized, - :query, - :boolean, - "Filter for currently sensitized accounts? (not implemented yet)" - ), Operation.parameter( :silenced, :query, @@ -69,14 +57,31 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do :boolean, "Filter for currently suspended accounts? (not implemented yet)" ), - Operation.parameter(:username, :query, :string, "Username to search for"), - Operation.parameter(:display_name, :query, :string, "Display name to search for"), + Operation.parameter( + :sensitized, + :query, + :boolean, + "Filter for accounts force-marked as sensitive? (not implemented yet)" + ), + Operation.parameter(:username, :query, :string, "Search for the given username"), + Operation.parameter( + :display_name, + :query, + :string, + "Search for the given display name" + ), + Operation.parameter( + :by_domain, + :query, + :string, + "Filter by the given domain" + ), Operation.parameter(:email, :query, :string, "Lookup a user with this email"), Operation.parameter( :ip, :query, :string, - "Lookup users by this IP address (not implemented yet)" + "Lookup users with this IP address (not implemented yet)" ), Operation.parameter(:staff, :query, :boolean, "Filter for staff accounts?") ] ++ @@ -93,6 +98,87 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do } end + def index2_operation do + %Operation{ + tags: ["User administration (Mastodon API)"], + summary: "View accounts by criteria (v2)", + operationId: "MastodonAdmin.AccountController.index2", + description: "View accounts matching certain criteria for filtering, up to 40 at a time.", + security: [%{"oAuth" => ["admin:read:accounts"]}], + parameters: + [ + Operation.parameter( + :origin, + :query, + %Schema{type: :string, enum: ["local", "remote"]}, + "Filter for local or remote accounts" + ), + Operation.parameter( + :status, + :query, + %Schema{ + type: :string, + enum: ["active", "inactive", "pending", "disabled", "silenced", "suspended"] + }, + "Filter for active, pending, disabled, silenced or suspended accounts" + ), + Operation.parameter( + :permissions, + :query, + :string, + "Filter for accounts with staff permissions (users that can manage reports). (not implemented yet)" + ), + Operation.parameter( + :role_ids, + :query, + %Schema{ + oneOf: [ + %Schema{type: :array, items: %Schema{type: :string}}, + %Schema{type: :string} + ] + }, + "Filter for users with these roles. (not implemented yet)" + ), + Operation.parameter( + :invited_by, + :query, + :string, + "Lookup users invited by the account with this ID. (not implemented yet)" + ), + Operation.parameter(:username, :query, :string, "Search for the given username"), + Operation.parameter( + :display_name, + :query, + :string, + "Search for the given display name" + ), + Operation.parameter( + :by_domain, + :query, + :string, + "Filter by the given domain" + ), + Operation.parameter(:email, :query, :string, "Lookup a user with this email"), + Operation.parameter( + :ip, + :query, + :string, + "Lookup users with this IP address (not implemented yet)" + ) + ] ++ + pagination_params(), + responses: %{ + 200 => + Operation.response("Account", "application/json", %Schema{ + title: "ArrayOfAccounts", + type: :array, + items: account() + }), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + def show_operation do %Operation{ tags: ["User administration (Mastodon API)"], diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex index 274ce3bd9..487e7853e 100644 --- a/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex @@ -119,16 +119,12 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do account: AccountOperation.account(), target_account: AccountOperation.account(), assigned_account: %Schema{ - oneOf: [ - AccountOperation.account(), - %Schema{type: :null} - ] + type: AccountOperation.account(), + nullable: true }, action_taken_by_account: %Schema{ - oneOf: [ - AccountOperation.account(), - %Schema{type: :null} - ] + type: AccountOperation.account(), + nullable: true }, statuses: %Schema{ type: :array, diff --git a/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex index a1eac7fe3..833bf1db5 100644 --- a/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do alias Pleroma.Web.Plugs.OAuthScopesPlug @filter_params ~W( - local external active needing_approval deactivated nickname name email staff + local external active needing_approval deactivated nickname name domain email staff origin status ) plug(Pleroma.Web.ApiSpec.CastAndValidate) @@ -30,7 +30,7 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do plug( OAuthScopesPlug, %{scopes: ["admin:read:accounts"]} - when action in [:index, :show] + when action in [:index, :index2, :show] ) plug( @@ -71,6 +71,18 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do |> render("index.json", users: users) end + def index2(conn, params) do + users = + params + |> build_criteria_v2() + |> User.Query.build() + |> Pagination.fetch_paginated(params) + + conn + |> add_link_headers(users) + |> render("index.json", users: users) + end + def show(%{assigns: %{user: _admin, account: user}} = conn, _params) do render(conn, "show.json", user: user) end @@ -162,10 +174,21 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do |> maybe_filter_deactivated(params) |> maybe_filter_nickname(params) |> maybe_filter_name(params) + |> maybe_filter_domain(params) |> maybe_filter_email(params) |> maybe_filter_staff(params) end + defp build_criteria_v2(params) do + %{} + |> maybe_filter_origin(params) + |> maybe_filter_status(params) + |> maybe_filter_nickname(params) + |> maybe_filter_name(params) + |> maybe_filter_domain(params) + |> maybe_filter_email(params) + end + defp maybe_filter_local(criteria, %{local: true} = _params), do: Map.put(criteria, :local, true) @@ -178,6 +201,12 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do defp maybe_filter_external(criteria, %{remote: false} = _params), do: Map.put(criteria, :local, true) + defp maybe_filter_origin(criteria, %{origin: "local"} = _params), + do: Map.put(criteria, :local, true) + + defp maybe_filter_origin(criteria, %{origin: "remote"} = _params), + do: Map.put(criteria, :external, true) + defp maybe_filter_active(criteria, %{active: active} = _params), do: Map.put(criteria, :active, active) @@ -187,12 +216,27 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do defp maybe_filter_deactivated(criteria, %{disabled: deactivated} = _params), do: Map.put(criteria, :deactivated, deactivated) + defp maybe_filter_status(criteria, %{status: "active"} = _params), + do: Map.put(criteria, :active, true) + + defp maybe_filter_status(criteria, %{status: "inactive"} = _params), + do: Map.put(criteria, :active, false) + + defp maybe_filter_status(criteria, %{status: "pending"} = _params), + do: Map.put(criteria, :need_approval, true) + + defp maybe_filter_status(criteria, %{status: "disabled"} = _params), + do: Map.put(criteria, :deactivated, true) + defp maybe_filter_nickname(criteria, %{username: nickname} = _params), do: Map.put(criteria, :nickname, nickname) defp maybe_filter_name(criteria, %{display_name: name} = _params), do: Map.put(criteria, :name, name) + defp maybe_filter_domain(criteria, %{by_domain: domain} = _params), + do: Map.put(criteria, :domain, domain) + defp maybe_filter_email(criteria, %{email: email} = _params), do: Map.put(criteria, :email, email) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index a8ba84036..87568b481 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -458,6 +458,13 @@ defmodule Pleroma.Web.Router do post("/reports/:id/reopen", ReportController, :reopen) end + # Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role) + scope "/api/v2/admin", Pleroma.Web.MastodonAPI.Admin do + pipe_through([:require_privileged_role_users_read]) + + get("/accounts", AccountController, :index2) + end + # AdminAPI: admins and mods (staff) can perform these actions (if privileged by role) scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do pipe_through(:require_privileged_role_emoji_manage_emoji) From 87e6cd3dc009f977fec7fddb664c73cc525d9fe1 Mon Sep 17 00:00:00 2001 From: mkljczk Date: Tue, 18 Mar 2025 00:34:37 +0100 Subject: [PATCH 3/8] Adapt tests for v2/admin/accounts from Mastodon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: mkljczk Signed-off-by: nicole mikołajczyk --- .../controllers/account_controller_test.exs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs index 7280691d4..e06b3a70a 100644 --- a/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs @@ -42,6 +42,96 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountControllerTest do end end + # Adapted from + # https://github.com/mastodon/mastodon/blob/main/spec/requests/api/v2/admin/accounts_spec.rb + describe "GET /api/v2/admin/accounts" do + setup do + remote_account = insert(:user, nickname: "remote@example.org", local: false) + other_remote_account = insert(:user, nickname: "other@foo.bar", local: false) + # suspended_account = insert(:user) + # suspended_remote = insert(:user) + disabled_account = insert(:user, is_active: false) + pending_account = insert(:user, is_approved: false) + admin_account = insert(:user, is_admin: true) + + {:ok, + %{ + remote_account: remote_account, + other_remote_account: other_remote_account, + disabled_account: disabled_account, + pending_account: pending_account, + admin_account: admin_account + }} + end + + # test "returns the correct accounts when called with status active + # and origin local and permissions staff", %{ + # conn: conn, + # admin_account: %{id: admin_account_id} + # } do + # assert [%{"id" => ^admin_account_id}] = + # conn + # |> get("/api/v2/admin/accounts?status=active&origin=local&permissions=staff") + # |> json_response_and_validate_schema(200) + # end + + test "returns the correct accounts when called with by_domain value and origin remote", %{ + conn: conn, + remote_account: %{id: remote_account_id} + } do + assert [%{"id" => ^remote_account_id}] = + conn + |> get("/api/v2/admin/accounts?by_domain=example.org&origin=remote") + |> json_response_and_validate_schema(200) + end + + # test "returns the correct accounts when called with status suspended", %{ + # conn: conn, + # suspended_account: %{id: suspended_account_id} + # } do + # assert [%{"id" => ^suspended_account_id}] = + # conn + # |> get("/api/v2/admin/accounts?status=suspended") + # |> json_response_and_validate_schema(200) + # end + + test "returns the correct accounts when called with status disabled", %{ + conn: conn, + disabled_account: %{id: disabled_account_id} + } do + assert [%{"id" => ^disabled_account_id}] = + conn + |> get("/api/v2/admin/accounts?status=disabled") + |> json_response_and_validate_schema(200) + end + + test "returns the correct accounts when called with status pending", %{ + conn: conn, + pending_account: %{id: pending_account_id} + } do + assert [%{"id" => ^pending_account_id}] = + conn + |> get("/api/v2/admin/accounts?status=pending") + |> json_response_and_validate_schema(200) + end + + test "sets the correct pagination headers with limit param", %{ + conn: conn, + admin_account: %{id: admin_account_id} + } do + response = + conn + |> get("/api/v2/admin/accounts?limit=1") + + next_url = + ~r{<.+?(?/api[^>]+)>; rel=\"next\"} + |> Regex.named_captures(get_resp_header(response, "link") |> Enum.at(0)) + |> Map.get("link") + + next_url =~ "&limit=1&max_id=#{admin_account_id}" + end + end + describe "GET /api/v1/admin/accounts/:id" do test "show admin-level information", %{conn: conn} do %{id: id} = From 8626a337f807d6ab97840419148f187d081d18bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Fri, 27 Feb 2026 22:34:18 +0100 Subject: [PATCH 4/8] Mastodon admin API v2 improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../api_spec/operations/mastodon_admin/account_operation.ex | 6 +++--- .../api_spec/operations/mastodon_admin/report_operation.ex | 4 ++-- .../web/mastodon_api/admin/controllers/report_controller.ex | 2 +- .../admin/controllers/account_controller_test.exs | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex index 19ccf97fb..e9143364c 100644 --- a/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex @@ -31,7 +31,7 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do :active, :query, :boolean, - "Filter for currently active accounts??" + "Filter for currently active accounts?" ), Operation.parameter( :pending, @@ -327,8 +327,8 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do } } }, - locale: %Schema{type: :string, format: "date-time", nullable: true}, - invite_request: %Schema{type: :string, format: "date-time", nullable: true}, + locale: %Schema{type: :string, nullable: true}, + invite_request: %Schema{type: :string, nullable: true}, role: %Schema{type: :string, nullable: true}, confirmed: %Schema{type: :boolean}, approved: %Schema{type: :boolean}, diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex index 487e7853e..3360cb843 100644 --- a/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex @@ -119,11 +119,11 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do account: AccountOperation.account(), target_account: AccountOperation.account(), assigned_account: %Schema{ - type: AccountOperation.account(), + oneOf: [AccountOperation.account()], nullable: true }, action_taken_by_account: %Schema{ - type: AccountOperation.account(), + oneOf: [AccountOperation.account()], nullable: true }, statuses: %Schema{ diff --git a/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex index 217a97cc9..2d89d13bc 100644 --- a/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex +++ b/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex @@ -65,7 +65,7 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do subject_actor: report.user_actor }) - render(conn, "show.json", Report.extract_report_info(activity)) + render(conn, "show.json", Report.extract_report_info(report)) else {:error, error} -> json_response(conn, :bad_request, %{error: error}) diff --git a/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs index e06b3a70a..113b242dd 100644 --- a/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs @@ -236,7 +236,7 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountControllerTest do end end - describe "POST /api/v1/admin/accounts/:id/rejct" do + describe "POST /api/v1/admin/accounts/:id/reject" do test "reject account", %{conn: conn} do %{id: id} = user = insert(:user, is_approved: false) From ae763ff6cf97fdb78edc5bdad13aab917cea37ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Mon, 4 May 2026 01:41:12 +0200 Subject: [PATCH 5/8] api/v2/admin/accounts: support 'permissions' param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../api_spec/operations/mastodon_admin/account_operation.ex | 2 +- .../web/mastodon_api/admin/controllers/account_controller.ex | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex index e9143364c..ad79d43d7 100644 --- a/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex @@ -126,7 +126,7 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do :permissions, :query, :string, - "Filter for accounts with staff permissions (users that can manage reports). (not implemented yet)" + "Filter for accounts with staff permissions (users that can manage reports)." ), Operation.parameter( :role_ids, diff --git a/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex index 833bf1db5..e96566774 100644 --- a/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex @@ -183,6 +183,7 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do %{} |> maybe_filter_origin(params) |> maybe_filter_status(params) + |> maybe_filter_staff(params) |> maybe_filter_nickname(params) |> maybe_filter_name(params) |> maybe_filter_domain(params) @@ -240,6 +241,9 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do defp maybe_filter_email(criteria, %{email: email} = _params), do: Map.put(criteria, :email, email) + defp maybe_filter_staff(criteria, %{permissions: "staff"} = _params), + do: Map.put(criteria, :staff, true) + defp maybe_filter_staff(criteria, %{staff: staff} = _params), do: Map.put(criteria, :staff, staff) From 11281d740a136c349b927587c1d895ea590f76af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Mon, 4 May 2026 13:48:38 +0200 Subject: [PATCH 6/8] make GET /api/v1/admin/reports match Mastodon admin API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../mastodon_admin/report_operation.ex | 1 + .../admin/controllers/report_controller.ex | 39 ++++++++++++++++++- .../controllers/report_controller_test.exs | 12 +++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex index 3360cb843..f59db9966 100644 --- a/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex @@ -28,6 +28,7 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do parameters: [ Operation.parameter(:resolved, :query, :boolean, "Filter for resolved reports"), + Operation.parameter(:unresolved, :query, :boolean, "Filter for open reports"), Operation.parameter(:account_id, :query, :string, "Filter by author account id"), Operation.parameter( :target_account_id, diff --git a/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex index 2d89d13bc..5dbf19aba 100644 --- a/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex +++ b/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex @@ -89,11 +89,46 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do end end + def assign_to_self(%{assigns: %{user: admin}} = conn, %{id: id}) do + with {:ok, activity} <- CommonAPI.assign_report_to_account(id, admin.id), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_assigned", + actor: admin, + subject: activity, + subject_actor: report.user_actor, + assigned_account: admin.nickname + }) + + render(conn, "show.json", Report.extract_report_info(report)) + else + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end + end + + def unassign(%{assigns: %{user: admin}} = conn, %{id: id}) do + with {:ok, activity} <- CommonAPI.assign_report_to_account(id, nil), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_unassigned", + actor: admin, + subject: activity, + subject_actor: report.user_actor + }) + + render(conn, "show.json", Report.extract_report_info(report)) + else + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end + end + defp restrict_state(opts, %{resolved: true}), do: Map.put(opts, :state, "resolved") - defp restrict_state(opts, %{resolved: false}), do: Map.put(opts, :state, "open") + defp restrict_state(opts, %{resolved: _}), do: Map.put(opts, :state, "resolved") - defp restrict_state(opts, _params), do: opts + defp restrict_state(opts, _params), do: Map.put(opts, :state, "open") defp restrict_actor(opts, %{account_id: actor}) do with %User{ap_id: ap_id} <- User.get_by_id(actor) do diff --git a/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs b/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs index a0cc5f459..702dcf1db 100644 --- a/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs +++ b/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs @@ -50,7 +50,17 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do assert [%{"id" => ^report_id1}] = conn - |> get("/api/v1/admin/reports?resolved=false") + |> get("/api/v1/admin/reports?unresolved=true") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^report_id1}] = + conn + |> get("/api/v1/admin/reports") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^report_id2}, %{"id" => ^report_id1}] = + conn + |> get("/api/v1/admin/reports?resolved=true&unresolved=true") |> json_response_and_validate_schema(200) assert [%{"id" => ^report_id2}] = From 458a15bf9f7d6eaaf44ea6cbe6c9f2a58b8502d1 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sun, 26 Jul 2026 13:24:03 +0400 Subject: [PATCH 7/8] fix(mastodon-api): harden admin moderation actions --- lib/pleroma/web/activity_pub/activity_pub.ex | 9 ++ .../mastodon_admin/account_operation.ex | 10 +- .../mastodon_admin/report_operation.ex | 43 ++++++- lib/pleroma/web/common_api.ex | 4 +- .../admin/controllers/account_controller.ex | 53 +++++++-- .../admin/controllers/report_controller.ex | 33 +++++- .../mastodon_api/admin/views/report_view.ex | 29 +++-- lib/pleroma/web/router.ex | 15 ++- .../controllers/account_controller_test.exs | 68 ++++++++++- .../controllers/report_controller_test.exs | 111 ++++++++++++++++++ 10 files changed, 344 insertions(+), 31 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 10d3fe063..cdad6a49b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1016,6 +1016,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_state(query, _), do: query + defp restrict_report_target(query, %{report_target_id: actor_id}) do + from(activity in query, + where: fragment("?->'object'->>0 = ?", activity.data, ^actor_id) + ) + end + + defp restrict_report_target(query, _), do: query + defp restrict_assigned_account(query, %{assigned_account: assigned_account}) do from(activity in query, where: fragment("?->>'assigned_account' = ?", activity.data, ^assigned_account) @@ -1514,6 +1522,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> restrict_actor(opts) |> restrict_type(opts) |> restrict_state(opts) + |> restrict_report_target(opts) |> restrict_assigned_account(opts) |> restrict_favorited_by(opts) |> restrict_blocked(restrict_blocked_opts) diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex index ad79d43d7..3b39cbe2a 100644 --- a/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex @@ -216,20 +216,24 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do properties: %{ type: %Schema{ type: :string, - enum: ["none", "disable", "sensitive", "silence", "suspend"] + enum: ["none", "disable"] }, report_id: %Schema{ type: :string, nullable: true, description: "ID of an associated report that caused this action to be taken" } - } + }, + required: [:type] }, required: true ), responses: %{ 204 => no_content_response(), - 401 => Operation.response("Error", "application/json", ApiError) + 400 => Operation.response("Error", "application/json", ApiError), + 401 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) } } end diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex index f59db9966..7e0bcc24b 100644 --- a/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex @@ -34,7 +34,7 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do :target_account_id, :query, :string, - "Filter by report target account id (not implemented)" + "Filter by report target account id" ) ] ++ pagination_params(), @@ -81,7 +81,8 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do responses: %{ 200 => Operation.response("Report", "application/json", report()), 400 => Operation.response("Error", "application/json", ApiError), - 401 => Operation.response("Error", "application/json", ApiError) + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) } } end @@ -99,11 +100,43 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do responses: %{ 200 => Operation.response("Report", "application/json", report()), 400 => Operation.response("Error", "application/json", ApiError), - 401 => Operation.response("Error", "application/json", ApiError) + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) } } end + def assign_to_self_operation do + %Operation{ + tags: ["Report management (Mastodon API)"], + summary: "Assign report to self", + operationId: "MastodonAdmin.ReportController.assign_to_self", + security: [%{"oAuth" => ["admin:write:reports"]}], + parameters: [Operation.parameter(:id, :path, :string, "ID of the report")], + responses: report_update_responses() + } + end + + def unassign_operation do + %Operation{ + tags: ["Report management (Mastodon API)"], + summary: "Unassign report", + operationId: "MastodonAdmin.ReportController.unassign", + security: [%{"oAuth" => ["admin:write:reports"]}], + parameters: [Operation.parameter(:id, :path, :string, "ID of the report")], + responses: report_update_responses() + } + end + + defp report_update_responses do + %{ + 200 => Operation.response("Report", "application/json", report()), + 400 => Operation.response("Error", "application/json", ApiError), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + end + defp report do %Schema{ title: "Admin::Report", @@ -112,7 +145,7 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do id: FlakeID, action_taken: %Schema{type: :boolean}, action_taken_at: %Schema{type: :string, format: "date-time", nullable: true}, - category: %Schema{type: :string, enum: ["spam", "violation", "other"]}, + category: %Schema{type: :string, enum: ["spam", "legal", "violation", "other"]}, comment: %Schema{type: :string, nullable: true}, forwarded: %Schema{type: :boolean, nullable: true}, created_at: %Schema{type: :string, format: "date-time"}, @@ -136,7 +169,7 @@ defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do items: %Schema{ type: :object, properties: %{ - id: %Schema{type: :integer}, + id: %Schema{type: :string}, text: %Schema{type: :string} } } diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index cb9d521b3..220a8a228 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -700,7 +700,7 @@ defmodule Pleroma.Web.CommonAPI do end def update_report_state(activity_id, state) do - with %Activity{} = activity <- Activity.get_by_id(activity_id, filter: []) do + with %Activity{} = activity <- Activity.get_report(activity_id) do Utils.update_report_state(activity, state) else nil -> {:error, :not_found} @@ -715,7 +715,7 @@ defmodule Pleroma.Web.CommonAPI do end def assign_report_to_account(activity_id, user) do - with %Activity{} = activity <- Activity.get_by_id(activity_id) do + with %Activity{} = activity <- Activity.get_report(activity_id) do Utils.assign_report_to_account(activity, user) else nil -> {:error, :not_found} diff --git a/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex index e96566774..b4e038f0e 100644 --- a/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex @@ -33,6 +33,8 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do when action in [:index, :index2, :show] ) + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug( OAuthScopesPlug, %{scopes: ["admin:write:accounts"]} @@ -92,11 +94,18 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do conn, _params ) do - {:ok, _user} = handle_account_action(user, admin, type) + with :ok <- validate_account_action(user, type), + {:ok, report} <- associated_report(user, body_params), + :ok <- resolve_report(admin, report), + {:ok, _user} <- handle_account_action(user, admin, type) do + json_response(conn, :no_content, "") + else + {:error, :not_found} -> + {:error, :not_found} - resolve_report(admin, body_params) - - json_response(conn, :no_content, "") + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end end def delete(%{assigns: %{user: admin, account: user}} = conn, _params) do @@ -161,10 +170,21 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do User.set_activation(user, false) end - defp handle_account_action(user, _admin, _type) do + defp handle_account_action(%User{}, _admin, "disable") do + {:error, "Only local accounts can be disabled"} + end + + defp handle_account_action(user, _admin, "none") do {:ok, user} end + defp validate_account_action(%User{local: true}, "disable"), do: :ok + + defp validate_account_action(%User{}, "disable"), + do: {:error, "Only local accounts can be disabled"} + + defp validate_account_action(%User{}, "none"), do: :ok + defp build_criteria(params) do %{} |> maybe_filter_local(params) @@ -251,7 +271,23 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do defp unquote(:"maybe_filter_#{filter_param}")(criteria, _params), do: criteria end - defp resolve_report(admin, %{report_id: id}) do + defp associated_report(user, %{report_id: id}) do + case Activity.get_report(id) do + %Activity{data: %{"object" => [target_ap_id | _]}} = report + when target_ap_id == user.ap_id -> + {:ok, report} + + %Activity{} -> + {:error, "Report does not target this account"} + + nil -> + {:error, :not_found} + end + end + + defp associated_report(_user, _params), do: {:ok, nil} + + defp resolve_report(admin, %Activity{id: id}) do with {:ok, activity} <- CommonAPI.update_report_state(id, "resolved"), report <- Activity.get_by_id_with_user_actor(activity.id) do ModerationLog.insert_log(%{ @@ -260,9 +296,10 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do subject: activity, subject_actor: report.user_actor }) + + :ok end end - defp resolve_report(_admin, _params) do - end + defp resolve_report(_admin, nil), do: :ok end diff --git a/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex index 5dbf19aba..062b90fc6 100644 --- a/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex +++ b/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex @@ -26,6 +26,13 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do plug(OAuthScopesPlug, %{scopes: ["admin:write:reports"]} when action in [:resolve, :reopen]) + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:reports"]} when action in [:assign_to_self, :unassign] + ) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation def index(conn, params) do @@ -37,6 +44,7 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do |> Map.put(:total, true) |> restrict_state(params) |> restrict_actor(params) + |> restrict_target(params) reports = ActivityPub.fetch_activities_query([], opts) @@ -67,6 +75,9 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do render(conn, "show.json", Report.extract_report_info(report)) else + {:error, :not_found} -> + {:error, :not_found} + {:error, error} -> json_response(conn, :bad_request, %{error: error}) end @@ -84,6 +95,9 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do render(conn, "show.json", Report.extract_report_info(report)) else + {:error, :not_found} -> + {:error, :not_found} + {:error, error} -> json_response(conn, :bad_request, %{error: error}) end @@ -102,6 +116,9 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do render(conn, "show.json", Report.extract_report_info(report)) else + {:error, :not_found} -> + {:error, :not_found} + {:error, error} -> json_response(conn, :bad_request, %{error: error}) end @@ -119,14 +136,17 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do render(conn, "show.json", Report.extract_report_info(report)) else + {:error, :not_found} -> + {:error, :not_found} + {:error, error} -> json_response(conn, :bad_request, %{error: error}) end end - defp restrict_state(opts, %{resolved: true}), do: Map.put(opts, :state, "resolved") + defp restrict_state(opts, %{resolved: true, unresolved: true}), do: opts - defp restrict_state(opts, %{resolved: _}), do: Map.put(opts, :state, "resolved") + defp restrict_state(opts, %{resolved: true}), do: Map.put(opts, :state, "resolved") defp restrict_state(opts, _params), do: Map.put(opts, :state, "open") @@ -139,4 +159,13 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do end defp restrict_actor(opts, _params), do: opts + + defp restrict_target(opts, %{target_account_id: actor}) do + case User.get_by_id(actor) do + %User{ap_id: ap_id} -> Map.put(opts, :report_target_id, ap_id) + _ -> Map.put(opts, :report_target_id, actor) + end + end + + defp restrict_target(opts, _params), do: opts end diff --git a/lib/pleroma/web/mastodon_api/admin/views/report_view.ex b/lib/pleroma/web/mastodon_api/admin/views/report_view.ex index 664f58b03..088824d03 100644 --- a/lib/pleroma/web/mastodon_api/admin/views/report_view.ex +++ b/lib/pleroma/web/mastodon_api/admin/views/report_view.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportView do use Pleroma.Web, :view alias Pleroma.HTML + alias Pleroma.Rule alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.Admin.AccountView @@ -21,9 +22,12 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportView do report: report, user: account, account: target_account, - statuses: statuses + statuses: statuses, + assigned_account: assigned_account }) do created_at = Utils.to_masto_date(report.data["published"]) + updated_at = Utils.to_masto_date(report.updated_at) + action_taken = report.data["state"] != "open" content = unless is_nil(report.data["content"]) do @@ -34,23 +38,34 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportView do %{ id: report.id, - action_taken: report.data["state"] != "open", - action_taken_at: nil, + action_taken: action_taken, + action_taken_at: if(action_taken, do: updated_at), category: "other", comment: content, - forwarded: nil, + forwarded: false, created_at: created_at, - updated_at: created_at, + updated_at: updated_at, account: AccountView.render("show.json", %{user: account}), target_account: AccountView.render("show.json", %{user: target_account}), - assigned_account: nil, + assigned_account: render_account(assigned_account), action_taken_by_account: nil, statuses: StatusView.render("index.json", %{ activities: statuses, as: :activity }), - rules: [] + rules: rules(report.data["rules"]) } end + + defp render_account(nil), do: nil + defp render_account(account), do: AccountView.render("show.json", %{user: account}) + + defp rules(nil), do: [] + + defp rules(rule_ids) do + rule_ids + |> Rule.get() + |> Enum.map(&%{id: to_string(&1.id), text: &1.text}) + end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index aa0ec4817..27229ed41 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -137,6 +137,12 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_activation_state) end + pipeline :require_privileged_role_account_actions do + plug(:admin_api) + plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_activation_state) + plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :reports_manage_reports) + end + pipeline :require_privileged_role_users_manage_invites do plug(:admin_api) plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_invites) @@ -443,10 +449,15 @@ defmodule Pleroma.Web.Router do scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do pipe_through([:require_privileged_role_users_manage_activation_state]) - post("/accounts/:id/action", AccountController, :account_action) post("/accounts/:id/enable", AccountController, :enable) end + scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do + pipe_through([:require_privileged_role_account_actions]) + + post("/accounts/:id/action", AccountController, :account_action) + end + # Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role) scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do pipe_through([:require_privileged_role_users_manage_invites]) @@ -462,6 +473,8 @@ defmodule Pleroma.Web.Router do get("/reports/:id", ReportController, :show) post("/reports/:id/resolve", ReportController, :resolve) post("/reports/:id/reopen", ReportController, :reopen) + post("/reports/:id/assign_to_self", ReportController, :assign_to_self) + post("/reports/:id/unassign", ReportController, :unassign) end # Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role) diff --git a/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs index 113b242dd..38f1189cd 100644 --- a/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs @@ -183,6 +183,20 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountControllerTest do assert %{is_active: false} = user end + test "does not disable a remote account", %{conn: conn} do + %{id: id} = user = insert(:user, local: false) + + assert %{"error" => "Only local accounts can be disabled"} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/admin/accounts/#{id}/action", %{ + "type" => "disable" + }) + |> json_response_and_validate_schema(400) + + assert %{is_active: true} = Repo.reload!(user) + end + test "perform action with assigned report", %{conn: conn} do [reporter, target_user] = insert_pair(:user) @@ -191,11 +205,9 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountControllerTest do account_id: target_user.id }) - %{id: id} = insert(:user) - conn |> put_req_header("content-type", "application/json") - |> post("/api/v1/admin/accounts/#{id}/action", %{ + |> post("/api/v1/admin/accounts/#{target_user.id}/action", %{ "type" => "none", "report_id" => report_id }) @@ -205,6 +217,56 @@ defmodule Pleroma.Web.MastodonAPI.Admin.AccountControllerTest do assert %{data: %{"state" => "resolved"}} = report end + + test "requires the report management privilege", %{conn: conn} do + clear_config([:instance, :admin_privileges], [:users_manage_activation_state]) + + %{id: id} = insert(:user) + + assert %{"error" => _} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/admin/accounts/#{id}/action", %{"type" => "none"}) + |> json_response(403) + end + + test "does not resolve a report for another account", %{conn: conn} do + [reporter, target_user, other_user] = insert_list(3, :user) + + {:ok, report} = + CommonAPI.report(reporter, %{ + account_id: target_user.id + }) + + assert %{"error" => _} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/admin/accounts/#{other_user.id}/action", %{ + "type" => "none", + "report_id" => report.id + }) + |> json_response_and_validate_schema(400) + + assert %{data: %{"state" => "open"}} = Repo.reload!(report) + end + + test "requires an action type", %{conn: conn} do + %{id: id} = insert(:user) + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/admin/accounts/#{id}/action", %{}) + |> json_response_and_validate_schema(400) + end + + test "rejects unsupported action types", %{conn: conn} do + %{id: id} = insert(:user) + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/admin/accounts/#{id}/action", %{"type" => "sensitive"}) + |> json_response_and_validate_schema(400) + end end describe "POST /api/v1/admin/accounts/:id/enable" do diff --git a/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs b/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs index 702dcf1db..2ea5e9d92 100644 --- a/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs +++ b/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs @@ -8,6 +8,9 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do import Pleroma.Factory + alias Pleroma.Activity + alias Pleroma.Repo + alias Pleroma.Rule alias Pleroma.Web.CommonAPI setup_all do @@ -58,6 +61,11 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do |> get("/api/v1/admin/reports") |> json_response_and_validate_schema(200) + assert [%{"id" => ^report_id1}] = + conn + |> get("/api/v1/admin/reports?resolved=false") + |> json_response_and_validate_schema(200) + assert [%{"id" => ^report_id2}, %{"id" => ^report_id1}] = conn |> get("/api/v1/admin/reports?resolved=true&unresolved=true") @@ -68,6 +76,25 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do |> get("/api/v1/admin/reports?resolved=true") |> json_response_and_validate_schema(200) end + + test "get reports by target account", %{conn: conn} do + [reporter, target_user, other_target] = insert_list(3, :user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id + }) + + {:ok, _report} = + CommonAPI.report(reporter, %{ + account_id: other_target.id + }) + + assert [%{"id" => ^report_id}] = + conn + |> get("/api/v1/admin/reports?target_account_id=#{target_user.id}") + |> json_response_and_validate_schema(200) + end end describe "GET /api/v1/admin/reports/:id" do @@ -86,6 +113,34 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do |> get("/api/v1/admin/reports/#{report_id}") |> json_response_and_validate_schema(200) end + + test "returns report rules", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + rule = Rule.create(%{text: "Be excellent to each other"}) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + rule_ids: [rule.id] + }) + + assert %{ + "rules" => [ + %{"id" => rule_id, "text" => "Be excellent to each other"} + ] + } = + conn + |> get("/api/v1/admin/reports/#{report_id}") + |> json_response_and_validate_schema(200) + + assert rule_id == to_string(rule.id) + end + + test "returns 404 for a missing report", %{conn: conn} do + conn + |> get("/api/v1/admin/reports/0") + |> json_response_and_validate_schema(404) + end end describe "POST /api/v1/admin/reports/:id/resolve" do @@ -104,6 +159,16 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do |> post("/api/v1/admin/reports/#{report_id}/resolve") |> json_response_and_validate_schema(200) end + + test "does not mutate a non-report activity", %{conn: conn} do + activity = insert(:note_activity) + + conn + |> post("/api/v1/admin/reports/#{activity.id}/resolve") + |> json_response_and_validate_schema(404) + + refute Repo.reload!(activity).data["state"] + end end describe "POST /api/v1/admin/reports/:id/reopen" do @@ -124,5 +189,51 @@ defmodule Pleroma.Web.MastodonAPI.Admin.ReportControllerTest do |> post("/api/v1/admin/reports/#{report_id}/reopen") |> json_response_and_validate_schema(200) end + + test "does not mutate a non-report activity", %{conn: conn} do + activity = insert(:note_activity) + + conn + |> post("/api/v1/admin/reports/#{activity.id}/reopen") + |> json_response_and_validate_schema(404) + + refute Repo.reload!(activity).data["state"] + end + end + + describe "POST /api/v1/admin/reports/:id/assign_to_self" do + test "assign and unassign a report", %{admin: admin, conn: conn} do + [reporter, target_user] = insert_pair(:user) + + {:ok, report} = + CommonAPI.report(reporter, %{ + account_id: target_user.id + }) + + assert %{"assigned_account" => %{"id" => admin_id}} = + conn + |> post("/api/v1/admin/reports/#{report.id}/assign_to_self") + |> json_response_and_validate_schema(200) + + assert admin_id == admin.id + assert Repo.reload!(report).data["assigned_account"] == admin.id + + assert %{"assigned_account" => nil} = + conn + |> post("/api/v1/admin/reports/#{report.id}/unassign") + |> json_response_and_validate_schema(200) + + refute Repo.reload!(report).data["assigned_account"] + end + + test "does not assign a non-report activity", %{conn: conn} do + %Activity{id: id} = activity = insert(:note_activity) + + conn + |> post("/api/v1/admin/reports/#{id}/assign_to_self") + |> json_response_and_validate_schema(404) + + refute Repo.reload!(activity).data["assigned_account"] + end end end From 55c20c0fe8381529315740f55ed101e31ece263e Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sun, 26 Jul 2026 13:54:28 +0400 Subject: [PATCH 8/8] test(mastodon-api): avoid relationship expiry races --- .../web/mastodon_api/views/account_view_test.exs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/pleroma/web/mastodon_api/views/account_view_test.exs b/test/pleroma/web/mastodon_api/views/account_view_test.exs index f34a801c1..552f95994 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -545,11 +545,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do test "represent a relationship for the blocking and blocked user with expiry" do user = insert(:user) other_user = insert(:user) - date = DateTime.utc_now() |> DateTime.add(24 * 60 * 60) |> DateTime.truncate(:second) - {:ok, user, other_user} = User.follow(user, other_user) {:ok, _subscription} = User.subscribe(user, other_user) - {:ok, _user_relationship} = User.block(user, other_user, %{duration: 24 * 60 * 60}) + {:ok, user_relationship} = User.block(user, other_user, %{duration: 24 * 60 * 60}) {:ok, _user_relationship} = User.block(other_user, user) expected = @@ -558,7 +556,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do %{ following: false, blocking: true, - block_expires_at: date, + block_expires_at: user_relationship.expires_at, blocked_by: true, id: to_string(other_user.id) } @@ -570,17 +568,19 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do test "represent a relationship for the muting user with expiry" do user = insert(:user) other_user = insert(:user) - date = DateTime.utc_now() |> DateTime.add(24 * 60 * 60) |> DateTime.truncate(:second) - {:ok, _user_relationship} = + {:ok, user_relationships} = User.mute(user, other_user, %{notifications: true, duration: 24 * 60 * 60}) + user_relationship = + Enum.find(user_relationships, &(&1.relationship_type == :mute)) + expected = Map.merge( @blank_response, %{ muting: true, - mute_expires_at: date, + mute_expires_at: user_relationship.expires_at, muting_notifications: true, id: to_string(other_user.id) }