diff --git a/src/app.ts b/src/app.ts index f9ea03a..19641d4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -30,7 +30,7 @@ import { adminAccountsController, adminActionController } from '@/controllers/ap import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts'; import { blocksController } from '@/controllers/api/blocks.ts'; import { bookmarksController } from '@/controllers/api/bookmarks.ts'; -import { adminRelaysController, adminSetRelaysController } from '@/controllers/api/ditto.ts'; +import { adminRelaysController, adminSetRelaysController, nameRequestController } from '@/controllers/api/ditto.ts'; import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts'; import { instanceController } from '@/controllers/api/instance.ts'; import { markersController, updateMarkersController } from '@/controllers/api/markers.ts'; @@ -243,6 +243,7 @@ app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAd app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysController); app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController); +app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.post('/api/v1/ditto/zap', requireSigner, zapController); app.post('/api/v1/reports', requireSigner, reportController); diff --git a/src/controllers/api/admin.ts b/src/controllers/api/admin.ts index 1b5794c..9da15bc 100644 --- a/src/controllers/api/admin.ts +++ b/src/controllers/api/admin.ts @@ -1,12 +1,13 @@ +import { NostrEvent } from '@nostrify/nostrify'; import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { paginated, paginationSchema, parseBody, updateUser } from '@/utils/api.ts'; -import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts'; +import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; const adminAccountQuerySchema = z.object({ local: booleanParamSchema.optional(), @@ -35,25 +36,51 @@ const adminAccountsController: AppController = async (c) => { } = adminAccountQuerySchema.parse(c.req.query()); // Not supported. - if (pending || disabled || silenced || suspended || sensitized) { + if (disabled || silenced || suspended || sensitized) { return c.json([]); } const store = await Storages.db(); - const { since, until, limit } = paginationSchema.parse(c.req.query()); + const params = paginationSchema.parse(c.req.query()); const { signal } = c.req.raw; - const events = await store.query([{ kinds: [30382], authors: [Conf.pubkey], since, until, limit }], { signal }); - const pubkeys = events.map((event) => event.tags.find(([name]) => name === 'd')?.[1]!); - const authors = await store.query([{ kinds: [0], authors: pubkeys }], { signal }); + const pubkeys = new Set(); + const events: NostrEvent[] = []; - for (const event of events) { - const d = event.tags.find(([name]) => name === 'd')?.[1]; - (event as DittoEvent).d_author = authors.find((author) => author.pubkey === d); + if (pending) { + for (const event of await store.query([{ kinds: [3036], '#p': [Conf.pubkey], ...params }], { signal })) { + pubkeys.add(event.pubkey); + events.push(event); + } + } else { + for (const event of await store.query([{ kinds: [30360], authors: [Conf.pubkey], ...params }], { signal })) { + const pubkey = event.tags.find(([name]) => name === 'd')?.[1]; + if (pubkey) { + pubkeys.add(pubkey); + events.push(event); + } + } } + const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }], { signal }) + .then((events) => hydrateEvents({ store, events, signal })); + const accounts = await Promise.all( - events.map((event) => renderAdminAccount(event)), + [...pubkeys].map(async (pubkey) => { + const author = authors.find((event) => event.pubkey === pubkey); + const account = author ? await renderAdminAccount(author) : await renderAdminAccountFromPubkey(pubkey); + const request = events.find((event) => event.kind === 3036 && event.pubkey === pubkey); + const grant = events.find( + (event) => event.kind === 30360 && event.tags.find(([name]) => name === 'd')?.[1] === pubkey, + ); + + return { + ...account, + invite_request: request?.content ?? null, + invite_request_username: request?.tags.find(([name]) => name === 'r')?.[1] ?? null, + approved: !!grant, + }; + }), ); return paginated(c, events, accounts); diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index df4f210..4cf2cd4 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -3,8 +3,11 @@ import { z } from 'zod'; import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { Storages } from '@/storages.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; +import { Storages } from '@/storages.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; +import { createEvent } from '@/utils/api.ts'; +import { renderNameRequest } from '@/views/ditto.ts'; const markerSchema = z.enum(['read', 'write']); @@ -58,3 +61,40 @@ function renderRelays(event: NostrEvent): RelayEntity[] { return acc; }, [] as RelayEntity[]); } + +const nameRequestSchema = z.object({ + nip05: z.string().email(), + reason: z.string().max(500).optional(), +}); + +export const nameRequestController: AppController = async (c) => { + const { nip05, reason } = nameRequestSchema.parse(await c.req.json()); + + const event = await createEvent({ + kind: 3036, + content: reason, + tags: [ + ['r', nip05], + ['L', 'nip05.domain'], + ['l', nip05.split('@')[1], 'nip05.domain'], + ['p', Conf.pubkey], + ], + }, c); + + await hydrateEvents({ events: [event], store: await Storages.db() }); + + const nameRequest = await renderNameRequest(event); + return c.json(nameRequest); +}; + +export const nameRequestsController: AppController = async (c) => { + const store = await Storages.db(); + const signer = c.get('signer')!; + const pubkey = await signer.getPublicKey(); + + const events = await store.query([{ kinds: [3036], authors: [pubkey], limit: 20 }]) + .then((events) => hydrateEvents({ events, store })); + + const nameRequests = await Promise.all(events.map(renderNameRequest)); + return c.json(nameRequests); +}; diff --git a/src/controllers/api/reports.ts b/src/controllers/api/reports.ts index 9cb2627..2092b8a 100644 --- a/src/controllers/api/reports.ts +++ b/src/controllers/api/reports.ts @@ -1,12 +1,14 @@ -import { NSchema as n } from '@nostrify/nostrify'; +import { NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { createAdminEvent, createEvent, parseBody } from '@/utils/api.ts'; +import { createEvent, paginationSchema, parseBody, updateEventInfo } from '@/utils/api.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { renderAdminReport } from '@/views/mastodon/reports.ts'; import { renderReport } from '@/views/mastodon/reports.ts'; +import { getTagSet } from '@/utils/tags.ts'; +import { booleanParamSchema } from '@/schema.ts'; const reportSchema = z.object({ account_id: n.id(), @@ -52,18 +54,60 @@ const reportController: AppController = async (c) => { return c.json(await renderReport(event)); }; +const adminReportsSchema = z.object({ + resolved: booleanParamSchema.optional(), + account_id: n.id().optional(), + target_account_id: n.id().optional(), +}); + /** https://docs.joinmastodon.org/methods/admin/reports/#get */ const adminReportsController: AppController = async (c) => { const store = c.get('store'); const viewerPubkey = await c.get('signer')?.getPublicKey(); - const reports = await store.query([{ kinds: [1984], '#P': [Conf.pubkey] }]) - .then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })) - .then((events) => - Promise.all( - events.map((event) => renderAdminReport(event, { viewerPubkey })), - ) - ); + const params = paginationSchema.parse(c.req.query()); + const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query()); + + const filter: NostrFilter = { + kinds: [30383], + authors: [Conf.pubkey], + ...params, + }; + + if (typeof resolved === 'boolean') { + filter['#n'] = [resolved ? 'closed' : 'open']; + } + if (account_id) { + filter['#p'] = [account_id]; + } + if (target_account_id) { + filter['#P'] = [target_account_id]; + } + + const orig = await store.query([filter]); + const ids = new Set(); + + for (const event of orig) { + const d = event.tags.find(([name]) => name === 'd')?.[1]; + if (d) { + ids.add(d); + } + } + + const events = await store.query([{ kinds: [1984], ids: [...ids] }]) + .then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })); + + const reports = await Promise.all( + events.map((event) => { + const internal = orig.find(({ tags }) => tags.some(([name, value]) => name === 'd' && value === event.id)); + const names = getTagSet(internal?.tags ?? [], 'n'); + + return renderAdminReport(event, { + viewerPubkey, + actionTaken: names.has('closed'), + }); + }), + ); return c.json(reports); }; @@ -82,12 +126,13 @@ const adminReportController: AppController = async (c) => { }], { signal }); if (!event) { - return c.json({ error: 'This action is not allowed' }, 403); + return c.json({ error: 'Not found' }, 404); } await hydrateEvents({ events: [event], store, signal }); - return c.json(await renderAdminReport(event, { viewerPubkey: pubkey })); + const report = await renderAdminReport(event, { viewerPubkey: pubkey }); + return c.json(report); }; /** https://docs.joinmastodon.org/methods/admin/reports/#resolve */ @@ -104,18 +149,15 @@ const adminReportResolveController: AppController = async (c) => { }], { signal }); if (!event) { - return c.json({ error: 'This action is not allowed' }, 403); + return c.json({ error: 'Not found' }, 404); } + await updateEventInfo(eventId, { open: false, closed: true }, c); + await hydrateEvents({ events: [event], store, signal }); - await createAdminEvent({ - kind: 5, - tags: [['e', event.id]], - content: 'Report closed.', - }, c); - - return c.json(await renderAdminReport(event, { viewerPubkey: pubkey, actionTaken: true })); + const report = await renderAdminReport(event, { viewerPubkey: pubkey, actionTaken: true }); + return c.json(report); }; export { adminReportController, adminReportResolveController, adminReportsController, reportController }; diff --git a/src/controllers/well-known/nostr.ts b/src/controllers/well-known/nostr.ts index 0669888..b6b7af0 100644 --- a/src/controllers/well-known/nostr.ts +++ b/src/controllers/well-known/nostr.ts @@ -10,9 +10,12 @@ const nameSchema = z.string().min(1).regex(/^\w+$/); * https://github.com/nostr-protocol/nips/blob/master/05.md */ const nostrController: AppController = async (c) => { + const store = c.get('store'); + const result = nameSchema.safeParse(c.req.query('name')); const name = result.success ? result.data : undefined; - const pointer = name ? await localNip05Lookup(c.get('store'), name) : undefined; + + const pointer = name ? await localNip05Lookup(store, name) : undefined; if (!name || !pointer) { return c.json({ names: {}, relays: {} }); diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts index 6f3e1d2..85e11d6 100644 --- a/src/interfaces/DittoEvent.ts +++ b/src/interfaces/DittoEvent.ts @@ -21,7 +21,6 @@ export interface DittoEvent extends NostrEvent { author_domain?: string; author_stats?: AuthorStats; event_stats?: EventStats; - d_author?: DittoEvent; user?: DittoEvent; repost?: DittoEvent; quote?: DittoEvent; @@ -35,4 +34,6 @@ export interface DittoEvent extends NostrEvent { * https://github.com/nostr-protocol/nips/blob/master/56.md */ reported_notes?: DittoEvent[]; + /** Admin event relationship. */ + info?: DittoEvent; } diff --git a/src/pipeline.ts b/src/pipeline.ts index f59a1eb..20fed92 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -3,10 +3,12 @@ import Debug from '@soapbox/stickynotes/debug'; import { sql } from 'kysely'; import { LRUCache } from 'lru-cache'; +import { Conf } from '@/config.ts'; import { DittoDB } from '@/db/DittoDB.ts'; import { deleteAttachedMedia } from '@/db/unattached-media.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { RelayError } from '@/RelayError.ts'; +import { AdminSigner } from '@/signers/AdminSigner.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; import { eventAge, parseNip05, Time } from '@/utils.ts'; @@ -53,6 +55,7 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise { } } +async function generateSetEvents(event: NostrEvent): Promise { + const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === Conf.pubkey); + + if (event.kind === 1984 && tagsAdmin) { + const signer = new AdminSigner(); + + const rel = await signer.signEvent({ + kind: 30383, + content: '', + tags: [ + ['d', event.id], + ['p', event.pubkey], + ['k', '1984'], + ['n', 'open'], + ...[...getTagSet(event.tags, 'p')].map((pubkey) => ['P', pubkey]), + ...[...getTagSet(event.tags, 'e')].map((pubkey) => ['e', pubkey]), + ], + created_at: Math.floor(Date.now() / 1000), + }); + + await handleEvent(rel, AbortSignal.timeout(1000)); + } + + if (event.kind === 3036 && tagsAdmin) { + const signer = new AdminSigner(); + + const rel = await signer.signEvent({ + kind: 30383, + content: '', + tags: [ + ['d', event.id], + ['p', event.pubkey], + ['k', '3036'], + ['n', 'pending'], + ], + created_at: Math.floor(Date.now() / 1000), + }); + + await handleEvent(rel, AbortSignal.timeout(1000)); + } +} + export { handleEvent }; diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts index 8dcee6c..6a94954 100644 --- a/src/storages/EventsDB.ts +++ b/src/storages/EventsDB.ts @@ -29,9 +29,9 @@ class EventsDB implements NStore { 'a': ({ count }) => count < 15, 'd': ({ event, count }) => count === 0 && NKinds.parameterizedReplaceable(event.kind), 'e': ({ event, count, value }) => ((event.kind === 10003) || count < 15) && isNostrId(value), + 'k': ({ count, value }) => count === 0 && Number.isInteger(Number(value)), 'L': ({ event, count }) => event.kind === 1985 || count === 0, 'l': ({ event, count }) => event.kind === 1985 || count === 0, - 'media': ({ count, value }) => (count < 4) && isURL(value), 'n': ({ count, value }) => count < 50 && value.length < 50, 'P': ({ count, value }) => count === 0 && isNostrId(value), 'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value), diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index ded03d4..a3821aa 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -44,6 +44,10 @@ async function hydrateEvents(opts: HydrateOpts): Promise { cache.push(event); } + for (const event of await gatherInfo({ events: cache, store, signal })) { + cache.push(event); + } + for (const event of await gatherReportedProfiles({ events: cache, store, signal })) { cache.push(event); } @@ -83,6 +87,7 @@ export function assembleEvents( for (const event of a) { event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e)); event.user = b.find((e) => matchFilter({ kinds: [30382], authors: [admin], '#d': [event.pubkey] }, e)); + event.info = b.find((e) => matchFilter({ kinds: [30383], authors: [admin], '#d': [event.id] }, e)); if (event.kind === 1) { const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content); @@ -106,20 +111,21 @@ export function assembleEvents( } 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 pubkey = event.tags.find(([name]) => name === 'p')?.[1]; + if (pubkey) { + event.reported_profile = b.find((e) => matchFilter({ kinds: [0], authors: [pubkey] }, 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); + const reportedEvents: DittoEvent[] = []; + const ids = event.tags.filter(([name]) => name === 'e').map(([_name, value]) => value); + + for (const id of ids) { + const reported = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e)); + if (reported) { + reportedEvents.push(reported); } - event.reported_notes = reportedEvents; } + event.reported_notes = reportedEvents; } event.author_stats = stats.authors.find((stats) => stats.pubkey === event.pubkey); @@ -206,6 +212,26 @@ function gatherUsers({ events, store, signal }: HydrateOpts): Promise { + const ids = new Set(); + + for (const event of events) { + if (event.kind === 3036) { + ids.add(event.id); + } + } + + if (!ids.size) { + return Promise.resolve([]); + } + + return store.query( + [{ ids: [...ids], limit: ids.size }], + { signal }, + ); +} + /** Collect reported notes from the events. */ function gatherReportedNotes({ events, store, signal }: HydrateOpts): Promise { const ids = new Set(); diff --git a/src/utils/api.ts b/src/utils/api.ts index a9390b5..1fa397b 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -107,12 +107,20 @@ async function updateAdminEvent( return createAdminEvent(fn(prev), c); } -async function updateUser(pubkey: string, n: Record, c: AppContext): Promise { +function updateUser(pubkey: string, n: Record, c: AppContext): Promise { + return updateNames(30382, pubkey, n, c); +} + +function updateEventInfo(id: string, n: Record, c: AppContext): Promise { + return updateNames(30383, id, n, c); +} + +async function updateNames(k: number, d: string, n: Record, c: AppContext): Promise { const signer = new AdminSigner(); const admin = await signer.getPublicKey(); return updateAdminEvent( - { kinds: [30382], authors: [admin], '#d': [pubkey], limit: 1 }, + { kinds: [k], authors: [admin], '#d': [d], limit: 1 }, (prev) => { const prevNames = prev?.tags.reduce((acc, [name, value]) => { if (name === 'n') acc[value] = true; @@ -124,10 +132,10 @@ async function updateUser(pubkey: string, n: Record, c: AppCont const other = prev?.tags.filter(([name]) => !['d', 'n'].includes(name)) ?? []; return { - kind: 30382, + kind: k, content: prev?.content ?? '', tags: [ - ['d', pubkey], + ['d', d], ...nTags, ...other, ], @@ -296,6 +304,7 @@ export { parseBody, updateAdminEvent, updateEvent, + updateEventInfo, updateListAdminEvent, updateListEvent, updateUser, diff --git a/src/utils/nip05.ts b/src/utils/nip05.ts index 840ef6d..a579da6 100644 --- a/src/utils/nip05.ts +++ b/src/utils/nip05.ts @@ -45,16 +45,15 @@ const nip05Cache = new SimpleLRU( { max: 500, ttl: Time.hours(1) }, ); -async function localNip05Lookup(store: NStore, name: string): Promise { - const [label] = await store.query([{ - kinds: [1985], +async function localNip05Lookup(store: NStore, localpart: string): Promise { + const [grant] = await store.query([{ + kinds: [30360], + '#d': [`${localpart}@${Conf.url.host}`], authors: [Conf.pubkey], - '#L': ['nip05'], - '#l': [`${name}@${Conf.url.host}`], limit: 1, }]); - const pubkey = label?.tags.find(([name]) => name === 'p')?.[1]; + const pubkey = grant?.tags.find(([name]) => name === 'p')?.[1]; if (pubkey) { return { pubkey, relays: [Conf.relay] }; diff --git a/src/views/ditto.ts b/src/views/ditto.ts new file mode 100644 index 0000000..708c522 --- /dev/null +++ b/src/views/ditto.ts @@ -0,0 +1,25 @@ +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; +import { getTagSet } from '@/utils/tags.ts'; + +export async function renderNameRequest(event: DittoEvent) { + const n = getTagSet(event.info?.tags ?? [], 'n'); + + let approvalStatus = 'pending'; + + if (n.has('approved')) { + approvalStatus = 'approved'; + } + if (n.has('rejected')) { + approvalStatus = 'rejected'; + } + + return { + id: event.id, + account: event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey), + name: event.tags.find(([name]) => name === 'r')?.[1] || '', + reason: event.content, + approval_status: approvalStatus, + created_at: new Date(event.created_at * 1000).toISOString(), + }; +}