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