mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge branch 'fix-revoke-nip05' into 'main'
fix(attempt): revoke username See merge request soapbox-pub/ditto!700
This commit is contained in:
commit
21649b8139
7 changed files with 194 additions and 18 deletions
|
|
@ -8,6 +8,7 @@ stages:
|
|||
|
||||
test:
|
||||
stage: test
|
||||
timeout: 2 minutes
|
||||
script:
|
||||
- deno fmt --check
|
||||
- deno task lint
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,6 +47,16 @@ export class DittoPglite implements DittoDB {
|
|||
}
|
||||
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
packages/db/adapters/DittoPostgres.test.ts
Normal file
22
packages/db/adapters/DittoPostgres.test.ts
Normal 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(),
|
||||
// );
|
||||
// });
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, URL>(
|
||||
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<void> {
|
||||
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;
|
||||
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<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. */
|
||||
async updateAuthorData(event: NostrEvent, signal?: AbortSignal): Promise<void> {
|
||||
if (event.kind !== 0) return;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue