Run a site
Abuse reports
How people report something, how administrators deal with it, and how an
installation sets the reasons people can choose from. It is all in
abuse-report-service, under /abuse-reports.
Its routes are in the API reference: abuse-report-service.
Reports and cases
Anything can be reported: a person, a group, a post, a reply, a hashtag or a location.
A report is one person's complaint. A case is one reported thing and the decision about it. Everybody who reports the same thing while its case is open joins that case, so a post that twenty people report is one case for an administrator to handle.
A case is open until somebody decides it, and then actioned (something was
done) or dismissed (nothing needed doing). If the same thing is reported
again after its case was closed, a new case opens, and the earlier one shows
as its history.
Reports are not nodes. They are kept in two tables of their own,
abuse_cases and abuse_reports, so that nothing which lists, searches,
federates or exports content can ever show one, and so that a case outlives
the thing it was about.
Reporting something
POST /abuse-reports with { "target_id": 123, "reason_key": "spam", "details": "…" }.
| Answer | Meaning |
|---|---|
| 201 | Reported |
| 200 | You had already reported it. Your report now has the new reason and details |
| 400 | The reason is not on the list or does not apply to this kind of thing, the reason needs an explanation and none was given, the rule is not one of the rules it is under (rule_required, rule_not_applicable), or it is your own |
| 403 | You are not signed in, or the app you use may not write for you (account:write) |
| 404 | There is no such thing that you can see |
| 429 | More than 20 reports in the last hour |
- Signed in only.
- You can only report what you can see. Something hidden from you answers exactly as something that does not exist, so reporting cannot be used to find out what is hidden.
- Not yourself, and not what you wrote.
- One report per person per case. Reporting again changes yours.
- Details are optional, up to 1,000 characters, except where the reason requires them.
The reasons list
GET /abuse-reports/reasons?type=<node type> returns the reasons that can be
given for that kind of thing, in the reader's language (?lang=fr-FR, or the
browser's Accept-Language).
The service carries a default list, in English and French:
Spam · Harassment or bullying · Racism · Sexism · Other hate · Violence or threats · Self-harm or suicide · Sexual content shared without consent · Child safety · Impersonation (people and groups only) · Private information · Misleading or false information · Intellectual property or copyright · Trademark · Illegal content or activity · Something else (an explanation is required)
Using your own reasons
What a community forbids is its own business. To replace the list:
- Copy
abuse-report-service/data/reasons/reasons.jsonand edit it. - Make the file available to the service: a volume in docker-compose, a ConfigMap on Kubernetes.
- Set
REASONS_PATHto where it is mounted.
The file is read again whenever it changes, so an edit takes effect without a restart.
{
"reasons": [
{
"key": "hate_racism",
"label": { "en-GB": "Racism", "fr-FR": "Racisme" },
"description": {
"en-GB": "Attacks or slurs based on race or ethnicity.",
"fr-FR": "Attaques ou injures fondées sur la race ou l’origine ethnique."
},
"applies_to": ["node.actor.", "node.medium."],
"requires_details": false,
"atproto_reason": "com.atproto.moderation.defs#reasonRude"
}
]
}
| Field | Meaning |
|---|---|
key |
What is stored on each report. Up to 64 characters, and unique. Treat it as permanent: reports that used a key you remove show the key in place of a label |
label |
The name, by language. en-GB is required |
description |
A sentence shown under the name. Optional |
applies_to |
The kinds of thing the reason can be given for, as type prefixes: node.actor. (people and groups), node.medium. (posts and replies), node.tag. (hashtags and places). Leave it out for everything |
requires_details |
true makes the reporter explain |
atproto_reason |
The AT Protocol reason this corresponds to. Not used yet; it is there for when reports are sent to other servers |
A file that cannot be used, because it is missing, is not valid JSON, has no
reasons, repeats a key or lacks an en-GB label, is not applied in part. The
built-in list is used until it is fixed, and the service's log says what is
wrong with it.
Breaking a rule
Besides a reason from the list, a report can say which rule was broken: one
of the site's rules (site.json, configuration.md) or
one of the rules of the group or event it was posted in, or of the group or
event itself. The rules are the reasons here, so they are not in the reasons
file.
POST /abuse-reports with { "target_id": 123, "reason_key": "rule", "rule": "Be kind", "rule_profile_id": 456 }.
| Field | Value |
|---|---|
reason_key |
rule |
rule |
The rule's title as the reporter read it, up to 100 characters |
rule_profile_id |
0 for the site's rules, or the group's or event's id |
- A group's or an event's rule is checked: it must be one of its rules
now, and the thing reported must be the group or event, be posted in it,
or be a reply in a thread posted in it. Otherwise
rule_not_applicable. - The site's rules are taken as given. This service does not read
site.json; the administrator reading the report has the rules in front of them. - Stored with the report, in
abuse_reports.rule_titleandrule_profile_id. The case answers withrule,rule_profile_idand the profile itself asrule_profile. - Filtered like any reason:
GET /abuse-reports/cases?reason=rule.
A group's own reports
A group's or an event's administrators see the reports against its rules:
GET /abuse-reports/profiles/:id/cases (?status=open, the default, and
limit, start), in the web app the Reports tab of its settings.
| Answer | Meaning |
|---|---|
| 200 | The cases with a report against its rules, newest first, each with those reports: rule, details and created_at |
| 403 | You administer neither it nor the site |
| 404 | There is no such group or event |
- Nobody's name. Who reported is shown to the site's administrators only.
- Never what is about them. A report on the group or event itself, or on something the viewer is answerable for, is left out: whoever is reported is not told.
- They act where it is. Deleting a post on its page closes its case, as for any deleted post. Deciding a case (Dismiss, Close) stays with the site's administrators, whose case it is.
What administrators do
In the web app, under Administration › Reports. The endpoints:
| Endpoint | Does |
|---|---|
GET /abuse-reports/cases?status=open&reason=spam |
Lists cases, the most recently reported first, 20 at a time (limit, offset). status is open, actioned or dismissed. reason is optional: with it, only the cases in which somebody gave that reason |
GET /abuse-reports/cases/summary |
{ "open": n }, for the number on the Administration menu |
GET /abuse-reports/cases/:id |
The case: what was reported and by whom, each report's reason and details, and the earlier cases the same thing has had |
POST /abuse-reports/cases/:id/resolve |
Closes it: { "status": "actioned" or "dismissed", "action": "…", "note": "…" } |
POST /abuse-reports/cases/:id/reopen |
Opens a closed case again |
The service records decisions. It does not carry them out. Deleting a
post, disabling an account or archiving a group is done through the service
that owns it, under that service's own rules: an administrator who may not
archive a group cannot do it from here either. The app does the action first,
then records the case as actioned with what was done: deleted, disabled,
archived, blocked, removed_as_spam or other. A person or a group
reported can be removed as spam from its case, which is then closed as
removed_as_spam (Removing as spam).
Two administrators deciding the same case at once get one decision recorded;
the second is told it was already decided (409). A closed case cannot be
reopened while a newer case for the same thing is open (409,
newer_case_open): use the newer one. Reopening a case that is open already
is answered 409 with already_open.
Nobody is emailed about a report. Administrators see the number waiting on the Administration menu.
When the reported thing is deleted
There is nothing left to decide about something that no longer exists, so its
open case is closed as actioned, deleted, with nobody named as having
decided it.
- A post or reply is gone the moment it is deleted.
- A person or group is not: deleting one starts a 30-day wait during which it can be restored. Its case stays open until it is purged.
The service hears of this through an event. Events can be lost, so opening a case also checks whether the thing still exists.
When a person who filed reports is purged, their reports are removed with everything else they wrote. A case that other people also reported keeps going with one report fewer. An open case that only they had reported is removed, because no complaint is left to decide. A case already decided stays, as the record of the decision.
This too arrives as an event, and nothing checks again if it is lost: the reports then stay, naming an account that is only a reserved handle.
Who sees what
| Sees | |
|---|---|
| The reporter | That their report was received. Nothing after that |
| The person reported | Nothing. They are not told of the report, or who made it |
| Administrators and super administrators | Every case, every report, and who made it |
The network address a report came from is stored with it and is not shown in the app. It is blanked 90 days after the report was filed; the report itself stays.
Filing a report needs the account:write scope. Reading cases needs
actors:read and deciding them actors:write, on top of being an
administrator.
Where the code is
| What | Where |
|---|---|
| The service | abuse-report-service/ |
| Reporting something | abuse-report-service/cmd/api/handlers/reports.go (Add) |
| The reasons list, and your own | abuse-report-service/data/reasons/reasons.go (BuiltIn, Parse, Source), abuse-report-service/data/reasons/reasons.json, setting REASONS_PATH |
| What administrators do | abuse-report-service/cmd/api/handlers/cases.go, abuse-report-service/cmd/api/permissions/permissions.go |
| Cases and reports | abuse-report-service/data/repositories/cases.go, tables abuse_cases and abuse_reports in migration-job/schema/init.sql |
| When the reported thing is deleted | abuse-report-service/cmd/api/handlers/events.go (Handle) |
| Blanking network addresses after 90 days | abuse-report-service/cmd/api/main.go (forgetOldAddresses) |
| Limits: 20 reports an hour, 1,000 characters | abuse-report-service/constants/constants.go |
| Tests of the rules | abuse-report-service/cmd/api/handlers/handlers_test.go, abuse-report-service/data/repositories/cases_integration_test.go |
| The web app | src/containers/admin/Reports/, src/api/abuseReports.js (anahita-web) |
From anahitasocial/anahita-services, 2026-10-27.
Written by Claude, reviewed by Rastin Mehr · About this