Merge branch 'admin-reports-api' into 'main'

Implement Mastodon API - view information about all reports

See merge request soapbox-pub/ditto!221
This commit is contained in:
Alex Gleason 2024-05-06 15:41:24 +00:00
commit f4c02f1568
6 changed files with 162 additions and 34 deletions

View File

@ -44,7 +44,7 @@ import {
} from '@/controllers/api/pleroma.ts';
import { preferencesController } from '@/controllers/api/preferences.ts';
import { relayController } from '@/controllers/nostr/relay.ts';
import { reportsController } from '@/controllers/api/reports.ts';
import { adminReportsController, reportsController } from '@/controllers/api/reports.ts';
import { searchController } from '@/controllers/api/search.ts';
import {
bookmarkController,
@ -208,6 +208,7 @@ app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysControlle
app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController);
app.post('/api/v1/reports', requirePubkey, reportsController);
app.get('/api/v1/admin/reports', requirePubkey, requireRole('admin'), adminReportsController);
// Not (yet) implemented.
app.get('/api/v1/custom_emojis', emptyArrayController);

View File

@ -1,16 +1,17 @@
import { type AppController } from '@/app.ts';
import { createEvent, parseBody } from '@/utils/api.ts';
import { Conf } from '@/config.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { NSchema as n } from '@nostrify/nostrify';
import { renderReport } from '@/views/mastodon/reports.ts';
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { createEvent, parseBody } from '@/utils/api.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { renderAdminReport } from '@/views/mastodon/reports.ts';
import { renderReport } from '@/views/mastodon/reports.ts';
const reportsSchema = z.object({
account_id: n.id(),
status_ids: n.id().array().default([]),
comment: z.string().max(1000).default(''),
forward: z.boolean().default(false),
category: z.string().default('other'),
// TODO: rules_ids[] is not implemented
});
@ -29,7 +30,6 @@ const reportsController: AppController = async (c) => {
account_id,
status_ids,
comment,
forward,
category,
} = result.data;
@ -38,16 +38,32 @@ const reportsController: AppController = async (c) => {
await hydrateEvents({ events: [profile], storage: store });
}
const tags = [
['p', account_id, category],
['P', Conf.pubkey],
];
for (const status of status_ids) {
tags.push(['e', status, category]);
}
const event = await createEvent({
kind: 1984,
content: JSON.stringify({ account_id, status_ids, comment, forward, category }),
tags: [
['p', account_id, category],
['P', Conf.pubkey],
],
content: comment,
tags,
}, c);
return c.json(await renderReport(event, profile));
};
export { reportsController };
/** https://docs.joinmastodon.org/methods/admin/reports/#get */
const adminReportsController: AppController = async (c) => {
const store = c.get('store');
const reports = await store.query([{ kinds: [1984], '#P': [Conf.pubkey] }])
.then((events) => hydrateEvents({ storage: store, events: events, signal: c.req.raw.signal }))
.then((events) => Promise.all(events.map((event) => renderAdminReport(event, { viewerPubkey: c.get('pubkey') }))));
return c.json(reports);
};
export { adminReportsController, reportsController };

View File

@ -25,4 +25,13 @@ export interface DittoEvent extends NostrEvent {
repost?: DittoEvent;
quote_repost?: DittoEvent;
reacted?: DittoEvent;
/** The profile being reported.
* Must be a kind 0 hydrated.
* https://github.com/nostr-protocol/nips/blob/master/56.md
*/
reported_profile?: DittoEvent;
/** The notes being reported.
* https://github.com/nostr-protocol/nips/blob/master/56.md
*/
reported_notes?: DittoEvent[];
}

View File

@ -42,6 +42,14 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
cache.push(event);
}
for (const event of await gatherReportedProfiles({ events: cache, storage, signal })) {
cache.push(event);
}
for (const event of await gatherReportedNotes({ events: cache, storage, signal })) {
cache.push(event);
}
const stats = {
authors: await gatherAuthorStats(cache),
events: await gatherEventStats(cache),
@ -69,6 +77,13 @@ function assembleEvents(
event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e));
event.user = b.find((e) => matchFilter({ kinds: [30361], authors: [admin], '#d': [event.pubkey] }, e));
if (event.kind === 1) {
const id = event.tags.find(([name]) => name === 'q')?.[1];
if (id) {
event.quote_repost = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e));
}
}
if (event.kind === 6) {
const id = event.tags.find(([name]) => name === 'e')?.[1];
if (id) {
@ -83,10 +98,20 @@ function assembleEvents(
}
}
if (event.kind === 1) {
const id = event.tags.find(([name]) => name === 'q')?.[1];
if (id) {
event.quote_repost = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e));
if (event.kind === 1984) {
const targetAccountId = event.tags.find(([name]) => name === 'p')?.[1];
if (targetAccountId) {
event.reported_profile = b.find((e) => matchFilter({ kinds: [0], authors: [targetAccountId] }, e));
}
const reportedEvents: DittoEvent[] = [];
const status_ids = event.tags.filter(([name]) => name === 'e').map((tag) => tag[1]);
if (status_ids.length > 0) {
for (const id of status_ids) {
const reportedEvent = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e));
if (reportedEvent) reportedEvents.push(reportedEvent);
}
event.reported_notes = reportedEvents;
}
}
@ -174,6 +199,45 @@ function gatherUsers({ events, storage, signal }: HydrateOpts): Promise<DittoEve
);
}
/** Collect reported notes from the events. */
function gatherReportedNotes({ events, storage, signal }: HydrateOpts): Promise<DittoEvent[]> {
const ids = new Set<string>();
for (const event of events) {
if (event.kind === 1984) {
const status_ids = event.tags.filter(([name]) => name === 'e').map((tag) => tag[1]);
if (status_ids.length > 0) {
for (const id of status_ids) {
ids.add(id);
}
}
}
}
return storage.query(
[{ kinds: [1], ids: [...ids], limit: ids.size }],
{ signal },
);
}
/** Collect reported profiles from the events. */
function gatherReportedProfiles({ events, storage, signal }: HydrateOpts): Promise<DittoEvent[]> {
const pubkeys = new Set<string>();
for (const event of events) {
if (event.kind === 1984) {
const pubkey = event.tags.find(([name]) => name === 'p')?.[1];
if (pubkey) {
pubkeys.add(pubkey);
}
}
}
return storage.query(
[{ kinds: [0], authors: [...pubkeys], limit: pubkeys.size }],
{ signal },
);
}
/** Collect author stats from the events. */
function gatherAuthorStats(events: DittoEvent[]): Promise<DittoTables['author_stats'][]> {
const pubkeys = new Set<string>(

View File

@ -1,11 +1,11 @@
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { nostrDate } from '@/utils.ts';
import { accountFromPubkey, renderAccount } from './accounts.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts';
/** Expects a kind 0 fully hydrated or a kind 30361 hydrated with `d_author` */
async function renderAdminAccount(event: DittoEvent) {
const d = event.tags.find(([name]) => name === 'd')?.[1]!;
const account = event.d_author ? await renderAccount({ ...event.d_author, user: event }) : await accountFromPubkey(d);
const account = await renderAccount(event);
return {
id: account.id,

View File

@ -1,29 +1,67 @@
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { nostrDate } from '@/utils.ts';
import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
/** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */
async function renderReport(reportEvent: DittoEvent, profile: DittoEvent) {
const {
account_id,
status_ids,
comment,
forward,
category,
} = JSON.parse(reportEvent.content);
// The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag
const category = reportEvent.tags.find(([name]) => name === 'p')?.[2];
const statusIds = reportEvent.tags.filter(([name]) => name === 'e').map((tag) => tag[1]) ?? [];
const reportedPubkey = reportEvent.tags.find(([name]) => name === 'p')?.[1]!;
return {
id: account_id,
id: reportEvent.id,
action_taken: false,
action_taken_at: null,
category,
comment,
forwarded: forward,
comment: reportEvent.content,
forwarded: false,
created_at: nostrDate(reportEvent.created_at).toISOString(),
status_ids,
status_ids: statusIds,
rules_ids: null,
target_account: profile ? await renderAccount(profile) : await accountFromPubkey(account_id),
target_account: profile ? await renderAccount(profile) : await accountFromPubkey(reportedPubkey),
};
}
export { renderReport };
interface RenderAdminReportOpts {
viewerPubkey?: string;
}
/** Admin-level information about a filed report.
* Expects an event of kind 1984 fully hydrated.
* https://docs.joinmastodon.org/entities/Admin_Report */
async function renderAdminReport(reportEvent: DittoEvent, opts: RenderAdminReportOpts) {
const { viewerPubkey } = opts;
// The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag
const category = reportEvent.tags.find(([name]) => name === 'p')?.[2];
const statuses = [];
if (reportEvent.reported_notes) {
for (const status of reportEvent.reported_notes) {
statuses.push(await renderStatus(status, { viewerPubkey }));
}
}
return {
id: reportEvent.id,
action_taken: false,
action_taken_at: null,
category,
comment: reportEvent.content,
forwarded: false,
created_at: nostrDate(reportEvent.created_at).toISOString(),
account: await renderAdminAccount(reportEvent.author as DittoEvent),
target_account: await renderAdminAccount(reportEvent.reported_profile as DittoEvent),
assigned_account: null,
action_taken_by_account: null,
statuses,
rule: [],
};
}
export { renderAdminReport, renderReport };