diff --git a/packages/ditto/routes/dittoNamesRoute.test.ts b/packages/ditto/routes/dittoNamesRoute.test.ts new file mode 100644 index 00000000..e443be96 --- /dev/null +++ b/packages/ditto/routes/dittoNamesRoute.test.ts @@ -0,0 +1,62 @@ +import { TestApp } from '@ditto/mastoapi/test'; +import { assertEquals } from '@std/assert'; + +import route from './dittoNamesRoute.ts'; + +Deno.test('POST / creates a name request event', async () => { + await using app = new TestApp(); + const { conf, relay } = app.var; + + const user = app.user(); + app.route('/', route); + + const response = await app.api.post('/', { name: 'Alex@Ditto.pub', reason: 'for testing' }); + + assertEquals(response.status, 200); + + const [event] = await relay.query([{ kinds: [3036], authors: [await user.signer.getPublicKey()] }]); + + assertEquals(event?.tags, [ + ['r', 'Alex@Ditto.pub'], + ['r', 'alex@ditto.pub'], + ['L', 'nip05.domain'], + ['l', 'ditto.pub', 'nip05.domain'], + ['p', await conf.signer.getPublicKey()], + ]); + + assertEquals(event?.content, 'for testing'); +}); + +Deno.test('POST / can be called multiple times with the same name', async () => { + await using app = new TestApp(); + + app.user(); + app.route('/', route); + + const response1 = await app.api.post('/', { name: 'alex@ditto.pub' }); + const response2 = await app.api.post('/', { name: 'alex@ditto.pub' }); + + assertEquals(response1.status, 200); + assertEquals(response2.status, 200); +}); + +Deno.test('POST / returns 400 if the name has already been granted', async () => { + await using app = new TestApp(); + const { conf, relay } = app.var; + + app.user(); + app.route('/', route); + + const grant = await conf.signer.signEvent({ + kind: 30360, + tags: [['d', 'alex@ditto.pub']], + content: '', + created_at: 0, + }); + + await relay.event(grant); + + const response = await app.api.post('/', { name: 'alex@ditto.pub' }); + + assertEquals(response.status, 400); +}); diff --git a/packages/ditto/routes/dittoNamesRoute.ts b/packages/ditto/routes/dittoNamesRoute.ts index 2baa3adf..8351be81 100644 --- a/packages/ditto/routes/dittoNamesRoute.ts +++ b/packages/ditto/routes/dittoNamesRoute.ts @@ -18,33 +18,55 @@ const route = new DittoRoute(); route.post('/', userMiddleware(), async (c) => { const { conf, relay, user } = c.var; - const pubkey = await user!.signer.getPublicKey(); const result = nameRequestSchema.safeParse(await c.req.json()); if (!result.success) { - return c.json({ error: 'Invalid username', schema: result.error }, 400); + return c.json({ error: 'Invalid username', schema: result.error }, 422); } + const pubkey = await user.signer.getPublicKey(); + const adminPubkey = await conf.signer.getPublicKey(); + const { name, reason } = result.data; + const [_localpart, domain] = name.split('@'); - const [existing] = await relay.query([{ kinds: [3036], authors: [pubkey], '#r': [name.toLowerCase()], limit: 1 }]); - if (existing) { - return c.json({ error: 'Name request already exists' }, 400); + if (domain.toLowerCase() !== conf.url.host.toLowerCase()) { + return c.json({ error: 'Unsupported domain' }, 422); } - const r: string[][] = [['r', name]]; + const d = name.toLowerCase(); + + const [grant] = await relay.query([{ kinds: [30360], authors: [adminPubkey], '#d': [d] }]); + if (grant) { + return c.json({ error: 'Name has already been granted' }, 400); + } + + const [pending] = await relay.query([{ + kinds: [30383], + authors: [adminPubkey], + '#p': [pubkey], + '#k': ['3036'], + '#r': [d], + '#n': ['pending'], + limit: 1, + }]); + if (pending) { + return c.json({ error: 'You have already requested that name, and it is pending approval by staff' }, 400); + } + + const tags: string[][] = [['r', name]]; if (name !== name.toLowerCase()) { - r.push(['r', name.toLowerCase()]); + tags.push(['r', name.toLowerCase()]); } const event = await createEvent({ kind: 3036, content: reason, tags: [ - ...r, + ...tags, ['L', 'nip05.domain'], - ['l', name.split('@')[1], 'nip05.domain'], + ['l', domain.toLowerCase(), 'nip05.domain'], ['p', await conf.signer.getPublicKey()], ], }, c); diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index b3938c9d..5ea1372a 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -358,19 +358,24 @@ export class DittoRelayStore implements NRelay { } if (event.kind === 3036 && tagsAdmin) { - 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), - }); + const r = event.tags.find(([name]) => name === 'r')?.[1]; - await this.event(rel, { signal: AbortSignal.timeout(1000) }); + if (r) { + const rel = await signer.signEvent({ + kind: 30383, + content: '', + tags: [ + ['d', event.id], + ['p', event.pubkey], + ['k', '3036'], + ['r', r.toLowerCase()], + ['n', 'pending'], + ], + created_at: Math.floor(Date.now() / 1000), + }); + + await this.event(rel, { signal: AbortSignal.timeout(1000) }); + } } } diff --git a/packages/ditto/storages/hydrate.ts b/packages/ditto/storages/hydrate.ts index a4dfe7ab..42d8b601 100644 --- a/packages/ditto/storages/hydrate.ts +++ b/packages/ditto/storages/hydrate.ts @@ -58,17 +58,19 @@ async function hydrateEvents(opts: HydrateOpts): Promise { return result; }, new Set()); - const favicons = ( - await db.kysely - .selectFrom('domain_favicons') - .select(['domain', 'favicon']) - .where('domain', 'in', [...domains]) - .execute() - ) - .reduce((result, { domain, favicon }) => { - result[domain] = favicon; - return result; - }, {} as Record); + const favicons: Record = domains.size + ? ( + await db.kysely + .selectFrom('domain_favicons') + .select(['domain', 'favicon']) + .where('domain', 'in', [...domains]) + .execute() + ) + .reduce((result, { domain, favicon }) => { + result[domain] = favicon; + return result; + }, {} as Record) + : {}; const stats = { authors: authorStats, diff --git a/packages/ditto/utils/api.ts b/packages/ditto/utils/api.ts index b5d4fc3b..a8242f73 100644 --- a/packages/ditto/utils/api.ts +++ b/packages/ditto/utils/api.ts @@ -27,10 +27,10 @@ async function createEvent & HonoOptions; + export class DittoApp extends Hono { // @ts-ignore Require a DittoRoute for type safety. declare route: (path: string, app: Hono) => Hono; - constructor(opts: Omit & HonoOptions) { + constructor(protected opts: DittoAppOpts) { super(opts); this.use((c, next) => { diff --git a/packages/mastoapi/router/DittoRoute.ts b/packages/mastoapi/router/DittoRoute.ts index 53d2109b..a4803b6e 100644 --- a/packages/mastoapi/router/DittoRoute.ts +++ b/packages/mastoapi/router/DittoRoute.ts @@ -1,4 +1,4 @@ -import { type ErrorHandler, Hono } from '@hono/hono'; +import { Hono } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; import type { HonoOptions } from '@hono/hono/hono-base'; @@ -16,8 +16,6 @@ export class DittoRoute extends Hono { this.assertVars(c.var); return next(); }); - - this.onError(this._errorHandler); } private assertVars(vars: Partial): DittoEnv['Variables'] { @@ -40,16 +38,4 @@ export class DittoRoute extends Hono { private throwMissingVar(name: string): never { throw new HTTPException(500, { message: `Missing required variable: ${name}` }); } - - private _errorHandler: ErrorHandler = (error, c) => { - if (error instanceof HTTPException) { - if (error.res) { - return error.res; - } else { - return c.json({ error: error.message }, error.status); - } - } - - return c.json({ error: 'Something went wrong' }, 500); - }; } diff --git a/packages/mastoapi/test.ts b/packages/mastoapi/test.ts index 41e35c2c..40b7275e 100644 --- a/packages/mastoapi/test.ts +++ b/packages/mastoapi/test.ts @@ -7,6 +7,8 @@ import { generateSecretKey, nip19 } from 'nostr-tools'; import type { User } from '@ditto/mastoapi/middleware'; +export { TestApp } from './test/TestApp.ts'; + export function testApp(): { app: DittoApp; relay: NRelay; diff --git a/packages/mastoapi/test/TestApp.ts b/packages/mastoapi/test/TestApp.ts new file mode 100644 index 00000000..b8e86d7e --- /dev/null +++ b/packages/mastoapi/test/TestApp.ts @@ -0,0 +1,88 @@ +import { DittoConf } from '@ditto/conf'; +import { type DittoDB, DummyDB } from '@ditto/db'; +import { type NRelay, NSecSigner } from '@nostrify/nostrify'; +import { generateSecretKey, nip19 } from 'nostr-tools'; + +import { DittoApp, type DittoAppOpts } from '../router/DittoApp.ts'; + +import type { Context } from '@hono/hono'; +import type { User } from '../middleware/User.ts'; +import { MockRelay } from '@nostrify/nostrify/test'; + +interface DittoVars { + db: DittoDB; + conf: DittoConf; + relay: NRelay; +} + +export class TestApp extends DittoApp implements AsyncDisposable { + private _user?: User; + + constructor(opts?: Partial) { + const nsec = nip19.nsecEncode(generateSecretKey()); + + const conf = opts?.conf ?? new DittoConf( + new Map([ + ['DITTO_NSEC', nsec], + ['LOCAL_DOMAIN', 'https://ditto.pub'], + ]), + ); + + const db = opts?.db ?? new DummyDB(); + const relay = opts?.relay ?? new MockRelay(); + + super({ + db, + conf, + relay, + ...opts, + }); + + this.use(async (c: Context<{ Variables: { user?: User } }>, next) => { + c.set('user', this._user); + await next(); + }); + + this.onError((err) => { + throw err; + }); + } + + get var(): DittoVars { + return { + db: this.opts.db, + conf: this.opts.conf, + relay: this.opts.relay, + }; + } + + user(user?: User): User { + user ??= this.createUser(); + this._user = user; + return user; + } + + createUser(sk?: Uint8Array): User { + return { + relay: this.opts.relay, + signer: new NSecSigner(sk ?? generateSecretKey()), + }; + } + + api = { + get: async (path: string): Promise => { + return await this.request(path); + }, + post: async (path: string, body: unknown): Promise => { + return await this.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + }, + }; + + async [Symbol.asyncDispose](): Promise { + await this.opts.db[Symbol.asyncDispose](); + } +}