diff --git a/src/app.ts b/src/app.ts index ddc902f..80e1288 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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); diff --git a/src/controllers/api/reports.ts b/src/controllers/api/reports.ts index 9e1f933..1e27613 100644 --- a/src/controllers/api/reports.ts +++ b/src/controllers/api/reports.ts @@ -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 }; diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts index 2ef0bb2..32c6e93 100644 --- a/src/interfaces/DittoEvent.ts +++ b/src/interfaces/DittoEvent.ts @@ -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[]; } diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index 61d8285..41670aa 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -42,6 +42,14 @@ async function hydrateEvents(opts: HydrateOpts): Promise { 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 { + const ids = new Set(); + 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 { + const pubkeys = new Set(); + + 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 { const pubkeys = new Set( diff --git a/src/views/mastodon/admin-accounts.ts b/src/views/mastodon/admin-accounts.ts index 7914776..411a655 100644 --- a/src/views/mastodon/admin-accounts.ts +++ b/src/views/mastodon/admin-accounts.ts @@ -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, diff --git a/src/views/mastodon/reports.ts b/src/views/mastodon/reports.ts index 03291f7..ec0d6c7 100644 --- a/src/views/mastodon/reports.ts +++ b/src/views/mastodon/reports.ts @@ -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 };