diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c15e8907..2b9f0555 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -8,6 +8,7 @@ stages: test: stage: test + timeout: 2 minutes script: - deno fmt --check - deno task lint diff --git a/packages/db/adapters/DittoPglite.test.ts b/packages/db/adapters/DittoPglite.test.ts index b0d9f4d1..4cea878f 100644 --- a/packages/db/adapters/DittoPglite.test.ts +++ b/packages/db/adapters/DittoPglite.test.ts @@ -1,14 +1,22 @@ -import { assertEquals } from '@std/assert'; +import { assertEquals, assertRejects } from '@std/assert'; import { DittoPglite } from './DittoPglite.ts'; Deno.test('DittoPglite', async () => { - const db = new DittoPglite('memory://'); + await using db = new DittoPglite('memory://'); await db.migrate(); assertEquals(db.poolSize, 1); assertEquals(db.availableConnections, 1); - - await db.kysely.destroy(); - await new Promise((resolve) => setTimeout(resolve, 100)); +}); + +Deno.test('DittoPglite query after closing', async () => { + const db = new DittoPglite('memory://'); + await db[Symbol.asyncDispose](); + + await assertRejects( + () => db.kysely.selectFrom('nostr_events').selectAll().execute(), + Error, + 'PGlite is closed', + ); }); diff --git a/packages/db/adapters/DittoPglite.ts b/packages/db/adapters/DittoPglite.ts index 7fcd5bab..bac1ebaa 100644 --- a/packages/db/adapters/DittoPglite.ts +++ b/packages/db/adapters/DittoPglite.ts @@ -47,6 +47,16 @@ export class DittoPglite implements DittoDB { } async [Symbol.asyncDispose](): Promise { - await this.kysely.destroy(); + try { + // FIXME: `kysely.destroy()` calls `pglite.close()` internally, but it doesn't work. + await this.pglite.close(); + await this.kysely.destroy(); + } catch (e) { + if (e instanceof Error && e.message === 'PGlite is closed') { + // Make dispose idempotent. + } else { + throw e; + } + } } } diff --git a/packages/db/adapters/DittoPostgres.test.ts b/packages/db/adapters/DittoPostgres.test.ts new file mode 100644 index 00000000..48e97340 --- /dev/null +++ b/packages/db/adapters/DittoPostgres.test.ts @@ -0,0 +1,22 @@ +import { DittoConf } from '@ditto/conf'; + +import { DittoPostgres } from './DittoPostgres.ts'; + +const conf = new DittoConf(Deno.env); +const isPostgres = /^postgres(?:ql)?:/.test(conf.databaseUrl); + +Deno.test('DittoPostgres', { ignore: !isPostgres }, async () => { + await using db = new DittoPostgres(conf.databaseUrl); + await db.migrate(); +}); + +// FIXME: There is a problem with postgres-js where queries just hang after the database is closed. + +// Deno.test('DittoPostgres query after closing', { ignore: !isPostgres }, async () => { +// const db = new DittoPostgres(conf.databaseUrl); +// await db[Symbol.asyncDispose](); +// +// await assertRejects( +// () => db.kysely.selectFrom('nostr_events').selectAll().execute(), +// ); +// }); diff --git a/packages/ditto/controllers/api/admin.ts b/packages/ditto/controllers/api/admin.ts index 124e0f88..774157f0 100644 --- a/packages/ditto/controllers/api/admin.ts +++ b/packages/ditto/controllers/api/admin.ts @@ -128,7 +128,7 @@ const adminAccountActionSchema = z.object({ }); const adminActionController: AppController = async (c) => { - const { conf, relay, requestId } = c.var; + const { conf, relay, requestId, signal } = c.var; const body = await parseBody(c.req.raw); const result = adminAccountActionSchema.safeParse(body); @@ -161,7 +161,23 @@ const adminActionController: AppController = async (c) => { if (data.type === 'revoke_name') { n.revoke_name = true; try { - await relay.remove!([{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#p': [authorId] }]); + const [event] = await relay.query([{ + kinds: [30360], + authors: [await conf.signer.getPublicKey()], + '#p': [authorId], + }], { signal }); + + if (event) { + await createAdminEvent({ + kind: 5, + tags: [ + ['e', event.id], + ['k', '30360'], + ], + }, c); + } else { + return c.json({ error: 'Name grant not found' }, 404); + } } catch (e) { logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, requestId, error: errorJson(e) }); return c.json({ error: 'Unexpected runtime error' }, 500); diff --git a/packages/ditto/storages/DittoRelayStore.test.ts b/packages/ditto/storages/DittoRelayStore.test.ts index 0db69ba7..bdf59dab 100644 --- a/packages/ditto/storages/DittoRelayStore.test.ts +++ b/packages/ditto/storages/DittoRelayStore.test.ts @@ -5,9 +5,10 @@ import { assertEquals } from '@std/assert'; import { waitFor } from '@std/async/unstable-wait-for'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; -import { DittoRelayStore } from './DittoRelayStore.ts'; +import { DittoRelayStore } from '@/storages/DittoRelayStore.ts'; import type { NostrMetadata } from '@nostrify/types'; +import { nostrNow } from '@/utils.ts'; Deno.test('generates set event for nip05 request', async () => { await using test = setupTest(); @@ -65,6 +66,78 @@ Deno.test('updateAuthorData sets nip05', async () => { assertEquals(row?.nip05_hostname, 'gleasonator.dev'); }); +Deno.test('Admin revokes nip05 grant and nip05 column gets null', async () => { + const alex = generateSecretKey(); + + await using test = setupTest((req) => { + switch (req.url) { + case 'https://gleasonator.dev/.well-known/nostr.json?name=alex': + return jsonResponse({ names: { alex: getPublicKey(alex) } }); + default: + return new Response('Not found', { status: 404 }); + } + }); + + const { db, store, conf } = test; + + const metadata: NostrMetadata = { nip05: 'alex@gleasonator.dev' }; + const event = genEvent({ kind: 0, content: JSON.stringify(metadata) }, alex); + + await store.event(event); + + await waitFor(async () => { + const row = await db.kysely + .selectFrom('author_stats') + .selectAll() + .where('pubkey', '=', getPublicKey(alex)) + .executeTakeFirst(); + + assertEquals(row?.nip05, 'alex@gleasonator.dev'); + assertEquals(row?.nip05_domain, 'gleasonator.dev'); + assertEquals(row?.nip05_hostname, 'gleasonator.dev'); + + return true; + }, 3000); + + const grant = await conf.signer.signEvent({ + kind: 30360, + tags: [ + ['d', 'alex@gleasonator.dev'], + ['r', 'alex@gleasonator.dev'], + ['L', 'nip05.domain'], + ['l', 'gleasonator.dev', 'nip05.domain'], + ['p', event.pubkey], + ['e', 'whatever'], + ], + created_at: nostrNow(), + content: '', + }); + + await store.event(grant); + + const adminDeletion = await conf.signer.signEvent({ + kind: 5, + tags: [ + ['k', '30360'], + ['e', grant.id], + ], + created_at: nostrNow(), + content: '', + }); + + await store.event(adminDeletion); + + const nullRow = await db.kysely + .selectFrom('author_stats') + .selectAll() + .where('pubkey', '=', getPublicKey(alex)) + .executeTakeFirst(); + + assertEquals(nullRow?.nip05, null); + assertEquals(nullRow?.nip05_domain, null); + assertEquals(nullRow?.nip05_hostname, null); +}); + Deno.test('fetchRelated', async () => { await using test = setupTest(); const { pool, store } = test; diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index 794613ce..3b8db534 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -18,6 +18,7 @@ import { NRelay, NSchema as n, } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; import { logi } from '@soapbox/logi'; import { UpdateObject } from 'kysely'; import { LRUCache } from 'lru-cache'; @@ -41,7 +42,6 @@ import { parseNoteContent, stripimeta } from '@/utils/note.ts'; import { SimpleLRU } from '@/utils/SimpleLRU.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts'; import { renderWebPushNotification } from '@/views/mastodon/push.ts'; -import { nip19 } from 'nostr-tools'; interface DittoRelayStoreOpts { db: DittoDB; @@ -69,10 +69,6 @@ export class DittoRelayStore implements NRelay { this.push = new DittoPush(opts); this.policyWorker = new PolicyWorker(conf); - this.listen().catch((e: unknown) => { - logi({ level: 'error', ns: this.ns, source: 'listen', error: errorJson(e) }); - }); - this.faviconCache = new SimpleLRU( async (domain, { signal }) => { const row = await queryFavicon(db.kysely, domain); @@ -94,17 +90,30 @@ export class DittoRelayStore implements NRelay { }, { ...conf.caches.nip05, gauge: cachedNip05sSizeGauge }, ); + + this.listen().catch((e: unknown) => { + if (e instanceof Error && e.name === 'AbortError') { + return; // `this.close()` was called. This is expected. + } + + throw e; + }); } /** Open a firehose to the relay. */ private async listen(): Promise { const { relay } = this.opts; - const { signal } = this.controller; + const { signal } = this.controller; // this controller only aborts when `this.close()` is called for await (const msg of relay.req([{ limit: 0 }], { signal })) { if (msg[0] === 'EVENT') { const [, , event] = msg; - await this.event(event, { signal }); + const { id, kind } = event; + try { + await this.event(event, { signal }); + } catch (e) { + logi({ level: 'error', ns: this.ns, id, kind, source: 'listen', error: errorJson(e) }); + } } } } @@ -127,7 +136,7 @@ export class DittoRelayStore implements NRelay { // Skip events that have already been encountered. if (this.encounters.get(event.id)) { - throw new RelayError('duplicate', 'already have this event'); + return; // NIP-01: duplicate events should have ok `true` } // Reject events that are too far in the future. if (eventAge(event) < -Time.minutes(1)) { @@ -152,7 +161,7 @@ export class DittoRelayStore implements NRelay { } // Recheck encountered after async ops. if (this.encounters.has(event.id)) { - throw new RelayError('duplicate', 'already have this event'); + return; } // Set the event as encountered after verifying the signature. this.encounters.set(event.id, true); @@ -184,6 +193,7 @@ export class DittoRelayStore implements NRelay { } try { + await this.handleRevokeNip05(event, signal); await relay.event(purifyEvent(event), { signal }); } finally { // This needs to run in steps, and should not block the API from responding. @@ -251,6 +261,42 @@ export class DittoRelayStore implements NRelay { } } + /** Sets the nip05 column to null if the event is a revocation of a nip05 */ + private async handleRevokeNip05(event: NostrEvent, signal?: AbortSignal): Promise { + const { conf, relay, db } = this.opts; + + if (event.kind !== 5 || await conf.signer.getPublicKey() !== event.pubkey) { + return; + } + + if (!event.tags.some(([name, value]) => name === 'k' && value === '30360')) { + return; + } + + const eventId = event.tags.find(([name]) => name === 'e')?.[1]; + if (!eventId || !isNostrId(eventId)) { + return; + } + + const [grant] = await relay.query([{ kinds: [30360], ids: [eventId] }], { signal }); + if (!grant) { + return; + } + + const authorId = grant.tags.find(([name]) => name === 'p')?.[1]; + if (!authorId || !isNostrId(authorId)) { + return; + } + + await db.kysely.updateTable('author_stats').set({ + nip05: null, + nip05_domain: null, + nip05_hostname: null, + nip05_last_verified_at: null, + }).where('pubkey', '=', authorId) + .execute(); + } + /** Parse kind 0 metadata and track indexes in the database. */ async updateAuthorData(event: NostrEvent, signal?: AbortSignal): Promise { if (event.kind !== 0) return;