From 1f470ffe2def840b4f463842a248851b962b4ed5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 11 Aug 2023 19:55:16 -0500 Subject: [PATCH 01/14] Add nostr schema for parsing filters --- src/controllers/nostr/relay.ts | 15 +++++++++++++++ src/schemas/nostr.ts | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/controllers/nostr/relay.ts create mode 100644 src/schemas/nostr.ts diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts new file mode 100644 index 0000000..3aaa7b1 --- /dev/null +++ b/src/controllers/nostr/relay.ts @@ -0,0 +1,15 @@ +import type { AppController } from '@/app.ts'; + +const relayController: AppController = (c) => { + const upgrade = c.req.headers.get('upgrade'); + + if (upgrade?.toLowerCase() !== 'websocket') { + return c.text('Please use a Nostr client to connect.', 400); + } + + const { socket, response } = Deno.upgradeWebSocket(c.req.raw); + + return response; +}; + +export { relayController }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts new file mode 100644 index 0000000..9f42b4f --- /dev/null +++ b/src/schemas/nostr.ts @@ -0,0 +1,25 @@ +import { z } from '@/deps.ts'; + +import { hexIdSchema, signedEventSchema } from '../schema.ts'; + +const filterSchema = z.object({ + kinds: z.number().int().positive().array().optional(), + ids: hexIdSchema.array().optional(), + authors: hexIdSchema.array().optional(), + since: z.number().int().positive().optional(), + until: z.number().int().positive().optional(), + limit: z.number().int().positive().optional(), +}).and(z.record( + z.custom<`#${string}`>((val) => typeof val === 'string' && val.startsWith('#')), + z.string().array(), +)); + +const clientMsgSchema = z.union([ + z.tuple([z.literal('REQ'), z.string().min(1)]).rest(filterSchema), + z.tuple([z.literal('EVENT'), signedEventSchema]), + z.tuple([z.literal('CLOSE'), z.string().min(1)]), +]); + +type Filter = z.infer; + +export { clientMsgSchema, filterSchema }; From 893542cf5824bd7d534c8002de40d3d832808121 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 11:28:16 -0500 Subject: [PATCH 02/14] Reorganize some nostr schema code --- src/middleware/auth98.ts | 3 ++- src/schema.ts | 18 +----------------- src/schemas/nostr.ts | 25 ++++++++++++++++++++----- src/sign.ts | 2 +- src/trends.ts | 3 ++- 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/middleware/auth98.ts b/src/middleware/auth98.ts index 9086182..280af90 100644 --- a/src/middleware/auth98.ts +++ b/src/middleware/auth98.ts @@ -2,7 +2,8 @@ import { type AppMiddleware } from '@/app.ts'; import { Conf } from '@/config.ts'; import { HTTPException } from '@/deps.ts'; import { type Event } from '@/event.ts'; -import { decode64Schema, jsonSchema, signedEventSchema } from '@/schema.ts'; +import { decode64Schema, jsonSchema } from '@/schema.ts'; +import { signedEventSchema } from '@/schemas/nostr.ts'; import { eventAge, findTag, sha256, Time } from '@/utils.ts'; const decodeEventSchema = decode64Schema.pipe(jsonSchema).pipe(signedEventSchema); diff --git a/src/schema.ts b/src/schema.ts index 635dff8..646367e 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,4 +1,4 @@ -import { verifySignature, z } from '@/deps.ts'; +import { z } from '@/deps.ts'; import type { Event } from './event.ts'; @@ -67,20 +67,6 @@ const relaySchema = z.custom((relay) => { } }); -const hexIdSchema = z.string().regex(/^[0-9a-f]{64}$/); - -const eventSchema = z.object({ - id: hexIdSchema, - kind: z.number(), - tags: z.array(z.array(z.string())), - content: z.string(), - created_at: z.number(), - pubkey: hexIdSchema, - sig: z.string(), -}); - -const signedEventSchema = eventSchema.refine(verifySignature); - const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]); /** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */ @@ -102,12 +88,10 @@ export { emojiTagSchema, filteredArray, hashtagSchema, - hexIdSchema, jsonSchema, type MetaContent, metaContentSchema, parseMetaContent, parseRelay, relaySchema, - signedEventSchema, }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 9f42b4f..1b318db 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -1,7 +1,23 @@ -import { z } from '@/deps.ts'; +import { verifySignature, z } from '@/deps.ts'; -import { hexIdSchema, signedEventSchema } from '../schema.ts'; +/** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */ +const hexIdSchema = z.string().regex(/^[0-9a-f]{64}$/); +/** Nostr event schema. */ +const eventSchema = z.object({ + id: hexIdSchema, + kind: z.number(), + tags: z.array(z.array(z.string())), + content: z.string(), + created_at: z.number(), + pubkey: hexIdSchema, + sig: z.string(), +}); + +/** Nostr event schema that also verifies the event's signature. */ +const signedEventSchema = eventSchema.refine(verifySignature); + +/** Nostr relay filter schema. */ const filterSchema = z.object({ kinds: z.number().int().positive().array().optional(), ids: hexIdSchema.array().optional(), @@ -14,12 +30,11 @@ const filterSchema = z.object({ z.string().array(), )); +/** Client message to a Nostr relay. */ const clientMsgSchema = z.union([ z.tuple([z.literal('REQ'), z.string().min(1)]).rest(filterSchema), z.tuple([z.literal('EVENT'), signedEventSchema]), z.tuple([z.literal('CLOSE'), z.string().min(1)]), ]); -type Filter = z.infer; - -export { clientMsgSchema, filterSchema }; +export { clientMsgSchema, filterSchema, hexIdSchema, signedEventSchema }; diff --git a/src/sign.ts b/src/sign.ts index 228496f..c0bebca 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -1,6 +1,6 @@ import { type AppContext } from '@/app.ts'; import { getEventHash, getPublicKey, getSignature, HTTPException, z } from '@/deps.ts'; -import { signedEventSchema } from '@/schema.ts'; +import { signedEventSchema } from '@/schemas/nostr.ts'; import { ws } from '@/stream.ts'; import type { Event, EventTemplate, SignedEvent } from '@/event.ts'; diff --git a/src/trends.ts b/src/trends.ts index 3bb19d4..aa1ea2d 100644 --- a/src/trends.ts +++ b/src/trends.ts @@ -1,5 +1,6 @@ import { Sqlite } from '@/deps.ts'; -import { hashtagSchema, hexIdSchema } from '@/schema.ts'; +import { hashtagSchema } from '@/schema.ts'; +import { hexIdSchema } from '@/schemas/nostr.ts'; import { Time } from '@/utils.ts'; import { generateDateRange } from '@/utils/time.ts'; From 80775d8bf0200f7f62cb70d28ce311dd5ba1ab9c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 11:48:11 -0500 Subject: [PATCH 03/14] Move more Nostr schema stuff into schemas/nostr.ts --- src/controllers/api/accounts.ts | 4 +-- src/schema.ts | 42 +----------------------- src/schemas/nostr.ts | 28 +++++++++++++++- src/transformers/nostr-to-activitypub.ts | 4 +-- src/transformers/nostr-to-mastoapi.ts | 5 +-- 5 files changed, 35 insertions(+), 48 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 8381607..7c8b0a0 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -1,7 +1,7 @@ import { type AppController } from '@/app.ts'; import { type Filter, findReplyTag, z } from '@/deps.ts'; import { getAuthor, getFilter, getFollows, publish } from '@/client.ts'; -import { parseMetaContent } from '@/schema.ts'; +import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { signEvent } from '@/sign.ts'; import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { buildLinkHeader, eventDateComparator, lookupAccount, nostrNow, paginationSchema, parseBody } from '@/utils.ts'; @@ -154,7 +154,7 @@ const updateCredentialsController: AppController = async (c) => { return c.json({ error: 'Could not find user.' }, 404); } - const meta = parseMetaContent(author); + const meta = jsonMetaContentSchema.parse(author.content); meta.name = result.data.display_name ?? meta.name; meta.about = result.data.note ?? meta.about; diff --git a/src/schema.ts b/src/schema.ts index 646367e..361a310 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,9 +1,5 @@ import { z } from '@/deps.ts'; -import type { Event } from './event.ts'; - -const optionalString = z.string().optional().catch(undefined); - /** Validates individual items in an array, dropping any that aren't valid. */ function filteredArray(schema: T) { return z.any().array().catch([]) @@ -24,31 +20,6 @@ const jsonSchema = z.string().transform((value, ctx) => { } }); -const metaContentSchema = z.object({ - name: optionalString, - about: optionalString, - picture: optionalString, - banner: optionalString, - nip05: optionalString, - lud16: optionalString, -}); - -/** Author metadata from Event<0>. */ -type MetaContent = z.infer; - -/** - * Get (and validate) data from a kind 0 event. - * https://github.com/nostr-protocol/nips/blob/master/01.md - */ -function parseMetaContent(event: Event<0>): MetaContent { - try { - const json = JSON.parse(event.content); - return metaContentSchema.passthrough().parse(json); - } catch (_e) { - return {}; - } -} - /** Alias for `safeParse`, but instead of returning a success object it returns the value (or undefined on fail). */ function parseValue(schema: z.ZodType, value: unknown): T | undefined { const result = schema.safeParse(value); @@ -83,15 +54,4 @@ const decode64Schema = z.string().transform((value, ctx) => { const hashtagSchema = z.string().regex(/^\w{1,30}$/); -export { - decode64Schema, - emojiTagSchema, - filteredArray, - hashtagSchema, - jsonSchema, - type MetaContent, - metaContentSchema, - parseMetaContent, - parseRelay, - relaySchema, -}; +export { decode64Schema, emojiTagSchema, filteredArray, hashtagSchema, jsonSchema, parseRelay, relaySchema }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 1b318db..56bd276 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -1,5 +1,7 @@ import { verifySignature, z } from '@/deps.ts'; +import { jsonSchema } from '../schema.ts'; + /** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */ const hexIdSchema = z.string().regex(/^[0-9a-f]{64}$/); @@ -37,4 +39,28 @@ const clientMsgSchema = z.union([ z.tuple([z.literal('CLOSE'), z.string().min(1)]), ]); -export { clientMsgSchema, filterSchema, hexIdSchema, signedEventSchema }; +/** Kind 0 content schema. */ +const metaContentSchema = z.object({ + name: z.string().optional().catch(undefined), + about: z.string().optional().catch(undefined), + picture: z.string().optional().catch(undefined), + banner: z.string().optional().catch(undefined), + nip05: z.string().optional().catch(undefined), + lud16: z.string().optional().catch(undefined), +}).partial().passthrough(); + +/** Parses kind 0 content from a JSON string. */ +const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({}); + +/** Author metadata from Event<0>. */ +type MetaContent = z.infer; + +export { + clientMsgSchema, + filterSchema, + hexIdSchema, + jsonMetaContentSchema, + type MetaContent, + metaContentSchema, + signedEventSchema, +}; diff --git a/src/transformers/nostr-to-activitypub.ts b/src/transformers/nostr-to-activitypub.ts index ae7ea77..f868087 100644 --- a/src/transformers/nostr-to-activitypub.ts +++ b/src/transformers/nostr-to-activitypub.ts @@ -1,5 +1,5 @@ import { Conf } from '@/config.ts'; -import { parseMetaContent } from '@/schema.ts'; +import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { getPublicKeyPem } from '@/utils/rsa.ts'; import type { Event } from '@/event.ts'; @@ -7,7 +7,7 @@ import type { Actor } from '@/schemas/activitypub.ts'; /** Nostr metadata event to ActivityPub actor. */ async function toActor(event: Event<0>, username: string): Promise { - const content = parseMetaContent(event); + const content = jsonMetaContentSchema.parse(event.content); return { type: 'Person', diff --git a/src/transformers/nostr-to-mastoapi.ts b/src/transformers/nostr-to-mastoapi.ts index ea70df5..53ff85e 100644 --- a/src/transformers/nostr-to-mastoapi.ts +++ b/src/transformers/nostr-to-mastoapi.ts @@ -6,7 +6,8 @@ import { findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from ' import { type Event } from '@/event.ts'; import { verifyNip05Cached } from '@/nip05.ts'; import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts'; -import { emojiTagSchema, filteredArray, type MetaContent, parseMetaContent } from '@/schema.ts'; +import { emojiTagSchema, filteredArray } from '@/schema.ts'; +import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts'; const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png'; @@ -20,7 +21,7 @@ async function toAccount(event: Event<0>, opts: ToAccountOpts = {}) { const { withSource = false } = opts; const { pubkey } = event; - const { name, nip05, picture, banner, about }: MetaContent = parseMetaContent(event); + const { name, nip05, picture, banner, about } = jsonMetaContentSchema.parse(event.content); const npub = nip19.npubEncode(pubkey); let parsed05: Nip05 | undefined; From e999d693d0dcc21eb525c628d2d17bb8e510aad7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 11:48:49 -0500 Subject: [PATCH 04/14] Rename hexIdSchema back to nostrIdSchema --- src/schemas/nostr.ts | 12 ++++++------ src/trends.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 56bd276..4c0d17b 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -3,16 +3,16 @@ import { verifySignature, z } from '@/deps.ts'; import { jsonSchema } from '../schema.ts'; /** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */ -const hexIdSchema = z.string().regex(/^[0-9a-f]{64}$/); +const nostrIdSchema = z.string().regex(/^[0-9a-f]{64}$/); /** Nostr event schema. */ const eventSchema = z.object({ - id: hexIdSchema, + id: nostrIdSchema, kind: z.number(), tags: z.array(z.array(z.string())), content: z.string(), created_at: z.number(), - pubkey: hexIdSchema, + pubkey: nostrIdSchema, sig: z.string(), }); @@ -22,8 +22,8 @@ const signedEventSchema = eventSchema.refine(verifySignature); /** Nostr relay filter schema. */ const filterSchema = z.object({ kinds: z.number().int().positive().array().optional(), - ids: hexIdSchema.array().optional(), - authors: hexIdSchema.array().optional(), + ids: nostrIdSchema.array().optional(), + authors: nostrIdSchema.array().optional(), since: z.number().int().positive().optional(), until: z.number().int().positive().optional(), limit: z.number().int().positive().optional(), @@ -58,9 +58,9 @@ type MetaContent = z.infer; export { clientMsgSchema, filterSchema, - hexIdSchema, jsonMetaContentSchema, type MetaContent, metaContentSchema, + nostrIdSchema, signedEventSchema, }; diff --git a/src/trends.ts b/src/trends.ts index aa1ea2d..a97b2c2 100644 --- a/src/trends.ts +++ b/src/trends.ts @@ -1,6 +1,6 @@ import { Sqlite } from '@/deps.ts'; import { hashtagSchema } from '@/schema.ts'; -import { hexIdSchema } from '@/schemas/nostr.ts'; +import { nostrIdSchema } from '@/schemas/nostr.ts'; import { Time } from '@/utils.ts'; import { generateDateRange } from '@/utils/time.ts'; @@ -101,7 +101,7 @@ class TrendsDB { } addTagUsages(pubkey: string, hashtags: string[], date = new Date()): void { - const pubkey8 = hexIdSchema.parse(pubkey).substring(0, 8); + const pubkey8 = nostrIdSchema.parse(pubkey).substring(0, 8); const tags = hashtagSchema.array().min(1).parse(hashtags); this.#db.query( From e2adc7ad1afcaa18cca2c6ada4114eb7437f6fb7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 11:49:33 -0500 Subject: [PATCH 05/14] Remove unused MetaContent type --- src/schemas/nostr.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 4c0d17b..e5e710f 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -52,15 +52,4 @@ const metaContentSchema = z.object({ /** Parses kind 0 content from a JSON string. */ const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({}); -/** Author metadata from Event<0>. */ -type MetaContent = z.infer; - -export { - clientMsgSchema, - filterSchema, - jsonMetaContentSchema, - type MetaContent, - metaContentSchema, - nostrIdSchema, - signedEventSchema, -}; +export { clientMsgSchema, filterSchema, jsonMetaContentSchema, metaContentSchema, nostrIdSchema, signedEventSchema }; From 808e8941b65e94b6aa9c6af8c982416d23769a6d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 13:40:21 -0500 Subject: [PATCH 06/14] Relay: make REQ work (doesn't stream yet) --- src/app.ts | 2 ++ src/client.ts | 13 +----------- src/controllers/nostr/relay.ts | 35 ++++++++++++++++++++++++++++++ src/db/events.ts | 12 +++++++++-- src/schemas/nostr.ts | 39 +++++++++++++++++++++++++++------- 5 files changed, 79 insertions(+), 22 deletions(-) diff --git a/src/app.ts b/src/app.ts index dadfebf..00a1460 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,6 +29,7 @@ import { } from './controllers/api/statuses.ts'; import { streamingController } from './controllers/api/streaming.ts'; import { trendingTagsController } from './controllers/api/trends.ts'; +import { relayController } from './controllers/nostr/relay.ts'; import { indexController } from './controllers/site.ts'; import { hostMetaController } from './controllers/well-known/host-meta.ts'; import { nodeInfoController, nodeInfoSchemaController } from './controllers/well-known/nodeinfo.ts'; @@ -60,6 +61,7 @@ app.use('*', logger()); app.get('/api/v1/streaming', streamingController); app.get('/api/v1/streaming/', streamingController); +app.get('/relay', relayController); app.use('*', cors({ origin: '*', exposeHeaders: ['link'] }), auth19, auth98()); diff --git a/src/client.ts b/src/client.ts index 378dc9c..9c5e74a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,4 @@ -import { Author, findReplyTag, matchFilter, RelayPool, TTLCache } from '@/deps.ts'; +import { Author, type Filter, findReplyTag, matchFilter, RelayPool, TTLCache } from '@/deps.ts'; import { type Event, type SignedEvent } from '@/event.ts'; import { Conf } from './config.ts'; @@ -29,17 +29,6 @@ function getPool(): Pool { return pool; } -type Filter = { - ids?: string[]; - kinds?: K[]; - authors?: string[]; - since?: number; - until?: number; - limit?: number; - search?: string; - [key: `#${string}`]: string[]; -}; - interface GetFilterOpts { timeout?: number; } diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 3aaa7b1..68bd815 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -1,5 +1,39 @@ +import { type Filter } from '@/deps.ts'; +import { getFilters } from '@/db/events.ts'; +import { jsonSchema } from '@/schema.ts'; +import { clientMsgSchema, type ClientREQ } from '@/schemas/nostr.ts'; + import type { AppController } from '@/app.ts'; +function connectStream(socket: WebSocket) { + socket.onmessage = (e) => { + const result = jsonSchema.pipe(clientMsgSchema).safeParse(e.data); + + if (!result.success) { + socket.send(JSON.stringify(['NOTICE', JSON.stringify(result.error.message)])); + return; + } + + const clientMsg = result.data; + + switch (clientMsg[0]) { + case 'REQ': + handleReq(clientMsg); + return; + default: + socket.send(JSON.stringify(['NOTICE', 'Unknown command.'])); + return; + } + }; + + async function handleReq([_, sub, ...filters]: ClientREQ) { + for (const event of await getFilters(filters as Filter[])) { + socket.send(JSON.stringify(['EVENT', sub, event])); + } + socket.send(JSON.stringify(['EOSE', sub])); + } +} + const relayController: AppController = (c) => { const upgrade = c.req.headers.get('upgrade'); @@ -9,6 +43,7 @@ const relayController: AppController = (c) => { const { socket, response } = Deno.upgradeWebSocket(c.req.raw); + connectStream(socket); return response; }; diff --git a/src/db/events.ts b/src/db/events.ts index 641149c..488b5c3 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -53,7 +53,15 @@ function insertEvent(event: SignedEvent): Promise { function getFilterQuery(filter: Filter) { let query = db .selectFrom('events') - .select(['id', 'kind', 'pubkey', 'content', 'tags', 'created_at', 'sig']) + .select([ + 'events.id', + 'events.kind', + 'events.pubkey', + 'events.content', + 'events.tags', + 'events.created_at', + 'events.sig', + ]) .orderBy('created_at', 'desc'); for (const key of Object.keys(filter)) { @@ -91,8 +99,8 @@ function getFilterQuery(filter: Filter) { return query; } -async function getFilters(filters: [Filter]): Promise[]>; async function getFilters(filters: Filter[]): Promise; +async function getFilters(filters: [Filter]): Promise[]>; async function getFilters(filters: Filter[]) { const queries = filters .map(getFilterQuery) diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index e5e710f..96344a2 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -27,18 +27,31 @@ const filterSchema = z.object({ since: z.number().int().positive().optional(), until: z.number().int().positive().optional(), limit: z.number().int().positive().optional(), -}).and(z.record( - z.custom<`#${string}`>((val) => typeof val === 'string' && val.startsWith('#')), - z.string().array(), -)); +}).passthrough().and( + z.record( + z.custom<`#${string}`>((val) => typeof val === 'string' && val.startsWith('#')), + z.string().array(), + ).catch({}), +); + +const clientReqSchema = z.tuple([z.literal('REQ'), z.string().min(1)]).rest(filterSchema); +const clientEventSchema = z.tuple([z.literal('EVENT'), signedEventSchema]); +const clientCloseSchema = z.tuple([z.literal('CLOSE'), z.string().min(1)]); /** Client message to a Nostr relay. */ const clientMsgSchema = z.union([ - z.tuple([z.literal('REQ'), z.string().min(1)]).rest(filterSchema), - z.tuple([z.literal('EVENT'), signedEventSchema]), - z.tuple([z.literal('CLOSE'), z.string().min(1)]), + clientReqSchema, + clientEventSchema, + clientCloseSchema, ]); +/** REQ message from client to relay. */ +type ClientREQ = z.infer; +/** EVENT message from client to relay. */ +type ClientEVENT = z.infer; +/** CLOSE message from client to relay. */ +type ClientCLOSE = z.infer; + /** Kind 0 content schema. */ const metaContentSchema = z.object({ name: z.string().optional().catch(undefined), @@ -52,4 +65,14 @@ const metaContentSchema = z.object({ /** Parses kind 0 content from a JSON string. */ const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({}); -export { clientMsgSchema, filterSchema, jsonMetaContentSchema, metaContentSchema, nostrIdSchema, signedEventSchema }; +export { + type ClientCLOSE, + type ClientEVENT, + clientMsgSchema, + type ClientREQ, + filterSchema, + jsonMetaContentSchema, + metaContentSchema, + nostrIdSchema, + signedEventSchema, +}; From b852111ec5242434d19c06e7593e6c23b21c0e8d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 13:57:20 -0500 Subject: [PATCH 07/14] Fix getFilters overload order --- src/db/events.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/events.ts b/src/db/events.ts index 488b5c3..beb057e 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -99,8 +99,8 @@ function getFilterQuery(filter: Filter) { return query; } -async function getFilters(filters: Filter[]): Promise; async function getFilters(filters: [Filter]): Promise[]>; +async function getFilters(filters: Filter[]): Promise; async function getFilters(filters: Filter[]) { const queries = filters .map(getFilterQuery) From 3593d5420d1b24cde73faa9e36f2e7ac0378ccb5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 14:01:30 -0500 Subject: [PATCH 08/14] Relay: limit to 100 events per filter --- src/controllers/nostr/relay.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 68bd815..c288209 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -5,12 +5,15 @@ import { clientMsgSchema, type ClientREQ } from '@/schemas/nostr.ts'; import type { AppController } from '@/app.ts'; +/** Limit of events returned per-filter. */ +const FILTER_LIMIT = 100; + function connectStream(socket: WebSocket) { socket.onmessage = (e) => { const result = jsonSchema.pipe(clientMsgSchema).safeParse(e.data); if (!result.success) { - socket.send(JSON.stringify(['NOTICE', JSON.stringify(result.error.message)])); + socket.send(JSON.stringify(['NOTICE', 'Invalid message.'])); return; } @@ -27,13 +30,20 @@ function connectStream(socket: WebSocket) { }; async function handleReq([_, sub, ...filters]: ClientREQ) { - for (const event of await getFilters(filters as Filter[])) { + for (const event of await getFilters(prepareFilters(filters))) { socket.send(JSON.stringify(['EVENT', sub, event])); } socket.send(JSON.stringify(['EOSE', sub])); } } +function prepareFilters(filters: ClientREQ[2][]): Filter[] { + return filters.map((filter) => ({ + ...filter, + limit: Math.min(filter.limit || FILTER_LIMIT, FILTER_LIMIT), + })); +} + const relayController: AppController = (c) => { const upgrade = c.req.headers.get('upgrade'); From 075da543b081f6803d11e23e59c3631ac8dd3c79 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 14:32:57 -0500 Subject: [PATCH 09/14] Make relay only return local events --- src/controllers/nostr/relay.ts | 1 + src/db/events.ts | 47 +++++++++++++++++++++------------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index c288209..534150f 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -41,6 +41,7 @@ function prepareFilters(filters: ClientREQ[2][]): Filter[] { return filters.map((filter) => ({ ...filter, limit: Math.min(filter.limit || FILTER_LIMIT, FILTER_LIMIT), + local: true, })); } diff --git a/src/db/events.ts b/src/db/events.ts index beb057e..4d14f83 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -50,7 +50,11 @@ function insertEvent(event: SignedEvent): Promise { }); } -function getFilterQuery(filter: Filter) { +interface DittoFilter extends Filter { + local?: boolean; +} + +function getFilterQuery(filter: DittoFilter) { let query = db .selectFrom('events') .select([ @@ -62,24 +66,24 @@ function getFilterQuery(filter: Filter) { 'events.created_at', 'events.sig', ]) - .orderBy('created_at', 'desc'); + .orderBy('events.created_at', 'desc'); for (const key of Object.keys(filter)) { - switch (key as keyof Filter) { + switch (key as keyof DittoFilter) { case 'ids': - query = query.where('id', 'in', filter.ids!); + query = query.where('events.id', 'in', filter.ids!); break; case 'kinds': - query = query.where('kind', 'in', filter.kinds!); + query = query.where('events.kind', 'in', filter.kinds!); break; case 'authors': - query = query.where('pubkey', 'in', filter.authors!); + query = query.where('events.pubkey', 'in', filter.authors!); break; case 'since': - query = query.where('created_at', '>=', filter.since!); + query = query.where('events.created_at', '>=', filter.since!); break; case 'until': - query = query.where('created_at', '<=', filter.until!); + query = query.where('events.created_at', '<=', filter.until!); break; case 'limit': query = query.limit(filter.limit!); @@ -89,19 +93,23 @@ function getFilterQuery(filter: Filter) { if (key.startsWith('#')) { const tag = key.replace(/^#/, ''); const value = filter[key as `#${string}`] as string[]; - return query + query = query .leftJoin('tags', 'tags.event_id', 'events.id') .where('tags.tag', '=', tag) .where('tags.value_1', 'in', value) as typeof query; } } + if (filter.local) { + query = query.innerJoin('users', 'users.pubkey', 'events.pubkey'); + } + return query; } -async function getFilters(filters: [Filter]): Promise[]>; -async function getFilters(filters: Filter[]): Promise; -async function getFilters(filters: Filter[]) { +async function getFilters(filters: [DittoFilter]): Promise[]>; +async function getFilters(filters: DittoFilter[]): Promise; +async function getFilters(filters: DittoFilter[]) { const queries = filters .map(getFilterQuery) .map((query) => query.execute()); @@ -113,17 +121,20 @@ async function getFilters(filters: Filter[]) { )); } -function getFilter(filter: Filter): Promise[]> { +function getFilter(filter: DittoFilter): Promise[]> { return getFilters([filter]); } /** Returns whether the pubkey is followed by a local user. */ async function isLocallyFollowed(pubkey: string): Promise { - const event = await getFilterQuery({ kinds: [3], '#p': [pubkey], limit: 1 }) - .innerJoin('users', 'users.pubkey', 'events.pubkey') - .executeTakeFirst(); - - return !!event; + return Boolean( + await getFilterQuery({ + kinds: [3], + '#p': [pubkey], + limit: 1, + local: true, + }).executeTakeFirst(), + ); } export { getFilter, getFilters, insertEvent, isLocallyFollowed }; From b2f538ed9432a53f7fc68412584a69b0e38aee2d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 14:41:07 -0500 Subject: [PATCH 10/14] Relay: improve types, DRY --- src/controllers/nostr/relay.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 534150f..5a797ff 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -1,19 +1,25 @@ -import { type Filter } from '@/deps.ts'; import { getFilters } from '@/db/events.ts'; import { jsonSchema } from '@/schema.ts'; import { clientMsgSchema, type ClientREQ } from '@/schemas/nostr.ts'; import type { AppController } from '@/app.ts'; +import type { Filter } from '@/deps.ts'; +import type { SignedEvent } from '@/event.ts'; /** Limit of events returned per-filter. */ const FILTER_LIMIT = 100; +type RelayMsg = + | ['EVENT', string, SignedEvent] + | ['NOTICE', string] + | ['EOSE', string]; + function connectStream(socket: WebSocket) { socket.onmessage = (e) => { const result = jsonSchema.pipe(clientMsgSchema).safeParse(e.data); if (!result.success) { - socket.send(JSON.stringify(['NOTICE', 'Invalid message.'])); + send(['NOTICE', 'Invalid message.']); return; } @@ -23,17 +29,23 @@ function connectStream(socket: WebSocket) { case 'REQ': handleReq(clientMsg); return; - default: - socket.send(JSON.stringify(['NOTICE', 'Unknown command.'])); + case 'EVENT': + send(['NOTICE', 'EVENT not yet implemented.']); + return; + case 'CLOSE': return; } }; async function handleReq([_, sub, ...filters]: ClientREQ) { for (const event of await getFilters(prepareFilters(filters))) { - socket.send(JSON.stringify(['EVENT', sub, event])); + send(['EVENT', sub, event]); } - socket.send(JSON.stringify(['EOSE', sub])); + send(['EOSE', sub]); + } + + function send(msg: RelayMsg) { + socket.send(JSON.stringify(msg)); } } From 8e47c9dda2a1407ed974b338449e9bd5f12a8c21 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 15:07:07 -0500 Subject: [PATCH 11/14] relay: refactor into smaller functions --- src/controllers/nostr/relay.ts | 20 ++++++++++---------- src/schemas/nostr.ts | 3 +++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 5a797ff..eb4a983 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -1,6 +1,6 @@ import { getFilters } from '@/db/events.ts'; import { jsonSchema } from '@/schema.ts'; -import { clientMsgSchema, type ClientREQ } from '@/schemas/nostr.ts'; +import { type ClientMsg, clientMsgSchema, type ClientREQ } from '@/schemas/nostr.ts'; import type { AppController } from '@/app.ts'; import type { Filter } from '@/deps.ts'; @@ -17,17 +17,17 @@ type RelayMsg = function connectStream(socket: WebSocket) { socket.onmessage = (e) => { const result = jsonSchema.pipe(clientMsgSchema).safeParse(e.data); - - if (!result.success) { + if (result.success) { + handleClientMsg(result.data); + } else { send(['NOTICE', 'Invalid message.']); - return; } + }; - const clientMsg = result.data; - - switch (clientMsg[0]) { + function handleClientMsg(msg: ClientMsg) { + switch (msg[0]) { case 'REQ': - handleReq(clientMsg); + handleReq(msg); return; case 'EVENT': send(['NOTICE', 'EVENT not yet implemented.']); @@ -35,7 +35,7 @@ function connectStream(socket: WebSocket) { case 'CLOSE': return; } - }; + } async function handleReq([_, sub, ...filters]: ClientREQ) { for (const event of await getFilters(prepareFilters(filters))) { @@ -45,7 +45,7 @@ function connectStream(socket: WebSocket) { } function send(msg: RelayMsg) { - socket.send(JSON.stringify(msg)); + return socket.send(JSON.stringify(msg)); } } diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 96344a2..e5bdb81 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -51,6 +51,8 @@ type ClientREQ = z.infer; type ClientEVENT = z.infer; /** CLOSE message from client to relay. */ type ClientCLOSE = z.infer; +/** Client message to a Nostr relay. */ +type ClientMsg = z.infer; /** Kind 0 content schema. */ const metaContentSchema = z.object({ @@ -68,6 +70,7 @@ const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({}); export { type ClientCLOSE, type ClientEVENT, + type ClientMsg, clientMsgSchema, type ClientREQ, filterSchema, From a35ea6ab5d41642930ef19e8e980b6d078ec22b3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 15:14:34 -0500 Subject: [PATCH 12/14] relay: restrict to local events unless the filter is already narrow --- src/controllers/nostr/relay.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index eb4a983..c2d77ec 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -49,11 +49,14 @@ function connectStream(socket: WebSocket) { } } +/** Enforce the filters with certain criteria. */ function prepareFilters(filters: ClientREQ[2][]): Filter[] { return filters.map((filter) => ({ ...filter, + // Limit the number of events returned per-filter. limit: Math.min(filter.limit || FILTER_LIMIT, FILTER_LIMIT), - local: true, + // Return only local events unless the query is already narrow. + local: !filter.ids?.length && !filter.authors?.length, })); } @@ -65,8 +68,8 @@ const relayController: AppController = (c) => { } const { socket, response } = Deno.upgradeWebSocket(c.req.raw); - connectStream(socket); + return response; }; From 4c8a685528a35984d997679fc1c16796b1ccae2c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 15:24:33 -0500 Subject: [PATCH 13/14] relay: allow local users to post to the relay --- src/controllers/nostr/relay.ts | 35 ++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index c2d77ec..8dcfd7d 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -1,6 +1,13 @@ -import { getFilters } from '@/db/events.ts'; +import { getFilters, insertEvent } from '@/db/events.ts'; +import { findUser } from '@/db/users.ts'; import { jsonSchema } from '@/schema.ts'; -import { type ClientMsg, clientMsgSchema, type ClientREQ } from '@/schemas/nostr.ts'; +import { + type ClientCLOSE, + type ClientEVENT, + type ClientMsg, + clientMsgSchema, + type ClientREQ, +} from '@/schemas/nostr.ts'; import type { AppController } from '@/app.ts'; import type { Filter } from '@/deps.ts'; @@ -12,27 +19,29 @@ const FILTER_LIMIT = 100; type RelayMsg = | ['EVENT', string, SignedEvent] | ['NOTICE', string] - | ['EOSE', string]; + | ['EOSE', string] + | ['OK', string, boolean, string]; function connectStream(socket: WebSocket) { socket.onmessage = (e) => { const result = jsonSchema.pipe(clientMsgSchema).safeParse(e.data); if (result.success) { - handleClientMsg(result.data); + handleMsg(result.data); } else { send(['NOTICE', 'Invalid message.']); } }; - function handleClientMsg(msg: ClientMsg) { + function handleMsg(msg: ClientMsg) { switch (msg[0]) { case 'REQ': handleReq(msg); return; case 'EVENT': - send(['NOTICE', 'EVENT not yet implemented.']); + handleEvent(msg); return; case 'CLOSE': + handleClose(msg); return; } } @@ -44,6 +53,20 @@ function connectStream(socket: WebSocket) { send(['EOSE', sub]); } + async function handleEvent([_, event]: ClientEVENT) { + if (await findUser({ pubkey: event.pubkey })) { + insertEvent(event); + send(['OK', event.id, true, '']); + } else { + send(['OK', event.id, false, 'blocked: only registered users can post']); + } + } + + function handleClose([_, _sub]: ClientCLOSE) { + // TODO: ??? + return; + } + function send(msg: RelayMsg) { return socket.send(JSON.stringify(msg)); } From 9da4fb2bba48d2bc1b80e372a40a4f6edf310ce4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 12 Aug 2023 15:45:58 -0500 Subject: [PATCH 14/14] db/events: add comments --- src/db/events.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/db/events.ts b/src/db/events.ts index 4d14f83..4bdea6c 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -15,6 +15,7 @@ const tagConditions: Record = { 't': ({ count }) => count < 5, }; +/** Insert an event (and its tags) into the database. */ function insertEvent(event: SignedEvent): Promise { return db.transaction().execute(async (trx) => { await trx.insertInto('events') @@ -50,10 +51,12 @@ function insertEvent(event: SignedEvent): Promise { }); } +/** Custom filter interface that extends Nostr filters with extra options for Ditto. */ interface DittoFilter extends Filter { local?: boolean; } +/** Build the query for a filter. */ function getFilterQuery(filter: DittoFilter) { let query = db .selectFrom('events') @@ -107,6 +110,7 @@ function getFilterQuery(filter: DittoFilter) { return query; } +/** Get events for filters from the database. */ async function getFilters(filters: [DittoFilter]): Promise[]>; async function getFilters(filters: DittoFilter[]): Promise; async function getFilters(filters: DittoFilter[]) { @@ -121,6 +125,7 @@ async function getFilters(filters: DittoFilter[]) { )); } +/** Get events for a filter from the database. */ function getFilter(filter: DittoFilter): Promise[]> { return getFilters([filter]); }