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'; } from '@/controllers/api/pleroma.ts';
import { preferencesController } from '@/controllers/api/preferences.ts'; import { preferencesController } from '@/controllers/api/preferences.ts';
import { relayController } from '@/controllers/nostr/relay.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 { searchController } from '@/controllers/api/search.ts';
import { import {
bookmarkController, 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.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController);
app.post('/api/v1/reports', requirePubkey, reportsController); app.post('/api/v1/reports', requirePubkey, reportsController);
app.get('/api/v1/admin/reports', requirePubkey, requireRole('admin'), adminReportsController);
// Not (yet) implemented. // Not (yet) implemented.
app.get('/api/v1/custom_emojis', emptyArrayController); 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 { NSchema as n } from '@nostrify/nostrify';
import { renderReport } from '@/views/mastodon/reports.ts';
import { z } from 'zod'; 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({ const reportsSchema = z.object({
account_id: n.id(), account_id: n.id(),
status_ids: n.id().array().default([]), status_ids: n.id().array().default([]),
comment: z.string().max(1000).default(''), comment: z.string().max(1000).default(''),
forward: z.boolean().default(false),
category: z.string().default('other'), category: z.string().default('other'),
// TODO: rules_ids[] is not implemented // TODO: rules_ids[] is not implemented
}); });
@ -29,7 +30,6 @@ const reportsController: AppController = async (c) => {
account_id, account_id,
status_ids, status_ids,
comment, comment,
forward,
category, category,
} = result.data; } = result.data;
@ -38,16 +38,32 @@ const reportsController: AppController = async (c) => {
await hydrateEvents({ events: [profile], storage: store }); 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({ const event = await createEvent({
kind: 1984, kind: 1984,
content: JSON.stringify({ account_id, status_ids, comment, forward, category }), content: comment,
tags: [ tags,
['p', account_id, category],
['P', Conf.pubkey],
],
}, c); }, c);
return c.json(await renderReport(event, profile)); 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; repost?: DittoEvent;
quote_repost?: DittoEvent; quote_repost?: DittoEvent;
reacted?: 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); 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 = { const stats = {
authors: await gatherAuthorStats(cache), authors: await gatherAuthorStats(cache),
events: await gatherEventStats(cache), events: await gatherEventStats(cache),
@ -69,6 +77,13 @@ function assembleEvents(
event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e)); 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)); 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) { if (event.kind === 6) {
const id = event.tags.find(([name]) => name === 'e')?.[1]; const id = event.tags.find(([name]) => name === 'e')?.[1];
if (id) { if (id) {
@ -83,10 +98,20 @@ function assembleEvents(
} }
} }
if (event.kind === 1) { if (event.kind === 1984) {
const id = event.tags.find(([name]) => name === 'q')?.[1]; const targetAccountId = event.tags.find(([name]) => name === 'p')?.[1];
if (id) { if (targetAccountId) {
event.quote_repost = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e)); 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. */ /** Collect author stats from the events. */
function gatherAuthorStats(events: DittoEvent[]): Promise<DittoTables['author_stats'][]> { function gatherAuthorStats(events: DittoEvent[]): Promise<DittoTables['author_stats'][]> {
const pubkeys = new Set<string>( const pubkeys = new Set<string>(

View File

@ -1,11 +1,11 @@
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { nostrDate } from '@/utils.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) { async function renderAdminAccount(event: DittoEvent) {
const d = event.tags.find(([name]) => name === 'd')?.[1]!; const account = await renderAccount(event);
const account = event.d_author ? await renderAccount({ ...event.d_author, user: event }) : await accountFromPubkey(d);
return { return {
id: account.id, id: account.id,

View File

@ -1,29 +1,67 @@
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { nostrDate } from '@/utils.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 */ /** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */
async function renderReport(reportEvent: DittoEvent, profile: DittoEvent) { async function renderReport(reportEvent: DittoEvent, profile: DittoEvent) {
const { // 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
account_id, const category = reportEvent.tags.find(([name]) => name === 'p')?.[2];
status_ids,
comment, const statusIds = reportEvent.tags.filter(([name]) => name === 'e').map((tag) => tag[1]) ?? [];
forward,
category, const reportedPubkey = reportEvent.tags.find(([name]) => name === 'p')?.[1]!;
} = JSON.parse(reportEvent.content);
return { return {
id: account_id, id: reportEvent.id,
action_taken: false, action_taken: false,
action_taken_at: null, action_taken_at: null,
category, category,
comment, comment: reportEvent.content,
forwarded: forward, forwarded: false,
created_at: nostrDate(reportEvent.created_at).toISOString(), created_at: nostrDate(reportEvent.created_at).toISOString(),
status_ids, status_ids: statusIds,
rules_ids: null, 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 };