diff --git a/deno.json b/deno.json index 0b14069..ecf32a3 100644 --- a/deno.json +++ b/deno.json @@ -22,7 +22,7 @@ "@db/sqlite": "jsr:@db/sqlite@^0.11.1", "@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1", "@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0", - "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.22.5", + "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.23.1", "@scure/base": "npm:@scure/base@^1.1.6", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.1.0", diff --git a/deno.lock b/deno.lock index e502c76..7d767ab 100644 --- a/deno.lock +++ b/deno.lock @@ -10,6 +10,7 @@ "jsr:@nostrify/nostrify@^0.22.1": "jsr:@nostrify/nostrify@0.22.5", "jsr:@nostrify/nostrify@^0.22.4": "jsr:@nostrify/nostrify@0.22.4", "jsr:@nostrify/nostrify@^0.22.5": "jsr:@nostrify/nostrify@0.22.5", + "jsr:@nostrify/nostrify@^0.23.1": "jsr:@nostrify/nostrify@0.23.1", "jsr:@soapbox/kysely-deno-sqlite@^2.1.0": "jsr:@soapbox/kysely-deno-sqlite@2.2.0", "jsr:@soapbox/stickynotes@^0.4.0": "jsr:@soapbox/stickynotes@0.4.0", "jsr:@std/assert@^0.217.0": "jsr:@std/assert@0.217.0", @@ -17,6 +18,7 @@ "jsr:@std/assert@^0.224.0": "jsr:@std/assert@0.224.0", "jsr:@std/assert@^0.225.1": "jsr:@std/assert@0.225.3", "jsr:@std/bytes@^0.224.0": "jsr:@std/bytes@0.224.0", + "jsr:@std/bytes@^1.0.0-rc.3": "jsr:@std/bytes@1.0.0", "jsr:@std/crypto@^0.224.0": "jsr:@std/crypto@0.224.0", "jsr:@std/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.0", "jsr:@std/encoding@^0.221.0": "jsr:@std/encoding@0.221.0", @@ -25,7 +27,7 @@ "jsr:@std/fmt@^0.221.0": "jsr:@std/fmt@0.221.0", "jsr:@std/fs@^0.221.0": "jsr:@std/fs@0.221.0", "jsr:@std/internal@^1.0.0": "jsr:@std/internal@1.0.0", - "jsr:@std/io@^0.224": "jsr:@std/io@0.224.0", + "jsr:@std/io@^0.224": "jsr:@std/io@0.224.1", "jsr:@std/media-types@^0.224.1": "jsr:@std/media-types@0.224.1", "jsr:@std/path@0.217": "jsr:@std/path@0.217.0", "jsr:@std/path@^0.221.0": "jsr:@std/path@0.221.0", @@ -119,6 +121,20 @@ "npm:zod@^3.23.8" ] }, + "@nostrify/nostrify@0.23.1": { + "integrity": "7a242dedfe33cf38131696ad96d789d54257cfbfd5b5e63748fe5d53c057d99a", + "dependencies": [ + "jsr:@std/encoding@^0.224.1", + "npm:@scure/base@^1.1.6", + "npm:@scure/bip32@^1.4.0", + "npm:@scure/bip39@^1.3.0", + "npm:kysely@^0.27.3", + "npm:lru-cache@^10.2.0", + "npm:nostr-tools@^2.7.0", + "npm:websocket-ts@^2.1.5", + "npm:zod@^3.23.8" + ] + }, "@soapbox/kysely-deno-sqlite@2.2.0": { "integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a", "dependencies": [ @@ -146,6 +162,9 @@ "@std/bytes@0.224.0": { "integrity": "a2250e1d0eb7d1c5a426f21267ab9bdeac2447fa87a3d0d1a467d3f7a6058e49" }, + "@std/bytes@1.0.0": { + "integrity": "9392e72af80adccaa1197912fa19990ed091cb98d5c9c4344b0c301b22d7c632" + }, "@std/crypto@0.224.0": { "integrity": "154ef3ff08ef535562ef1a718718c5b2c5fc3808f0f9100daad69e829bfcdf2d", "dependencies": [ @@ -181,6 +200,12 @@ "jsr:@std/bytes@^0.224.0" ] }, + "@std/io@0.224.1": { + "integrity": "73de242551a5c0965eb33e36b1fc7df4834ffbc836a1a643a410ccd11253d6be", + "dependencies": [ + "jsr:@std/bytes@^1.0.0-rc.3" + ] + }, "@std/media-types@0.224.1": { "integrity": "9e69a5daed37c5b5c6d3ce4731dc191f80e67f79bed392b0957d1d03b87f11e1" }, @@ -1318,7 +1343,7 @@ "dependencies": [ "jsr:@bradenmacdonald/s3-lite-client@^0.7.4", "jsr:@db/sqlite@^0.11.1", - "jsr:@nostrify/nostrify@^0.22.5", + "jsr:@nostrify/nostrify@^0.23.1", "jsr:@soapbox/kysely-deno-sqlite@^2.1.0", "jsr:@soapbox/stickynotes@^0.4.0", "jsr:@std/assert@^0.225.1", diff --git a/docs/events.md b/docs/events.md deleted file mode 100644 index 1674239..0000000 --- a/docs/events.md +++ /dev/null @@ -1,40 +0,0 @@ -# Ditto custom events - -Instead of using database tables, the Ditto server publishes Nostr events that describe its state. It then reads these events using Nostr filters. - -## Ditto User (kind 30361) - -The Ditto server publishes kind `30361` events to represent users. These events are parameterized replaceable events of kind `30361` where the `d` tag is a pubkey. These events are published by Ditto's internal admin keypair. - -User events have the following tags: - -- `d` - pubkey of the user. -- `role` - one of `admin` or `user`. - -Example: - -```json -{ - "id": "d6ae2f320ae163612bf28080e7c6e55b228ee39bfa04ad50baab2e51022d4d59", - "kind": 30361, - "pubkey": "4cfc6ceb07bbe2f5e75f746f3e6f0eda53973e0374cd6bdbce7a930e10437e06", - "content": "", - "created_at": 1691568245, - "tags": [ - ["d", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"], - ["role", "user"], - ["alt", "User's account was updated by the admins of ditto.ngrok.app"] - ], - "sig": "fc12db77b1c8f8aa86c73b617f0cd4af1e6ba244239eaf3164a292de6d39363f32d6b817ffff796ace7a103d75e1d8e6a0fb7f618819b32d81a953b4a75d7507" -} -``` - -## NIP-78 - -[NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md) defines events of kind `30078` with a globally unique `d` tag. These events are queried by the `d` tag, which allows Ditto to store custom data on relays. Ditto uses reverse DNS names like `pub.ditto.` for `d` tags. - -The sections below describe the `content` field. Some are encrypted and some are not, depending on whether the data should be public. Also, some events are user events, and some are admin events. - -### `pub.ditto.pleroma.config` - -NIP-04 encrypted JSON array of Pleroma ConfigDB objects. Pleroma admin API endpoints set this config, and Ditto reads from it. diff --git a/fixtures/events/event-30361.json b/fixtures/events/event-30361.json deleted file mode 100644 index 5844000..0000000 --- a/fixtures/events/event-30361.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "d6ae2f320ae163612bf28080e7c6e55b228ee39bfa04ad50baab2e51022d4d59", - "kind": 30361, - "pubkey": "4cfc6ceb07bbe2f5e75f746f3e6f0eda53973e0374cd6bdbce7a930e10437e06", - "content": "", - "created_at": 1691568245, - "tags": [ - ["d", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"], - ["name", "alex"], - ["role", "user"], - ["origin", "https://ditto.ngrok.app"], - ["alt", "@alex@ditto.ngrok.app's account was updated by the admins of ditto.ngrok.app"] - ], - "sig": "fc12db77b1c8f8aa86c73b617f0cd4af1e6ba244239eaf3164a292de6d39363f32d6b817ffff796ace7a103d75e1d8e6a0fb7f618819b32d81a953b4a75d7507" -} \ No newline at end of file diff --git a/scripts/admin-role.ts b/scripts/admin-role.ts index 6e7bfc6..305b593 100644 --- a/scripts/admin-role.ts +++ b/scripts/admin-role.ts @@ -1,7 +1,6 @@ import { NSchema } from '@nostrify/nostrify'; import { DittoDB } from '@/db/DittoDB.ts'; -import { Conf } from '@/config.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { EventsDB } from '@/storages/EventsDB.ts'; import { nostrNow } from '@/utils.ts'; @@ -21,14 +20,39 @@ if (!['admin', 'user'].includes(role)) { Deno.exit(1); } -const event = await new AdminSigner().signEvent({ - kind: 30361, - tags: [ - ['d', pubkey], - ['role', role], - // NIP-31: https://github.com/nostr-protocol/nips/blob/master/31.md - ['alt', `User's account was updated by the admins of ${Conf.url.host}`], - ], +const signer = new AdminSigner(); +const admin = await signer.getPublicKey(); + +const [existing] = await eventsDB.query([{ + kinds: [30382], + authors: [admin], + '#d': [pubkey], + limit: 1, +}]); + +const prevTags = (existing?.tags ?? []).filter(([name, value]) => { + if (name === 'd') { + return false; + } + if (name === 'n' && value === 'admin') { + return false; + } + return true; +}); + +const tags: string[][] = [ + ['d', pubkey], +]; + +if (role === 'admin') { + tags.push(['n', 'admin']); +} + +tags.push(...prevTags); + +const event = await signer.signEvent({ + kind: 30382, + tags, content: '', created_at: nostrNow(), }); diff --git a/src/app.ts b/src/app.ts index bb1c5b6..074359d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,11 +26,21 @@ import { updateCredentialsController, verifyCredentialsController, } from '@/controllers/api/accounts.ts'; -import { adminAccountAction, adminAccountsController } from '@/controllers/api/admin.ts'; +import { + adminAccountsController, + adminActionController, + adminApproveController, + adminRejectController, +} from '@/controllers/api/admin.ts'; 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, + nameRequestsController, +} 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'; @@ -42,6 +52,10 @@ import { configController, frontendConfigController, pleromaAdminDeleteStatusController, + pleromaAdminSuggestController, + pleromaAdminTagController, + pleromaAdminUnsuggestController, + pleromaAdminUntagController, updateConfigController, } from '@/controllers/api/pleroma.ts'; import { preferencesController } from '@/controllers/api/preferences.ts'; @@ -49,6 +63,7 @@ import { deleteReactionController, reactionController, reactionsController } fro import { relayController } from '@/controllers/nostr/relay.ts'; import { adminReportController, + adminReportReopenController, adminReportResolveController, adminReportsController, reportController, @@ -231,7 +246,6 @@ app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactions app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController); app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, deleteReactionController); -app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController); app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController); app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController); app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController); @@ -239,6 +253,9 @@ 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.get('/api/v1/ditto/names', requireSigner, nameRequestsController); + app.post('/api/v1/ditto/zap', requireSigner, zapController); app.post('/api/v1/reports', requireSigner, reportController); @@ -250,8 +267,27 @@ app.post( requireRole('admin'), adminReportResolveController, ); +app.post( + '/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen', + requireSigner, + requireRole('admin'), + adminReportReopenController, +); -app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminAccountAction); +app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController); +app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminActionController); +app.post( + '/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve', + requireSigner, + requireRole('admin'), + adminApproveController, +); +app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject', requireSigner, requireRole('admin'), adminRejectController); + +app.put('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminTagController); +app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController); +app.patch('/api/v1/pleroma/admin/users/suggest', requireRole('admin'), pleromaAdminSuggestController); +app.patch('/api/v1/pleroma/admin/users/unsuggest', requireRole('admin'), pleromaAdminUnsuggestController); // Not (yet) implemented. app.get('/api/v1/custom_emojis', emptyArrayController); diff --git a/src/controllers/api/admin.ts b/src/controllers/api/admin.ts index d7cd365..bcc40ce 100644 --- a/src/controllers/api/admin.ts +++ b/src/controllers/api/admin.ts @@ -1,13 +1,14 @@ +import { NostrFilter } 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, updateListAdminEvent } from '@/utils/api.ts'; -import { addTag } from '@/utils/tags.ts'; -import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; +import { createAdminEvent, paginated, paginationSchema, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts'; +import { renderNameRequest } from '@/views/ditto.ts'; +import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; const adminAccountQuerySchema = z.object({ local: booleanParamSchema.optional(), @@ -27,45 +28,93 @@ const adminAccountQuerySchema = z.object({ }); const adminAccountsController: AppController = async (c) => { + const store = await Storages.db(); + const params = paginationSchema.parse(c.req.query()); + const { signal } = c.req.raw; const { + local, pending, disabled, silenced, suspended, sensitized, + staff, } = adminAccountQuerySchema.parse(c.req.query()); - // Not supported. - if (pending || disabled || silenced || suspended || sensitized) { - return c.json([]); + if (pending) { + if (disabled || silenced || suspended || sensitized) { + return c.json([]); + } + + const orig = await store.query( + [{ kinds: [30383], authors: [Conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...params }], + { signal }, + ); + + const ids = new Set( + orig + .map(({ tags }) => tags.find(([name]) => name === 'd')?.[1]) + .filter((id): id is string => !!id), + ); + + const events = await store.query([{ kinds: [3036], ids: [...ids] }]) + .then((events) => hydrateEvents({ store, events, signal })); + + const nameRequests = await Promise.all(events.map(renderNameRequest)); + return paginated(c, orig, nameRequests); } - const store = await Storages.db(); - const { since, until, limit } = paginationSchema.parse(c.req.query()); - const { signal } = c.req.raw; + if (disabled || silenced || suspended || sensitized) { + const n = []; - const events = await store.query([{ kinds: [30361], 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 }); + if (disabled) { + n.push('disabled'); + } + if (silenced) { + n.push('silenced'); + } + if (suspended) { + n.push('suspended'); + } + if (sensitized) { + n.push('sensitized'); + } + if (staff) { + n.push('admin'); + n.push('moderator'); + } - 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); + const events = await store.query([{ kinds: [30382], authors: [Conf.pubkey], '#n': n, ...params }], { signal }); + const pubkeys = new Set(events.map(({ pubkey }) => pubkey)); + const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }]) + .then((events) => hydrateEvents({ store, events, signal })); + + const accounts = await Promise.all( + [...pubkeys].map((pubkey) => { + const author = authors.find((e) => e.pubkey === pubkey); + return author ? renderAdminAccount(author) : renderAdminAccountFromPubkey(pubkey); + }), + ); + + return paginated(c, events, accounts); } - const accounts = await Promise.all( - events.map((event) => renderAdminAccount(event)), - ); - + const filter: NostrFilter = { kinds: [0], ...params }; + if (local) { + filter.search = `domain:${Conf.url.host}`; + } + const events = await store.query([filter], { signal }); + const accounts = await Promise.all(events.map(renderAdminAccount)); return paginated(c, events, accounts); }; const adminAccountActionSchema = z.object({ - type: z.enum(['none', 'sensitive', 'disable', 'silence', 'suspend']), + type: z.enum(['none', 'sensitive', 'disable', 'silence', 'suspend', 'revoke_name']), }); -const adminAccountAction: AppController = async (c) => { +const adminActionController: AppController = async (c) => { const body = await parseBody(c.req.raw); + const store = await Storages.db(); const result = adminAccountActionSchema.safeParse(body); const authorId = c.req.param('id'); @@ -75,17 +124,84 @@ const adminAccountAction: AppController = async (c) => { const { data } = result; - if (data.type !== 'disable') { - return c.json({ error: 'Record invalid' }, 422); + const n: Record = {}; + + if (data.type === 'sensitive') { + n.sensitized = true; + } + if (data.type === 'disable') { + n.disabled = true; + } + if (data.type === 'silence') { + n.silenced = true; + } + if (data.type === 'suspend') { + n.suspended = true; + store.remove([{ authors: [authorId] }]).catch(console.warn); + } + if (data.type === 'revoke_name') { + n.revoke_name = true; + store.remove([{ kinds: [30360], authors: [Conf.pubkey], '#p': [authorId] }]).catch(console.warn); } - await updateListAdminEvent( - { kinds: [10000], authors: [Conf.pubkey], limit: 1 }, - (tags) => addTag(tags, ['p', authorId]), - c, - ); + await updateUser(authorId, n, c); return c.json({}, 200); }; -export { adminAccountAction, adminAccountsController }; +const adminApproveController: AppController = async (c) => { + const eventId = c.req.param('id'); + const store = await Storages.db(); + + const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + + const r = event.tags.find(([name]) => name === 'r')?.[1]; + if (!r) { + return c.json({ error: 'NIP-05 not found' }, 404); + } + if (!z.string().email().safeParse(r).success) { + return c.json({ error: 'Invalid NIP-05' }, 400); + } + + const [existing] = await store.query([{ kinds: [30360], authors: [Conf.pubkey], '#d': [r], limit: 1 }]); + if (existing) { + return c.json({ error: 'NIP-05 already granted to another user' }, 400); + } + + await createAdminEvent({ + kind: 30360, + tags: [ + ['d', r], + ['L', 'nip05.domain'], + ['l', r.split('@')[1], 'nip05.domain'], + ['p', event.pubkey], + ['e', event.id], + ], + }, c); + + await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c); + await hydrateEvents({ events: [event], store }); + + const nameRequest = await renderNameRequest(event); + return c.json(nameRequest); +}; + +const adminRejectController: AppController = async (c) => { + const eventId = c.req.param('id'); + const store = await Storages.db(); + + const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + + await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c); + await hydrateEvents({ events: [event], store }); + + const nameRequest = await renderNameRequest(event); + return c.json(nameRequest); +}; +export { adminAccountsController, adminActionController, adminApproveController, adminRejectController }; diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index df4f210..5723a9e 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,10 +1,14 @@ -import { NostrEvent } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { z } from 'zod'; import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { Storages } from '@/storages.ts'; +import { booleanParamSchema } from '@/schema.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; +import { Storages } from '@/storages.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; +import { createEvent, paginated, paginationSchema } from '@/utils/api.ts'; +import { renderNameRequest } from '@/views/ditto.ts'; const markerSchema = z.enum(['read', 'write']); @@ -58,3 +62,76 @@ function renderRelays(event: NostrEvent): RelayEntity[] { return acc; }, [] as RelayEntity[]); } + +const nameRequestSchema = z.object({ + name: z.string().email(), + reason: z.string().max(500).optional(), +}); + +export const nameRequestController: AppController = async (c) => { + const { name, reason } = nameRequestSchema.parse(await c.req.json()); + + const event = await createEvent({ + kind: 3036, + content: reason, + tags: [ + ['r', name], + ['L', 'nip05.domain'], + ['l', name.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); +}; + +const nameRequestsSchema = z.object({ + approved: booleanParamSchema.optional(), + rejected: booleanParamSchema.optional(), +}); + +export const nameRequestsController: AppController = async (c) => { + const store = await Storages.db(); + const signer = c.get('signer')!; + const pubkey = await signer.getPublicKey(); + + const params = paginationSchema.parse(c.req.query()); + const { approved, rejected } = nameRequestsSchema.parse(c.req.query()); + + const filter: NostrFilter = { + kinds: [30383], + authors: [Conf.pubkey], + '#k': ['3036'], + '#p': [pubkey], + ...params, + }; + + if (approved) { + filter['#n'] = ['approved']; + } + if (rejected) { + filter['#n'] = ['rejected']; + } + + 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: [3036], ids: [...ids] }]) + .then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })); + + const nameRequests = await Promise.all( + events.map((event) => renderNameRequest(event)), + ); + + return paginated(c, orig, nameRequests); +}; diff --git a/src/controllers/api/notifications.ts b/src/controllers/api/notifications.ts index ba15bd0..d92ccf4 100644 --- a/src/controllers/api/notifications.ts +++ b/src/controllers/api/notifications.ts @@ -1,24 +1,87 @@ -import { NostrFilter } from '@nostrify/nostrify'; +import { NostrFilter, NSchema as n } from '@nostrify/nostrify'; +import { z } from 'zod'; import { AppContext, AppController } from '@/app.ts'; +import { Conf } from '@/config.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { paginated, paginationSchema } from '@/utils/api.ts'; +import { paginated, PaginationParams, paginationSchema } from '@/utils/api.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts'; +/** Set of known notification types across backends. */ +const notificationTypes = new Set([ + 'mention', + 'status', + 'reblog', + 'follow', + 'follow_request', + 'favourite', + 'poll', + 'update', + 'admin.sign_up', + 'admin.report', + 'severed_relationships', + 'pleroma:emoji_reaction', + 'ditto:name_grant', +]); + +const notificationsSchema = z.object({ + account_id: n.id().optional(), +}); + const notificationsController: AppController = async (c) => { const pubkey = await c.get('signer')?.getPublicKey()!; - const { since, until } = paginationSchema.parse(c.req.query()); + const params = paginationSchema.parse(c.req.query()); - return renderNotifications(c, [{ kinds: [1, 6, 7], '#p': [pubkey], since, until }]); + const types = notificationTypes + .intersection(new Set(c.req.queries('types[]') ?? notificationTypes)) + .difference(new Set(c.req.queries('exclude_types[]'))); + + const { account_id } = notificationsSchema.parse(c.req.query()); + + const kinds = new Set(); + + if (types.has('mention')) { + kinds.add(1); + } + if (types.has('reblog')) { + kinds.add(6); + } + if (types.has('favourite') || types.has('pleroma:emoji_reaction')) { + kinds.add(7); + } + + const filter: NostrFilter = { + kinds: [...kinds], + '#p': [pubkey], + ...params, + }; + + const filters: NostrFilter[] = [filter]; + + if (account_id) { + filter.authors = [account_id]; + } + + if (types.has('ditto:name_grant') && !account_id) { + filters.push({ kinds: [30360], authors: [Conf.pubkey], '#p': [pubkey], ...params }); + } + + return renderNotifications(filters, types, params, c); }; -async function renderNotifications(c: AppContext, filters: NostrFilter[]) { +async function renderNotifications( + filters: NostrFilter[], + types: Set, + params: PaginationParams, + c: AppContext, +) { const store = c.get('store'); const pubkey = await c.get('signer')?.getPublicKey()!; const { signal } = c.req.raw; + const opts = { signal, limit: params.limit }; const events = await store - .query(filters, { signal }) + .query(filters, opts) .then((events) => events.filter((event) => event.pubkey !== pubkey)) .then((events) => hydrateEvents({ events, store, signal })); @@ -26,9 +89,8 @@ async function renderNotifications(c: AppContext, filters: NostrFilter[]) { return c.json([]); } - const notifications = (await Promise - .all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey })))) - .filter(Boolean); + const notifications = (await Promise.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey })))) + .filter((notification) => notification && types.has(notification.type)); if (!notifications.length) { return c.json([]); diff --git a/src/controllers/api/pleroma.ts b/src/controllers/api/pleroma.ts index 31b4fc5..31d8545 100644 --- a/src/controllers/api/pleroma.ts +++ b/src/controllers/api/pleroma.ts @@ -6,7 +6,8 @@ import { Conf } from '@/config.ts'; import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/pleroma-api.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { Storages } from '@/storages.ts'; -import { createAdminEvent } from '@/utils/api.ts'; +import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts'; +import { lookupPubkey } from '@/utils/lookup.ts'; const frontendConfigController: AppController = async (c) => { const store = await Storages.db(); @@ -87,4 +88,100 @@ async function getConfigs(store: NStore, signal: AbortSignal): Promise { + const params = pleromaAdminTagSchema.parse(await c.req.json()); + + for (const nickname of params.nicknames) { + const pubkey = await lookupPubkey(nickname); + if (!pubkey) continue; + + await updateAdminEvent( + { kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 }, + (prev) => { + const tags = prev?.tags ?? [['d', pubkey]]; + + for (const tag of params.tags) { + const existing = prev?.tags.some(([name, value]) => name === 't' && value === tag); + if (!existing) { + tags.push(['t', tag]); + } + } + + return { + kind: 30382, + content: prev?.content ?? '', + tags, + }; + }, + c, + ); + } + + return new Response(null, { status: 204 }); +}; + +const pleromaAdminUntagController: AppController = async (c) => { + const params = pleromaAdminTagSchema.parse(await c.req.json()); + + for (const nickname of params.nicknames) { + const pubkey = await lookupPubkey(nickname); + if (!pubkey) continue; + + await updateAdminEvent( + { kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 }, + (prev) => ({ + kind: 30382, + content: prev?.content ?? '', + tags: (prev?.tags ?? [['d', pubkey]]) + .filter(([name, value]) => !(name === 't' && params.tags.includes(value))), + }), + c, + ); + } + + return new Response(null, { status: 204 }); +}; + +const pleromaAdminSuggestSchema = z.object({ + nicknames: z.string().array(), +}); + +const pleromaAdminSuggestController: AppController = async (c) => { + const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json()); + + for (const nickname of nicknames) { + const pubkey = await lookupPubkey(nickname); + if (!pubkey) continue; + await updateUser(pubkey, { suggested: true }, c); + } + + return new Response(null, { status: 204 }); +}; + +const pleromaAdminUnsuggestController: AppController = async (c) => { + const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json()); + + for (const nickname of nicknames) { + const pubkey = await lookupPubkey(nickname); + if (!pubkey) continue; + await updateUser(pubkey, { suggested: false }, c); + } + + return new Response(null, { status: 204 }); +}; + +export { + configController, + frontendConfigController, + pleromaAdminDeleteStatusController, + pleromaAdminSuggestController, + pleromaAdminTagController, + pleromaAdminUnsuggestController, + pleromaAdminUntagController, + updateConfigController, +}; diff --git a/src/controllers/api/reports.ts b/src/controllers/api/reports.ts index 9cb2627..da107ed 100644 --- a/src/controllers/api/reports.ts +++ b/src/controllers/api/reports.ts @@ -1,12 +1,13 @@ -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, paginated, 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 { booleanParamSchema } from '@/schema.ts'; const reportSchema = z.object({ account_id: n.id(), @@ -52,20 +53,55 @@ 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()); - return c.json(reports); + const filter: NostrFilter = { + kinds: [30383], + authors: [Conf.pubkey], + '#k': ['1984'], + ...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) => renderAdminReport(event, { viewerPubkey })), + ); + + return paginated(c, orig, reports); }; /** https://docs.joinmastodon.org/methods/admin/reports/#get-one */ @@ -82,12 +118,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 +141,43 @@ 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 }); + return c.json(report); }; -export { adminReportController, adminReportResolveController, adminReportsController, reportController }; +const adminReportReopenController: AppController = async (c) => { + const eventId = c.req.param('id'); + const { signal } = c.req.raw; + const store = c.get('store'); + const pubkey = await c.get('signer')?.getPublicKey(); + + const [event] = await store.query([{ + kinds: [1984], + ids: [eventId], + limit: 1, + }], { signal }); + + if (!event) { + return c.json({ error: 'Not found' }, 404); + } + + await updateEventInfo(eventId, { open: true, closed: false }, c); + await hydrateEvents({ events: [event], store, signal }); + + const report = await renderAdminReport(event, { viewerPubkey: pubkey }); + return c.json(report); +}; + +export { + adminReportController, + adminReportReopenController, + adminReportResolveController, + adminReportsController, + reportController, +}; diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index 427c350..552ea3b 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -1,4 +1,4 @@ -import { NostrFilter } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import Debug from '@soapbox/stickynotes/debug'; import { z } from 'zod'; @@ -11,6 +11,7 @@ import { hydrateEvents } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; import { bech32ToPubkey } from '@/utils.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; +import { renderNotification } from '@/views/mastodon/notifications.ts'; const debug = Debug('ditto:streaming'); @@ -52,6 +53,11 @@ const streamingController: AppController = async (c) => { const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 }); + const store = await Storages.db(); + const pubsub = await Storages.pubsub(); + + const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined; + function send(name: string, payload: object) { if (socket.readyState === WebSocket.OPEN) { debug('send', name, JSON.stringify(payload)); @@ -63,52 +69,54 @@ const streamingController: AppController = async (c) => { } } - socket.onopen = async () => { - if (!stream) return; - - const filter = await topicToFilter(stream, c.req.query(), pubkey); - if (!filter) return; - + async function sub(type: string, filters: NostrFilter[], render: (event: NostrEvent) => Promise) { try { - const db = await Storages.db(); - const pubsub = await Storages.pubsub(); - - for await (const msg of pubsub.req([filter], { signal: controller.signal })) { + for await (const msg of pubsub.req(filters, { signal: controller.signal })) { if (msg[0] === 'EVENT') { const event = msg[2]; - if (pubkey) { - const policy = new MuteListPolicy(pubkey, await Storages.admin()); + if (policy) { const [, , ok] = await policy.call(event); if (!ok) { continue; } } - await hydrateEvents({ - events: [event], - store: db, - signal: AbortSignal.timeout(1000), - }); + await hydrateEvents({ events: [event], store, signal: AbortSignal.timeout(1000) }); - if (event.kind === 1) { - const status = await renderStatus(event, { viewerPubkey: pubkey }); - if (status) { - send('update', status); - } - } + const result = await render(event); - if (event.kind === 6) { - const status = await renderReblog(event, { viewerPubkey: pubkey }); - if (status) { - send('update', status); - } + if (result) { + send(type, result); } } } } catch (e) { debug('streaming error:', e); } + } + + socket.onopen = async () => { + if (!stream) return; + const topicFilter = await topicToFilter(stream, c.req.query(), pubkey); + + if (topicFilter) { + sub('update', [topicFilter], async (event) => { + if (event.kind === 1) { + return await renderStatus(event, { viewerPubkey: pubkey }); + } + if (event.kind === 6) { + return await renderReblog(event, { viewerPubkey: pubkey }); + } + }); + } + + if (['user', 'user:notification'].includes(stream) && pubkey) { + sub('notification', [{ '#p': [pubkey] }], async (event) => { + return await renderNotification(event, { viewerPubkey: pubkey }); + }); + return; + } }; socket.onclose = () => { diff --git a/src/controllers/api/suggestions.ts b/src/controllers/api/suggestions.ts index b56851a..7e461c4 100644 --- a/src/controllers/api/suggestions.ts +++ b/src/controllers/api/suggestions.ts @@ -31,7 +31,7 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s const pubkey = await signer?.getPublicKey(); const filters: NostrFilter[] = [ - { kinds: [3], authors: [Conf.pubkey], limit: 1 }, + { kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'], limit }, { kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [Conf.pubkey], limit: 1 }, ]; @@ -42,8 +42,8 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s const events = await store.query(filters, { signal }); - const [suggestedEvent, followsEvent, mutesEvent, trendingEvent] = [ - events.find((event) => matchFilter({ kinds: [3], authors: [Conf.pubkey] }, event)), + const [userEvents, followsEvent, mutesEvent, trendingEvent] = [ + events.filter((event) => matchFilter({ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'] }, event)), pubkey ? events.find((event) => matchFilter({ kinds: [3], authors: [pubkey] }, event)) : undefined, pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined, events.find((event) => @@ -51,8 +51,13 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s ), ]; - const [suggested, trending, follows, mutes] = [ - getTagSet(suggestedEvent?.tags ?? [], 'p'), + const suggested = new Set( + userEvents + .map((event) => event.tags.find(([name]) => name === 'd')?.[1]) + .filter((pubkey): pubkey is string => !!pubkey), + ); + + const [trending, follows, mutes] = [ getTagSet(trendingEvent?.tags ?? [], 'p'), getTagSet(followsEvent?.tags ?? [], 'p'), getTagSet(mutesEvent?.tags ?? [], 'p'), 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/db/users.ts b/src/db/users.ts deleted file mode 100644 index bf0cab7..0000000 --- a/src/db/users.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { NostrFilter } from '@nostrify/nostrify'; -import Debug from '@soapbox/stickynotes/debug'; - -import { Conf } from '@/config.ts'; -import * as pipeline from '@/pipeline.ts'; -import { AdminSigner } from '@/signers/AdminSigner.ts'; -import { Storages } from '@/storages.ts'; - -const debug = Debug('ditto:users'); - -interface User { - pubkey: string; - inserted_at: Date; - admin: boolean; -} - -function buildUserEvent(user: User) { - const { origin, host } = Conf.url; - const signer = new AdminSigner(); - - return signer.signEvent({ - kind: 30361, - tags: [ - ['d', user.pubkey], - ['role', user.admin ? 'admin' : 'user'], - ['origin', origin], - // NIP-31: https://github.com/nostr-protocol/nips/blob/master/31.md - ['alt', `User's account was updated by the admins of ${host}`], - ], - content: '', - created_at: Math.floor(user.inserted_at.getTime() / 1000), - }); -} - -/** Adds a user to the database. */ -async function insertUser(user: User) { - debug('insertUser', JSON.stringify(user)); - const event = await buildUserEvent(user); - return pipeline.handleEvent(event, AbortSignal.timeout(1000)); -} - -/** - * Finds a single user based on one or more properties. - * - * ```ts - * await findUser({ username: 'alex' }); - * ``` - */ -async function findUser(user: Partial, signal?: AbortSignal): Promise { - const filter: NostrFilter = { kinds: [30361], authors: [Conf.pubkey], limit: 1 }; - - for (const [key, value] of Object.entries(user)) { - switch (key) { - case 'pubkey': - filter['#d'] = [String(value)]; - break; - case 'admin': - filter['#role'] = [value ? 'admin' : 'user']; - break; - } - } - - const store = await Storages.db(); - const [event] = await store.query([filter], { signal }); - - if (event) { - return { - pubkey: event.tags.find(([name]) => name === 'd')?.[1]!, - inserted_at: new Date(event.created_at * 1000), - admin: event.tags.find(([name]) => name === 'role')?.[1] === 'admin', - }; - } -} - -export { buildUserEvent, findUser, insertUser, type User }; 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/middleware/auth98Middleware.ts b/src/middleware/auth98Middleware.ts index 34d6937..05b0681 100644 --- a/src/middleware/auth98Middleware.ts +++ b/src/middleware/auth98Middleware.ts @@ -2,8 +2,8 @@ import { NostrEvent } from '@nostrify/nostrify'; import { HTTPException } from 'hono'; import { type AppContext, type AppMiddleware } from '@/app.ts'; -import { findUser, User } from '@/db/users.ts'; import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; +import { Storages } from '@/storages.ts'; import { localRequest } from '@/utils/api.ts'; import { buildAuthEventTemplate, @@ -11,6 +11,7 @@ import { type ParseAuthRequestOpts, validateAuthEvent, } from '@/utils/nip98.ts'; +import { Conf } from '@/config.ts'; /** * NIP-98 auth. @@ -35,7 +36,14 @@ type UserRole = 'user' | 'admin'; /** Require the user to prove their role before invoking the controller. */ function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware { return withProof(async (_c, proof, next) => { - const user = await findUser({ pubkey: proof.pubkey }); + const store = await Storages.db(); + + const [user] = await store.query([{ + kinds: [30382], + authors: [Conf.pubkey], + '#d': [proof.pubkey], + limit: 1, + }]); if (user && matchesRole(user, role)) { await next(); @@ -53,15 +61,8 @@ function requireProof(opts?: ParseAuthRequestOpts): AppMiddleware { } /** Check whether the user fulfills the role. */ -function matchesRole(user: User, role: UserRole): boolean { - switch (role) { - case 'user': - return true; - case 'admin': - return user.admin; - default: - return false; - } +function matchesRole(user: NostrEvent, role: UserRole): boolean { + return user.tags.some(([tag, value]) => tag === 'n' && value === role); } /** HOC to obtain proof in middleware. */ diff --git a/src/pipeline.ts b/src/pipeline.ts index 3255aa7..9f99520 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -1,5 +1,4 @@ import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify'; -import { PipePolicy } from '@nostrify/nostrify/policies'; import Debug from '@soapbox/stickynotes/debug'; import { sql } from 'kysely'; import { LRUCache } from 'lru-cache'; @@ -8,8 +7,8 @@ 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 { MuteListPolicy } from '@/policies/MuteListPolicy.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'; @@ -35,6 +34,7 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise ${event.id}`); if (event.kind !== 24133) { @@ -43,9 +43,19 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise { const debug = Debug('ditto:policy'); - const policy = new PipePolicy([ - new MuteListPolicy(Conf.pubkey, await Storages.admin()), - policyWorker, - ]); - try { - const result = await policy.call(event); + const result = await policyWorker.call(event); debug(JSON.stringify(result)); RelayError.assert(result); } catch (e) { @@ -84,6 +89,13 @@ function encounterEvent(event: NostrEvent): boolean { return encountered; } +/** Check if the event already exists in the database. */ +async function existsInDB(event: DittoEvent): Promise { + const store = await Storages.db(); + const events = await store.query([{ ids: [event.id], limit: 1 }]); + return events.length > 0; +} + /** Hydrate the event with the user, if applicable. */ async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise { await hydrateEvents({ events: [event], store: await Storages.db(), signal }); @@ -167,4 +179,46 @@ async function streamOut(event: NostrEvent): 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.ts b/src/storages.ts index f8f206d..4aaca1c 100644 --- a/src/storages.ts +++ b/src/storages.ts @@ -3,15 +3,15 @@ import { RelayPoolWorker } from 'nostr-relaypool'; import { Conf } from '@/config.ts'; import { DittoDB } from '@/db/DittoDB.ts'; +import { AdminStore } from '@/storages/AdminStore.ts'; import { EventsDB } from '@/storages/EventsDB.ts'; import { PoolStore } from '@/storages/pool-store.ts'; import { SearchStore } from '@/storages/search-store.ts'; import { InternalRelay } from '@/storages/InternalRelay.ts'; -import { UserStore } from '@/storages/UserStore.ts'; export class Storages { private static _db: Promise | undefined; - private static _admin: Promise | undefined; + private static _admin: Promise | undefined; private static _client: Promise | undefined; private static _pubsub: Promise | undefined; private static _search: Promise | undefined; @@ -28,9 +28,9 @@ export class Storages { } /** Admin user storage. */ - public static async admin(): Promise { + public static async admin(): Promise { if (!this._admin) { - this._admin = Promise.resolve(new UserStore(Conf.pubkey, await this.db())); + this._admin = Promise.resolve(new AdminStore(await this.db())); } return this._admin; } diff --git a/src/storages/AdminStore.ts b/src/storages/AdminStore.ts new file mode 100644 index 0000000..014dcb7 --- /dev/null +++ b/src/storages/AdminStore.ts @@ -0,0 +1,41 @@ +import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; + +import { Conf } from '@/config.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { getTagSet } from '@/utils/tags.ts'; + +/** A store that prevents banned users from being displayed. */ +export class AdminStore implements NStore { + constructor(private store: NStore) {} + + async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise { + return await this.store.event(event, opts); + } + + async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise { + const events = await this.store.query(filters, opts); + const pubkeys = new Set(events.map((event) => event.pubkey)); + + const users = await this.store.query([{ + kinds: [30382], + authors: [Conf.pubkey], + '#d': [...pubkeys], + limit: pubkeys.size, + }]); + + return events.filter((event) => { + const user = users.find( + ({ kind, pubkey, tags }) => + kind === 30382 && pubkey === Conf.pubkey && tags.find(([name]) => name === 'd')?.[1] === event.pubkey, + ); + + const n = getTagSet(user?.tags ?? [], 'n'); + + if (n.has('disabled') || n.has('suspended')) { + return false; + } + + return true; + }); + } +} diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts index 20a0895..c26ebf1 100644 --- a/src/storages/EventsDB.ts +++ b/src/storages/EventsDB.ts @@ -11,7 +11,6 @@ import { RelayError } from '@/RelayError.ts'; import { purifyEvent } from '@/storages/hydrate.ts'; import { isNostrId, isURL } from '@/utils.ts'; import { abortError } from '@/utils/abort.ts'; -import { getTagSet } from '@/utils/tags.ts'; /** Function to decide whether or not to index a tag. */ type TagCondition = ({ event, count, value }: { @@ -27,11 +26,12 @@ class EventsDB implements NStore { /** Conditions for when to index certain tags. */ static tagConditions: Record = { + '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), @@ -39,8 +39,6 @@ class EventsDB implements NStore { 'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value), 'r': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 3) && isURL(value), 't': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50, - 'name': ({ event, count }) => event.kind === 30361 && count === 0, - 'role': ({ event, count }) => event.kind === 30361 && count === 0, }; constructor(private kysely: Kysely) { @@ -77,17 +75,62 @@ class EventsDB implements NStore { /** Check if an event has been deleted by the admin. */ private async isDeletedAdmin(event: NostrEvent): Promise { - const [deletion] = await this.query([ + const filters: NostrFilter[] = [ { kinds: [5], authors: [Conf.pubkey], '#e': [event.id], limit: 1 }, - ]); - return !!deletion; + ]; + + if (NKinds.replaceable(event.kind) || NKinds.parameterizedReplaceable(event.kind)) { + const d = event.tags.find(([tag]) => tag === 'd')?.[1] ?? ''; + + filters.push({ + kinds: [5], + authors: [Conf.pubkey], + '#a': [`${event.kind}:${event.pubkey}:${d}`], + since: event.created_at, + limit: 1, + }); + } + + const events = await this.query(filters); + return events.length > 0; } /** The DITTO_NSEC can delete any event from the database. NDatabase already handles user deletions. */ private async deleteEventsAdmin(event: NostrEvent): Promise { if (event.kind === 5 && event.pubkey === Conf.pubkey) { - const ids = getTagSet(event.tags, 'e'); - await this.remove([{ ids: [...ids] }]); + const ids = new Set(event.tags.filter(([name]) => name === 'e').map(([_name, value]) => value)); + const addrs = new Set(event.tags.filter(([name]) => name === 'a').map(([_name, value]) => value)); + + const filters: NostrFilter[] = []; + + if (ids.size) { + filters.push({ ids: [...ids] }); + } + + for (const addr of addrs) { + const [k, pubkey, d] = addr.split(':'); + const kind = Number(k); + + if (!(Number.isInteger(kind) && kind >= 0)) continue; + if (!isNostrId(pubkey)) continue; + if (d === undefined) continue; + + const filter: NostrFilter = { + kinds: [kind], + authors: [pubkey], + until: event.created_at, + }; + + if (d) { + filter['#d'] = [d]; + } + + filters.push(filter); + } + + if (filters.length) { + await this.remove(filters); + } } } @@ -180,6 +223,8 @@ class EventsDB implements NStore { return event.content; case 30009: return EventsDB.buildTagsSearchContent(event.tags.filter(([t]) => t !== 'alt')); + case 30360: + return event.tags.find(([name]) => name === 'd')?.[1] || ''; default: return ''; } diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index d80c2f4..9ec9e8c 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); } @@ -82,7 +86,8 @@ 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: [30361], authors: [admin], '#d': [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); @@ -200,8 +206,32 @@ function gatherAuthors({ events, store, signal }: HydrateOpts): Promise { const pubkeys = new Set(events.map((event) => event.pubkey)); + if (!pubkeys.size) { + return Promise.resolve([]); + } + return store.query( - [{ kinds: [30361], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }], + [{ kinds: [30382], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }], + { signal }, + ); +} + +/** Collect info events from the events. */ +function gatherInfo({ events, store, signal }: HydrateOpts): Promise { + const ids = new Set(); + + for (const event of events) { + if (event.kind === 1984 || event.kind === 3036) { + ids.add(event.id); + } + } + + if (!ids.size) { + return Promise.resolve([]); + } + + return store.query( + [{ kinds: [30383], authors: [Conf.pubkey], '#d': [...ids], limit: ids.size }], { signal }, ); } diff --git a/src/utils/api.ts b/src/utils/api.ts index 3cc8b7d..1fa397b 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -107,6 +107,44 @@ async function updateAdminEvent( return createAdminEvent(fn(prev), c); } +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: [k], authors: [admin], '#d': [d], limit: 1 }, + (prev) => { + const prevNames = prev?.tags.reduce((acc, [name, value]) => { + if (name === 'n') acc[value] = true; + return acc; + }, {} as Record); + + const names = { ...prevNames, ...n }; + const nTags = Object.entries(names).filter(([, value]) => value).map(([name]) => ['n', name]); + const other = prev?.tags.filter(([name]) => !['d', 'n'].includes(name)) ?? []; + + return { + kind: k, + content: prev?.content ?? '', + tags: [ + ['d', d], + ...nTags, + ...other, + ], + }; + }, + c, + ); +} + /** Push the event through the pipeline, rethrowing any RelayError. */ async function publishEvent(event: NostrEvent, c: AppContext): Promise { debug('EVENT', event); @@ -264,7 +302,10 @@ export { type PaginationParams, paginationSchema, 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..ebc07b7 --- /dev/null +++ b/src/views/ditto.ts @@ -0,0 +1,22 @@ +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { getTagSet } from '@/utils/tags.ts'; +import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; + +/** Renders an Admin::Account entity from a name request event. */ +export async function renderNameRequest(event: DittoEvent) { + const n = getTagSet(event.info?.tags ?? [], 'n'); + const [username, domain] = event.tags.find(([name]) => name === 'r')?.[1]?.split('@') ?? []; + + const adminAccount = event.author + ? await renderAdminAccount(event.author) + : await renderAdminAccountFromPubkey(event.pubkey); + + return { + ...adminAccount, + id: event.id, + approved: n.has('approved'), + username, + domain, + invite_request: event.content, + }; +} diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 918d03b..99f69f0 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -6,6 +6,7 @@ import { Conf } from '@/config.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { getLnurl } from '@/utils/lnurl.ts'; import { nip05Cache } from '@/utils/nip05.ts'; +import { getTagSet } from '@/utils/tags.ts'; import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts'; @@ -33,7 +34,7 @@ async function renderAccount( const npub = nip19.npubEncode(pubkey); const parsed05 = await parseAndVerifyNip05(nip05, pubkey); - const role = event.user?.tags.find(([name]) => name === 'role')?.[1] ?? 'user'; + const names = getTagSet(event.user?.tags ?? [], 'n'); return { id: pubkey, @@ -74,13 +75,14 @@ async function renderAccount( username: parsed05?.nickname || npub.substring(0, 8), ditto: { accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), - is_registered: Boolean(event.user), }, pleroma: { - is_admin: role === 'admin', - is_moderator: ['admin', 'moderator'].includes(role), + is_admin: names.has('admin'), + is_moderator: names.has('admin') || names.has('moderator'), + is_suggested: names.has('suggested'), is_local: parsed05?.domain === Conf.url.host, settings_store: undefined as unknown, + tags: [...getTagSet(event.user?.tags ?? [], 't')], }, nostr: { pubkey, diff --git a/src/views/mastodon/admin-accounts.ts b/src/views/mastodon/admin-accounts.ts index 4dc8569..34b6860 100644 --- a/src/views/mastodon/admin-accounts.ts +++ b/src/views/mastodon/admin-accounts.ts @@ -1,9 +1,20 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; -import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { getTagSet } from '@/utils/tags.ts'; /** Expects a kind 0 fully hydrated */ async function renderAdminAccount(event: DittoEvent) { const account = await renderAccount(event); + const names = getTagSet(event.user?.tags ?? [], 'n'); + + let role = 'user'; + + if (names.has('admin')) { + role = 'admin'; + } + if (names.has('moderator')) { + role = 'moderator'; + } return { id: account.id, @@ -15,12 +26,13 @@ async function renderAdminAccount(event: DittoEvent) { ips: [], locale: '', invite_request: null, - role: event.tags.find(([name]) => name === 'role')?.[1], + role, confirmed: true, approved: true, - disabled: false, - silenced: false, - suspended: false, + disabled: names.has('disabled'), + silenced: names.has('silenced'), + suspended: names.has('suspended'), + sensitized: names.has('sensitized'), account, }; } diff --git a/src/views/mastodon/notifications.ts b/src/views/mastodon/notifications.ts index 5b618d7..8f2a8a6 100644 --- a/src/views/mastodon/notifications.ts +++ b/src/views/mastodon/notifications.ts @@ -1,8 +1,10 @@ +import { NostrEvent } from '@nostrify/nostrify'; + +import { Conf } from '@/config.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { nostrDate } from '@/utils.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -import { NostrEvent } from '@nostrify/nostrify'; interface RenderNotificationOpts { viewerPubkey: string; @@ -26,6 +28,10 @@ function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) { if (event.kind === 7) { return renderReaction(event, opts); } + + if (event.kind === 30360 && event.pubkey === Conf.pubkey) { + return renderNameGrant(event); + } } async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) { @@ -45,7 +51,7 @@ async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) { if (event.repost?.kind !== 1) return; const status = await renderStatus(event.repost, opts); if (!status) return; - const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); + const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); return { id: notificationId(event), @@ -60,7 +66,7 @@ async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts) if (event.reacted?.kind !== 1) return; const status = await renderStatus(event.reacted, opts); if (!status) return; - const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); + const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); return { id: notificationId(event), @@ -75,7 +81,7 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) { if (event.reacted?.kind !== 1) return; const status = await renderStatus(event.reacted, opts); if (!status) return; - const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); + const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); return { id: notificationId(event), @@ -87,6 +93,21 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) { }; } +async function renderNameGrant(event: DittoEvent) { + const d = event.tags.find(([name]) => name === 'd')?.[1]; + const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); + + if (!d) return; + + return { + id: notificationId(event), + type: 'ditto:name_grant', + name: d, + created_at: nostrDate(event.created_at).toISOString(), + account, + }; +} + /** This helps notifications be sorted in the correct order. */ function notificationId({ id, created_at }: NostrEvent): string { return `${created_at}-${id}`; diff --git a/src/views/mastodon/reports.ts b/src/views/mastodon/reports.ts index bec08b4..48baa42 100644 --- a/src/views/mastodon/reports.ts +++ b/src/views/mastodon/reports.ts @@ -3,6 +3,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { nostrDate } from '@/utils.ts'; import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { getTagSet } from '@/utils/tags.ts'; /** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */ async function renderReport(event: DittoEvent) { @@ -30,43 +31,42 @@ async function renderReport(event: DittoEvent) { interface RenderAdminReportOpts { viewerPubkey?: string; - actionTaken?: boolean; } /** 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, actionTaken = false } = opts; +async function renderAdminReport(event: 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 category = event.tags.find(([name]) => name === 'p')?.[2]; const statuses = []; - if (reportEvent.reported_notes) { - for (const status of reportEvent.reported_notes) { + if (event.reported_notes) { + for (const status of event.reported_notes) { statuses.push(await renderStatus(status, { viewerPubkey })); } } - const reportedPubkey = reportEvent.tags.find(([name]) => name === 'p')?.[1]; + const reportedPubkey = event.tags.find(([name]) => name === 'p')?.[1]; if (!reportedPubkey) { return; } + const names = getTagSet(event.info?.tags ?? [], 'n'); + return { - id: reportEvent.id, - action_taken: actionTaken, + id: event.id, + action_taken: names.has('closed'), action_taken_at: null, category, - comment: reportEvent.content, + comment: event.content, forwarded: false, - created_at: nostrDate(reportEvent.created_at).toISOString(), - account: reportEvent.author - ? await renderAdminAccount(reportEvent.author) - : await renderAdminAccountFromPubkey(reportEvent.pubkey), - target_account: reportEvent.reported_profile - ? await renderAdminAccount(reportEvent.reported_profile) + created_at: nostrDate(event.created_at).toISOString(), + account: event.author ? await renderAdminAccount(event.author) : await renderAdminAccountFromPubkey(event.pubkey), + target_account: event.reported_profile + ? await renderAdminAccount(event.reported_profile) : await renderAdminAccountFromPubkey(reportedPubkey), assigned_account: null, action_taken_by_account: null,