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:
|
test:
|
||||||
stage: test
|
stage: test
|
||||||
|
timeout: 2 minutes
|
||||||
script:
|
script:
|
||||||
- deno fmt --check
|
- deno fmt --check
|
||||||
- deno task lint
|
- deno task lint
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,16 @@ export class DittoPglite implements DittoDB {
|
||||||
}
|
}
|
||||||
|
|
||||||
async [Symbol.asyncDispose](): Promise<void> {
|
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();
|
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 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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
const { id, kind } = event;
|
||||||
|
try {
|
||||||
await this.event(event, { signal });
|
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue