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:
commit
f4c02f1568
|
@ -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);
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 };
|
||||
|
|
Loading…
Reference in New Issue