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 768a6f3c2..6678b049a 100644
--- a/lib/pleroma/user.ex
+++ b/lib/pleroma/user.ex
@@ -1970,6 +1970,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..33f19c454 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,19 @@ 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({: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/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.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..3b39cbe2a
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex
@@ -0,0 +1,348 @@
+# 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 (v1)",
+ 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(
+ :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(
+ :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(
+ :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 with 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 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)."
+ ),
+ 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)"],
+ 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"]
+ },
+ 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(),
+ 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
+
+ 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, nullable: true},
+ invite_request: %Schema{type: :string, 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..7e0bcc24b
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex
@@ -0,0 +1,180 @@
+# 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(:unresolved, :query, :boolean, "Filter for open 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"
+ )
+ ] ++
+ 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),
+ 404 => 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),
+ 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",
+ 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", "legal", "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()],
+ nullable: true
+ },
+ action_taken_by_account: %Schema{
+ oneOf: [AccountOperation.account()],
+ nullable: true
+ },
+ statuses: %Schema{
+ type: :array,
+ items: Status
+ },
+ rules: %Schema{
+ type: :array,
+ items: %Schema{
+ type: :object,
+ properties: %{
+ id: %Schema{type: :string},
+ text: %Schema{type: :string}
+ }
+ }
+ }
+ }
+ }
+ end
+end
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
new file mode 100644
index 000000000..b4e038f0e
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex
@@ -0,0 +1,305 @@
+# 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 domain email staff origin status
+ )
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+ plug(
+ OAuthScopesPlug,
+ %{scopes: ["admin:read:accounts"]}
+ when action in [:index, :index2, :show]
+ )
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ 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 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
+
+ def account_action(
+ %{assigns: %{user: admin, account: user}, body_params: %{type: type} = body_params} =
+ conn,
+ _params
+ ) do
+ 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}
+
+ {:error, error} ->
+ json_response(conn, :bad_request, %{error: error})
+ end
+ 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, "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)
+ |> 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_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_staff(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)
+
+ 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_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)
+
+ 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_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)
+
+ 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)
+
+ for filter_param <- @filter_params do
+ defp unquote(:"maybe_filter_#{filter_param}")(criteria, _params), do: criteria
+ end
+
+ 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(%{
+ action: "report_update",
+ actor: admin,
+ subject: activity,
+ subject_actor: report.user_actor
+ })
+
+ :ok
+ end
+ 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
new file mode 100644
index 000000000..062b90fc6
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex
@@ -0,0 +1,171 @@
+# 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])
+
+ 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
+ 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)
+ |> restrict_target(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(report))
+ else
+ {:error, :not_found} ->
+ {:error, :not_found}
+
+ {: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, :not_found} ->
+ {:error, :not_found}
+
+ {:error, error} ->
+ json_response(conn, :bad_request, %{error: error})
+ 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, :not_found} ->
+ {:error, :not_found}
+
+ {: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, :not_found} ->
+ {:error, :not_found}
+
+ {:error, error} ->
+ json_response(conn, :bad_request, %{error: error})
+ end
+ end
+
+ defp restrict_state(opts, %{resolved: true, unresolved: true}), do: opts
+
+ defp restrict_state(opts, %{resolved: true}), do: Map.put(opts, :state, "resolved")
+
+ 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
+ Map.put(opts, :actor_id, ap_id)
+ else
+ _ -> Map.put(opts, :actor_id, actor)
+ end
+ 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/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..088824d03
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/admin/views/report_view.ex
@@ -0,0 +1,71 @@
+# 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.Rule
+ 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,
+ 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
+ HTML.filter_tags(report.data["content"])
+ else
+ nil
+ end
+
+ %{
+ id: report.id,
+ action_taken: action_taken,
+ action_taken_at: if(action_taken, do: updated_at),
+ category: "other",
+ comment: content,
+ forwarded: false,
+ created_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: render_account(assigned_account),
+ action_taken_by_account: nil,
+ statuses:
+ StatusView.render("index.json", %{
+ activities: statuses,
+ as: :activity
+ }),
+ 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 5657bcad7..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)
@@ -424,6 +430,60 @@ 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/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])
+ 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)
+ 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)
+ 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)
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..38f1189cd
--- /dev/null
+++ b/test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs
@@ -0,0 +1,327 @@
+# 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
+
+ # 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} =
+ 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 "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)
+
+ {:ok, %{id: report_id} = report} =
+ CommonAPI.report(reporter, %{
+ account_id: target_user.id
+ })
+
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/v1/admin/accounts/#{target_user.id}/action", %{
+ "type" => "none",
+ "report_id" => report_id
+ })
+ |> json_response_and_validate_schema(204)
+
+ report = Repo.reload!(report)
+
+ 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
+ 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/reject" 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..2ea5e9d92
--- /dev/null
+++ b/test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs
@@ -0,0 +1,239 @@
+# 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.Activity
+ alias Pleroma.Repo
+ alias Pleroma.Rule
+ 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?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_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")
+ |> 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
+
+ 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
+ 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
+
+ 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
+ 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
+
+ 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
+ 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
+
+ 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
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)
}