Merge branch 'mastodon-admin' into 'develop'

MastoAPI: /api/v1/admin/accounts, /api/v1/admin/reports

See merge request pleroma/pleroma!3671
This commit is contained in:
mkljczk 2025-03-20 09:44:27 +00:00
commit 61a55984d1
14 changed files with 1459 additions and 2 deletions

View file

@ -0,0 +1 @@
Implement basics of Mastodon admin API

View file

@ -339,6 +339,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},

View file

@ -1941,6 +1941,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 ->
@ -2615,7 +2621,7 @@ defmodule Pleroma.User do
end
end
# Internal function; public one is `deactivate/2`
# Internal function; public one is `set_activation/2`
defp set_activation_status(user, status) do
user
|> cast(%{is_active: status}, [:is_active])

View file

@ -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

View file

@ -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",

View file

@ -0,0 +1,344 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# 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). (not implemented yet)"
),
Operation.parameter(
:role_ids,
:query,
%Schema{
oneOf: [
%Schema{type: :array, items: %Schema{type: :string}},
%Schema{type: :string}
]
},
"Filter for users with these roles. (not implemented yet)"
),
Operation.parameter(
:invited_by,
:query,
:string,
"Lookup users invited by the account with this ID. (not implemented yet)"
),
Operation.parameter(:username, :query, :string, "Search for the given username"),
Operation.parameter(
:display_name,
:query,
:string,
"Search for the given display name"
),
Operation.parameter(
:by_domain,
:query,
:string,
"Filter by the given domain"
),
Operation.parameter(:email, :query, :string, "Lookup a user with this email"),
Operation.parameter(
:ip,
:query,
:string,
"Lookup users with this IP address (not implemented yet)"
)
] ++
pagination_params(),
responses: %{
200 =>
Operation.response("Account", "application/json", %Schema{
title: "ArrayOfAccounts",
type: :array,
items: account()
}),
401 => Operation.response("Error", "application/json", ApiError)
}
}
end
def show_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
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

View file

@ -0,0 +1,146 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# 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{
type: AccountOperation.account(),
nullable: true
},
action_taken_by_account: %Schema{
type: AccountOperation.account(),
nullable: true
},
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

View file

@ -0,0 +1,264 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# 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]
)
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
{: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_domain(params)
|> maybe_filter_email(params)
|> maybe_filter_staff(params)
end
defp build_criteria_v2(params) do
%{}
|> maybe_filter_origin(params)
|> maybe_filter_status(params)
|> maybe_filter_nickname(params)
|> maybe_filter_name(params)
|> maybe_filter_domain(params)
|> maybe_filter_email(params)
end
defp maybe_filter_local(criteria, %{local: true} = _params),
do: Map.put(criteria, :local, true)
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, %{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

View file

@ -0,0 +1,107 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# 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

View file

@ -0,0 +1,64 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# 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

View file

@ -0,0 +1,56 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# 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

View file

@ -416,6 +416,53 @@ 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
# 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)

View file

@ -0,0 +1,265 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# 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{<.+?(?<link>/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 "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

View file

@ -0,0 +1,118 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# 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