Merge branch 'fix-revoke-nip05' into 'main'

fix(attempt): revoke username

See merge request soapbox-pub/ditto!700
This commit is contained in:
Alex Gleason 2025-03-05 23:31:17 +00:00
commit 1ab77fdeab
7 changed files with 194 additions and 18 deletions

View file

@ -8,6 +8,7 @@ stages:
test: test:
stage: test stage: test
timeout: 2 minutes
script: script:
- deno fmt --check - deno fmt --check
- deno task lint - deno task lint

View file

@ -1,14 +1,22 @@
import { assertEquals } from '@std/assert'; import { assertEquals, assertRejects } from '@std/assert';
import { DittoPglite } from './DittoPglite.ts'; import { DittoPglite } from './DittoPglite.ts';
Deno.test('DittoPglite', async () => { Deno.test('DittoPglite', async () => {
const db = new DittoPglite('memory://'); await using db = new DittoPglite('memory://');
await db.migrate(); await db.migrate();
assertEquals(db.poolSize, 1); assertEquals(db.poolSize, 1);
assertEquals(db.availableConnections, 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',
);
}); });

View file

@ -47,6 +47,16 @@ export class DittoPglite implements DittoDB {
} }
async [Symbol.asyncDispose](): Promise<void> { async [Symbol.asyncDispose](): Promise<void> {
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;
}
}
} }
} }

View file

@ -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(),
// );
// });

View file

@ -128,7 +128,7 @@ const adminAccountActionSchema = z.object({
}); });
const adminActionController: AppController = async (c) => { 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 body = await parseBody(c.req.raw);
const result = adminAccountActionSchema.safeParse(body); const result = adminAccountActionSchema.safeParse(body);
@ -161,7 +161,23 @@ const adminActionController: AppController = async (c) => {
if (data.type === 'revoke_name') { if (data.type === 'revoke_name') {
n.revoke_name = true; n.revoke_name = true;
try { 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) { } catch (e) {
logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, requestId, error: errorJson(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); return c.json({ error: 'Unexpected runtime error' }, 500);

View file

@ -5,9 +5,10 @@ import { assertEquals } from '@std/assert';
import { waitFor } from '@std/async/unstable-wait-for'; import { waitFor } from '@std/async/unstable-wait-for';
import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { DittoRelayStore } from './DittoRelayStore.ts'; import { DittoRelayStore } from '@/storages/DittoRelayStore.ts';
import type { NostrMetadata } from '@nostrify/types'; import type { NostrMetadata } from '@nostrify/types';
import { nostrNow } from '@/utils.ts';
Deno.test('generates set event for nip05 request', async () => { Deno.test('generates set event for nip05 request', async () => {
await using test = setupTest(); await using test = setupTest();
@ -65,6 +66,78 @@ Deno.test('updateAuthorData sets nip05', async () => {
assertEquals(row?.nip05_hostname, 'gleasonator.dev'); 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 () => { Deno.test('fetchRelated', async () => {
await using test = setupTest(); await using test = setupTest();
const { pool, store } = test; const { pool, store } = test;

View file

@ -18,6 +18,7 @@ import {
NRelay, NRelay,
NSchema as n, NSchema as n,
} from '@nostrify/nostrify'; } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { UpdateObject } from 'kysely'; import { UpdateObject } from 'kysely';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
@ -41,7 +42,6 @@ import { parseNoteContent, stripimeta } from '@/utils/note.ts';
import { SimpleLRU } from '@/utils/SimpleLRU.ts'; import { SimpleLRU } from '@/utils/SimpleLRU.ts';
import { unfurlCardCached } from '@/utils/unfurl.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts';
import { renderWebPushNotification } from '@/views/mastodon/push.ts'; import { renderWebPushNotification } from '@/views/mastodon/push.ts';
import { nip19 } from 'nostr-tools';
interface DittoRelayStoreOpts { interface DittoRelayStoreOpts {
db: DittoDB; db: DittoDB;
@ -69,10 +69,6 @@ export class DittoRelayStore implements NRelay {
this.push = new DittoPush(opts); this.push = new DittoPush(opts);
this.policyWorker = new PolicyWorker(conf); 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<string, URL>( this.faviconCache = new SimpleLRU<string, URL>(
async (domain, { signal }) => { async (domain, { signal }) => {
const row = await queryFavicon(db.kysely, domain); const row = await queryFavicon(db.kysely, domain);
@ -94,17 +90,30 @@ export class DittoRelayStore implements NRelay {
}, },
{ ...conf.caches.nip05, gauge: cachedNip05sSizeGauge }, { ...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. */ /** Open a firehose to the relay. */
private async listen(): Promise<void> { private async listen(): Promise<void> {
const { relay } = this.opts; 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 })) { for await (const msg of relay.req([{ limit: 0 }], { signal })) {
if (msg[0] === 'EVENT') { if (msg[0] === 'EVENT') {
const [, , event] = msg; 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. // Skip events that have already been encountered.
if (this.encounters.get(event.id)) { 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. // Reject events that are too far in the future.
if (eventAge(event) < -Time.minutes(1)) { if (eventAge(event) < -Time.minutes(1)) {
@ -152,7 +161,7 @@ export class DittoRelayStore implements NRelay {
} }
// Recheck encountered after async ops. // Recheck encountered after async ops.
if (this.encounters.has(event.id)) { if (this.encounters.has(event.id)) {
throw new RelayError('duplicate', 'already have this event'); return;
} }
// Set the event as encountered after verifying the signature. // Set the event as encountered after verifying the signature.
this.encounters.set(event.id, true); this.encounters.set(event.id, true);
@ -184,6 +193,7 @@ export class DittoRelayStore implements NRelay {
} }
try { try {
await this.handleRevokeNip05(event, signal);
await relay.event(purifyEvent(event), { signal }); await relay.event(purifyEvent(event), { signal });
} finally { } finally {
// This needs to run in steps, and should not block the API from responding. // 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<void> {
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. */ /** Parse kind 0 metadata and track indexes in the database. */
async updateAuthorData(event: NostrEvent, signal?: AbortSignal): Promise<void> { async updateAuthorData(event: NostrEvent, signal?: AbortSignal): Promise<void> {
if (event.kind !== 0) return; if (event.kind !== 0) return;