diff --git a/deno.json b/deno.json index 167176f..b4e834a 100644 --- a/deno.json +++ b/deno.json @@ -7,7 +7,6 @@ "debug": "deno run -A --inspect src/server.ts", "test": "DATABASE_URL=\"sqlite://:memory:\" deno test -A", "check": "deno check src/server.ts", - "relays:sync": "deno run -A scripts/relays.ts sync", "nsec": "deno run scripts/nsec.ts", "admin:event": "deno run -A scripts/admin-event.ts", "admin:role": "deno run -A scripts/admin-role.ts" diff --git a/scripts/relays.ts b/scripts/relays.ts deleted file mode 100644 index 84f8a7e..0000000 --- a/scripts/relays.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { addRelays } from '@/db/relays.ts'; -import { filteredArray } from '@/schema.ts'; -import { relaySchema } from '@/utils.ts'; - -switch (Deno.args[0]) { - case 'sync': - await sync(Deno.args.slice(1)); - break; - default: - console.log('Usage: deno run -A scripts/relays.ts sync '); -} - -async function sync([url]: string[]) { - if (!url) { - console.error('Error: please provide a URL'); - Deno.exit(1); - } - const response = await fetch(url); - const data = await response.json(); - const values = filteredArray(relaySchema).parse(data) as `wss://${string}`[]; - await addRelays(values, { active: true }); - console.log(`Done: added ${values.length} relays.`); -} diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 96d4afa..473dd63 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -1,4 +1,4 @@ -import { NostrFilter } from '@nostrify/nostrify'; +import { NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { z } from 'zod'; @@ -6,7 +6,6 @@ import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; -import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { Storages } from '@/storages.ts'; import { addTag, deleteTag, findReplyTag, getTagSet } from '@/tags.ts'; import { uploadFile } from '@/upload.ts'; @@ -198,7 +197,7 @@ const updateCredentialsController: AppController = async (c) => { } const author = await getAuthor(pubkey); - const meta = author ? jsonMetaContentSchema.parse(author.content) : {}; + const meta = author ? n.json().pipe(n.metadata()).catch({}).parse(author.content) : {}; const { avatar: avatarFile, diff --git a/src/controllers/api/instance.ts b/src/controllers/api/instance.ts index d4239d3..188d68f 100644 --- a/src/controllers/api/instance.ts +++ b/src/controllers/api/instance.ts @@ -1,6 +1,8 @@ +import { NSchema as n } from '@nostrify/nostrify'; + import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { jsonServerMetaSchema } from '@/schemas/nostr.ts'; +import { serverMetaSchema } from '@/schemas/nostr.ts'; import { Storages } from '@/storages.ts'; const instanceController: AppController = async (c) => { @@ -8,7 +10,7 @@ const instanceController: AppController = async (c) => { const { signal } = c.req.raw; const [event] = await Storages.db.query([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], { signal }); - const meta = jsonServerMetaSchema.parse(event?.content); + const meta = n.json().pipe(serverMetaSchema).catch({}).parse(event?.content); /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; diff --git a/src/controllers/api/pleroma.ts b/src/controllers/api/pleroma.ts index 51f48dc..4b693c4 100644 --- a/src/controllers/api/pleroma.ts +++ b/src/controllers/api/pleroma.ts @@ -1,3 +1,4 @@ +import { NSchema as n } from '@nostrify/nostrify'; import { z } from 'zod'; import { type AppController } from '@/app.ts'; @@ -6,7 +7,6 @@ import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/p import { AdminSigner } from '@/signers/AdminSigner.ts'; import { Storages } from '@/storages.ts'; import { createAdminEvent } from '@/utils/api.ts'; -import { jsonSchema } from '@/schema.ts'; const frontendConfigController: AppController = async (c) => { const configs = await getConfigs(c.req.raw.signal); @@ -75,7 +75,7 @@ async function getConfigs(signal: AbortSignal): Promise { try { const decrypted = await new AdminSigner().nip44.decrypt(Conf.pubkey, event.content); - return jsonSchema.pipe(configSchema.array()).catch([]).parse(decrypted); + return n.json().pipe(configSchema.array()).catch([]).parse(decrypted); } catch (_e) { return []; } diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index f595e22..e674d0e 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -1,10 +1,9 @@ -import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; -import { nostrIdSchema } from '@/schemas/nostr.ts'; import { Storages } from '@/storages.ts'; import { dedupeEvents } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; @@ -20,7 +19,7 @@ const searchQuerySchema = z.object({ type: z.enum(['accounts', 'statuses', 'hashtags']).optional(), resolve: booleanParamSchema.optional().transform(Boolean), following: z.boolean().default(false), - account_id: nostrIdSchema.optional(), + account_id: n.id().optional(), limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), }); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 2c05dbd..56ea38b 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -1,4 +1,4 @@ -import { NIP05, NostrEvent, NostrFilter } from '@nostrify/nostrify'; +import { NIP05, NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import ISO6391 from 'iso-639-1'; import { nip19 } from 'nostr-tools'; import { z } from 'zod'; @@ -7,7 +7,6 @@ import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; -import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { addTag, deleteTag } from '@/tags.ts'; import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; import { renderEventAccounts } from '@/views.ts'; @@ -406,7 +405,7 @@ const zapController: AppController = async (c) => { const target = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'], signal }); const author = target?.author; - const meta = jsonMetaContentSchema.parse(author?.content); + const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content); const lnurl = getLnurl(meta); if (target && lnurl) { diff --git a/src/controllers/nostr/relay-info.ts b/src/controllers/nostr/relay-info.ts index 7f1ddf7..a56df51 100644 --- a/src/controllers/nostr/relay-info.ts +++ b/src/controllers/nostr/relay-info.ts @@ -1,12 +1,14 @@ +import { NSchema as n } from '@nostrify/nostrify'; + import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { jsonServerMetaSchema } from '@/schemas/nostr.ts'; +import { serverMetaSchema } from '@/schemas/nostr.ts'; import { Storages } from '@/storages.ts'; const relayInfoController: AppController = async (c) => { const { signal } = c.req.raw; const [event] = await Storages.db.query([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], { signal }); - const meta = jsonServerMetaSchema.parse(event?.content); + const meta = n.json().pipe(serverMetaSchema).catch({}).parse(event?.content); return c.json({ name: meta.name ?? 'Ditto', diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 2fe8f92..7d70ad9 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -1,14 +1,15 @@ -import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; +import { + NostrClientCLOSE, + NostrClientCOUNT, + NostrClientEVENT, + NostrClientMsg, + NostrClientREQ, + NostrEvent, + NostrFilter, + NSchema as n, +} from '@nostrify/nostrify'; import { relayInfoController } from '@/controllers/nostr/relay-info.ts'; import * as pipeline from '@/pipeline.ts'; -import { - type ClientCLOSE, - type ClientCOUNT, - type ClientEVENT, - type ClientMsg, - clientMsgSchema, - type ClientREQ, -} from '@/schemas/nostr.ts'; import { Storages } from '@/storages.ts'; import type { AppController } from '@/app.ts'; @@ -30,7 +31,7 @@ function connectStream(socket: WebSocket) { const controllers = new Map(); socket.onmessage = (e) => { - const result = n.json().pipe(clientMsgSchema).safeParse(e.data); + const result = n.json().pipe(n.clientMsg()).safeParse(e.data); if (result.success) { handleMsg(result.data); } else { @@ -45,7 +46,7 @@ function connectStream(socket: WebSocket) { }; /** Handle client message. */ - function handleMsg(msg: ClientMsg) { + function handleMsg(msg: NostrClientMsg) { switch (msg[0]) { case 'REQ': handleReq(msg); @@ -63,7 +64,7 @@ function connectStream(socket: WebSocket) { } /** Handle REQ. Start a subscription. */ - async function handleReq([_, subId, ...rest]: ClientREQ): Promise { + async function handleReq([_, subId, ...rest]: NostrClientREQ): Promise { const filters = prepareFilters(rest); const controller = new AbortController(); @@ -88,7 +89,7 @@ function connectStream(socket: WebSocket) { } /** Handle EVENT. Store the event. */ - async function handleEvent([_, event]: ClientEVENT): Promise { + async function handleEvent([_, event]: NostrClientEVENT): Promise { try { // This will store it (if eligible) and run other side-effects. await pipeline.handleEvent(event, AbortSignal.timeout(1000)); @@ -104,7 +105,7 @@ function connectStream(socket: WebSocket) { } /** Handle CLOSE. Close the subscription. */ - function handleClose([_, subId]: ClientCLOSE): void { + function handleClose([_, subId]: NostrClientCLOSE): void { const controller = controllers.get(subId); if (controller) { controller.abort(); @@ -113,7 +114,7 @@ function connectStream(socket: WebSocket) { } /** Handle COUNT. Return the number of events matching the filters. */ - async function handleCount([_, subId, ...rest]: ClientCOUNT): Promise { + async function handleCount([_, subId, ...rest]: NostrClientCOUNT): Promise { const { count } = await Storages.db.count(prepareFilters(rest)); send(['COUNT', subId, { count, approximate: false }]); } @@ -127,7 +128,7 @@ function connectStream(socket: WebSocket) { } /** Enforce the filters with certain criteria. */ -function prepareFilters(filters: ClientREQ[2][]): NostrFilter[] { +function prepareFilters(filters: NostrClientREQ[2][]): NostrFilter[] { return filters.map((filter) => { const narrow = Boolean(filter.ids?.length || filter.authors?.length); const search = narrow ? filter.search : `domain:${Conf.url.host} ${filter.search ?? ''}`; diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index 79fec5d..d71f48a 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -2,7 +2,6 @@ export interface DittoTables { events: EventRow; events_fts: EventFTSRow; tags: TagRow; - relays: RelayRow; unattached_media: UnattachedMediaRow; author_stats: AuthorStatsRow; event_stats: EventStatsRow; @@ -45,12 +44,6 @@ interface TagRow { event_id: string; } -interface RelayRow { - url: string; - domain: string; - active: boolean; -} - interface UnattachedMediaRow { id: string; pubkey: string; diff --git a/src/db/migrations/000_create_events.ts b/src/db/migrations/000_create_events.ts index 158551b..f08a614 100644 --- a/src/db/migrations/000_create_events.ts +++ b/src/db/migrations/000_create_events.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema diff --git a/src/db/migrations/001_add_relays.ts b/src/db/migrations/001_add_relays.ts index 1415f5f..11c6884 100644 --- a/src/db/migrations/001_add_relays.ts +++ b/src/db/migrations/001_add_relays.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema diff --git a/src/db/migrations/003_events_admin.ts b/src/db/migrations/003_events_admin.ts index 8469fc2..388a3a4 100644 --- a/src/db/migrations/003_events_admin.ts +++ b/src/db/migrations/003_events_admin.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(_db: Kysely): Promise { } diff --git a/src/db/migrations/004_add_user_indexes.ts b/src/db/migrations/004_add_user_indexes.ts index 929181c..fca9c5f 100644 --- a/src/db/migrations/004_add_user_indexes.ts +++ b/src/db/migrations/004_add_user_indexes.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(_db: Kysely): Promise { } diff --git a/src/db/migrations/006_pragma.ts b/src/db/migrations/006_pragma.ts index 2639e81..f20ee9b 100644 --- a/src/db/migrations/006_pragma.ts +++ b/src/db/migrations/006_pragma.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(_db: Kysely): Promise { } diff --git a/src/db/migrations/007_unattached_media.ts b/src/db/migrations/007_unattached_media.ts index 1887111..a36c5d3 100644 --- a/src/db/migrations/007_unattached_media.ts +++ b/src/db/migrations/007_unattached_media.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema diff --git a/src/db/migrations/008_wal.ts b/src/db/migrations/008_wal.ts index 2639e81..f20ee9b 100644 --- a/src/db/migrations/008_wal.ts +++ b/src/db/migrations/008_wal.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(_db: Kysely): Promise { } diff --git a/src/db/migrations/009_add_stats.ts b/src/db/migrations/009_add_stats.ts index 60d9447..ef1c443 100644 --- a/src/db/migrations/009_add_stats.ts +++ b/src/db/migrations/009_add_stats.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema diff --git a/src/db/migrations/010_drop_users.ts b/src/db/migrations/010_drop_users.ts index 6cd83c0..c36f2fa 100644 --- a/src/db/migrations/010_drop_users.ts +++ b/src/db/migrations/010_drop_users.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema.dropTable('users').ifExists().execute(); diff --git a/src/db/migrations/011_kind_author_index.ts b/src/db/migrations/011_kind_author_index.ts index da21988..3e7d010 100644 --- a/src/db/migrations/011_kind_author_index.ts +++ b/src/db/migrations/011_kind_author_index.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema diff --git a/src/db/migrations/012_tags_composite_index.ts b/src/db/migrations/012_tags_composite_index.ts index 8769289..412fa59 100644 --- a/src/db/migrations/012_tags_composite_index.ts +++ b/src/db/migrations/012_tags_composite_index.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema.dropIndex('idx_tags_tag').execute(); diff --git a/src/db/migrations/013_soft_deletion.ts b/src/db/migrations/013_soft_deletion.ts index 3856ca0..df19da5 100644 --- a/src/db/migrations/013_soft_deletion.ts +++ b/src/db/migrations/013_soft_deletion.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema.alterTable('events').addColumn('deleted_at', 'integer').execute(); diff --git a/src/db/migrations/014_stats_indexes.ts.ts b/src/db/migrations/014_stats_indexes.ts.ts index d9071c6..0f27a7f 100644 --- a/src/db/migrations/014_stats_indexes.ts.ts +++ b/src/db/migrations/014_stats_indexes.ts.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema.createIndex('idx_author_stats_pubkey').on('author_stats').column('pubkey').execute(); diff --git a/src/db/migrations/015_add_pubkey_domains.ts b/src/db/migrations/015_add_pubkey_domains.ts index 0b5fe29..4b7e23c 100644 --- a/src/db/migrations/015_add_pubkey_domains.ts +++ b/src/db/migrations/015_add_pubkey_domains.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema diff --git a/src/db/migrations/016_pubkey_domains_updated_at.ts b/src/db/migrations/016_pubkey_domains_updated_at.ts index 3a000c1..8b1f75d 100644 --- a/src/db/migrations/016_pubkey_domains_updated_at.ts +++ b/src/db/migrations/016_pubkey_domains_updated_at.ts @@ -1,4 +1,4 @@ -import { Kysely } from '@/deps.ts'; +import { Kysely } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema diff --git a/src/db/migrations/017_rm_relays.ts b/src/db/migrations/017_rm_relays.ts new file mode 100644 index 0000000..70a274d --- /dev/null +++ b/src/db/migrations/017_rm_relays.ts @@ -0,0 +1,14 @@ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema.dropTable('relays').execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .createTable('relays') + .addColumn('url', 'text', (col) => col.primaryKey()) + .addColumn('domain', 'text', (col) => col.notNull()) + .addColumn('active', 'boolean', (col) => col.notNull()) + .execute(); +} diff --git a/src/db/relays.ts b/src/db/relays.ts deleted file mode 100644 index da29b79..0000000 --- a/src/db/relays.ts +++ /dev/null @@ -1,37 +0,0 @@ -import tldts from 'tldts'; - -import { db } from '@/db.ts'; - -interface AddRelaysOpts { - active?: boolean; -} - -/** Inserts relays into the database, skipping duplicates. */ -function addRelays(relays: `wss://${string}`[], opts: AddRelaysOpts = {}) { - if (!relays.length) return Promise.resolve(); - const { active = false } = opts; - - const values = relays.map((url) => ({ - url: new URL(url).toString(), - domain: tldts.getDomain(url)!, - active, - })); - - return db.insertInto('relays') - .values(values) - .onConflict((oc) => oc.column('url').doNothing()) - .execute(); -} - -/** Get a list of all known active relay URLs. */ -async function getActiveRelays(): Promise { - const rows = await db - .selectFrom('relays') - .select('relays.url') - .where('relays.active', '=', true) - .execute(); - - return rows.map((row) => row.url); -} - -export { addRelays, getActiveRelays }; diff --git a/src/filter.ts b/src/filter.ts index 6247378..59b0298 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -1,9 +1,8 @@ -import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import stringifyStable from 'fast-stable-stringify'; import { z } from 'zod'; import { isReplaceableKind } from '@/kinds.ts'; -import { nostrIdSchema } from '@/schemas/nostr.ts'; /** Microfilter to get one specific event by ID. */ type IdMicrofilter = { ids: [NostrEvent['id']] }; @@ -42,8 +41,8 @@ function getMicroFilters(event: NostrEvent): MicroFilter[] { /** Microfilter schema. */ const microFilterSchema = z.union([ - z.object({ ids: z.tuple([nostrIdSchema]) }).strict(), - z.object({ kinds: z.tuple([z.literal(0)]), authors: z.tuple([nostrIdSchema]) }).strict(), + z.object({ ids: z.tuple([n.id()]) }).strict(), + z.object({ kinds: z.tuple([z.literal(0)]), authors: z.tuple([n.id()]) }).strict(), ]); /** Checks whether the filter is a microfilter. */ diff --git a/src/pipeline.ts b/src/pipeline.ts index f91626d..b193617 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -5,7 +5,6 @@ import { sql } from 'kysely'; import { Conf } from '@/config.ts'; import { db } from '@/db.ts'; -import { addRelays } from '@/db/relays.ts'; import { deleteAttachedMedia } from '@/db/unattached-media.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { isEphemeralKind } from '@/kinds.ts'; @@ -14,7 +13,7 @@ import { updateStats } from '@/stats.ts'; import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; import { getTagSet } from '@/tags.ts'; -import { eventAge, isRelay, nostrDate, nostrNow, parseNip05, Time } from '@/utils.ts'; +import { eventAge, nostrDate, nostrNow, parseNip05, Time } from '@/utils.ts'; import { fetchWorker } from '@/workers/fetch.ts'; import { TrendsWorker } from '@/workers/trends.ts'; import { verifyEventWorker } from '@/workers/verify.ts'; @@ -59,7 +58,6 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise { } } -/** Tracks known relays in the database. */ -function trackRelays(event: NostrEvent) { - const relays = new Set<`wss://${string}`>(); - - event.tags.forEach((tag) => { - if (['p', 'e', 'a'].includes(tag[0]) && isRelay(tag[2])) { - relays.add(tag[2]); - } - if (event.kind === 10002 && tag[0] === 'r' && isRelay(tag[1])) { - relays.add(tag[1]); - } - }); - - return addRelays([...relays]); -} - /** Queue related events to fetch. */ async function fetchRelatedEvents(event: DittoEvent, signal: AbortSignal) { if (!event.user) { diff --git a/src/pool.ts b/src/pool.ts index 48d5e1f..3ac1a1d 100644 --- a/src/pool.ts +++ b/src/pool.ts @@ -1,8 +1,20 @@ import { RelayPoolWorker } from 'nostr-relaypool'; -import { getActiveRelays } from '@/db/relays.ts'; +import { Storages } from '@/storages.ts'; +import { Conf } from '@/config.ts'; -const activeRelays = await getActiveRelays(); +const [relayList] = await Storages.db.query([ + { kinds: [10002], authors: [Conf.pubkey], limit: 1 }, +]); + +const tags = relayList?.tags ?? []; + +const activeRelays = tags.reduce((acc, [name, url, marker]) => { + if (name === 'r' && !marker) { + acc.push(url); + } + return acc; +}, []); console.log(`pool: connecting to ${activeRelays.length} relays.`); diff --git a/src/schema.ts b/src/schema.ts index 74dc7af..d152a0d 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -11,16 +11,6 @@ function filteredArray(schema: T) { )); } -/** Parses a JSON string into its native type. */ -const jsonSchema = z.string().transform((value, ctx) => { - try { - return JSON.parse(value) as unknown; - } catch (_e) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' }); - return z.NEVER; - } -}); - /** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */ const decode64Schema = z.string().transform((value, ctx) => { try { @@ -48,4 +38,4 @@ const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value /** Schema for `File` objects. */ const fileSchema = z.custom((value) => value instanceof File); -export { booleanParamSchema, decode64Schema, fileSchema, filteredArray, hashtagSchema, jsonSchema, safeUrlSchema }; +export { booleanParamSchema, decode64Schema, fileSchema, filteredArray, hashtagSchema, safeUrlSchema }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 7f51f6c..a42b9f0 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -1,80 +1,14 @@ +import { NSchema as n } from '@nostrify/nostrify'; import { getEventHash, verifyEvent } from 'nostr-tools'; import { z } from 'zod'; -import { jsonSchema, safeUrlSchema } from '@/schema.ts'; - -/** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */ -const nostrIdSchema = z.string().regex(/^[0-9a-f]{64}$/); -/** Nostr kinds are positive integers. */ -const kindSchema = z.number().int().nonnegative(); - -/** Nostr event schema. */ -const eventSchema = z.object({ - id: nostrIdSchema, - kind: kindSchema, - tags: z.array(z.array(z.string())), - content: z.string(), - created_at: z.number(), - pubkey: nostrIdSchema, - sig: z.string(), -}); +import { safeUrlSchema } from '@/schema.ts'; /** Nostr event schema that also verifies the event's signature. */ -const signedEventSchema = eventSchema +const signedEventSchema = n.event() .refine((event) => event.id === getEventHash(event), 'Event ID does not match hash') .refine(verifyEvent, 'Event signature is invalid'); -/** Nostr relay filter schema. */ -const filterSchema = z.object({ - kinds: kindSchema.array().optional(), - ids: nostrIdSchema.array().optional(), - authors: nostrIdSchema.array().optional(), - since: z.number().int().nonnegative().optional(), - until: z.number().int().nonnegative().optional(), - limit: z.number().int().nonnegative().optional(), - search: z.string().optional(), -}).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)]); -const clientCountSchema = z.tuple([z.literal('COUNT'), z.string().min(1)]).rest(filterSchema); - -/** Client message to a Nostr relay. */ -const clientMsgSchema = z.union([ - clientReqSchema, - clientEventSchema, - clientCloseSchema, - clientCountSchema, -]); - -/** 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; -/** COUNT message from client to relay. */ -type ClientCOUNT = z.infer; -/** Client message to a Nostr relay. */ -type ClientMsg = z.infer; - -/** 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), - lud06: z.string().optional().catch(undefined), - lud16: z.string().optional().catch(undefined), -}).partial().passthrough(); - /** Media data schema from `"media"` tags. */ const mediaDataSchema = z.object({ blurhash: z.string().optional().catch(undefined), @@ -88,40 +22,25 @@ const mediaDataSchema = z.object({ }); /** Kind 0 content schema for the Ditto server admin user. */ -const serverMetaSchema = metaContentSchema.extend({ +const serverMetaSchema = n.metadata().and(z.object({ tagline: z.string().optional().catch(undefined), email: z.string().optional().catch(undefined), -}); +})); /** Media data from `"media"` tags. */ type MediaData = z.infer; -/** Parses kind 0 content from a JSON string. */ -const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({}); - -/** Parses media data from a JSON string. */ -const jsonMediaDataSchema = jsonSchema.pipe(mediaDataSchema).catch({}); - -/** Parses server admin meta from a JSON string. */ -const jsonServerMetaSchema = jsonSchema.pipe(serverMetaSchema).catch({}); - /** NIP-11 Relay Information Document. */ const relayInfoDocSchema = z.object({ name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined), description: z.string().transform((val) => val.slice(0, 3000)).optional().catch(undefined), - pubkey: nostrIdSchema.optional().catch(undefined), + pubkey: n.id().optional().catch(undefined), contact: safeUrlSchema.optional().catch(undefined), supported_nips: z.number().int().nonnegative().array().optional().catch(undefined), software: safeUrlSchema.optional().catch(undefined), icon: safeUrlSchema.optional().catch(undefined), }); -/** NIP-46 signer response. */ -const connectResponseSchema = z.object({ - id: z.string(), - result: signedEventSchema, -}); - /** Parses a Nostr emoji tag. */ const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]); @@ -129,23 +48,11 @@ const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url() type EmojiTag = z.infer; export { - type ClientCLOSE, - type ClientCOUNT, - type ClientEVENT, - type ClientMsg, - clientMsgSchema, - type ClientREQ, - connectResponseSchema, type EmojiTag, emojiTagSchema, - filterSchema, - jsonMediaDataSchema, - jsonMetaContentSchema, - jsonServerMetaSchema, type MediaData, mediaDataSchema, - metaContentSchema, - nostrIdSchema, relayInfoDocSchema, + serverMetaSchema, signedEventSchema, }; diff --git a/src/storages/events-db.ts b/src/storages/events-db.ts index 74bcb01..ba34b3c 100644 --- a/src/storages/events-db.ts +++ b/src/storages/events-db.ts @@ -1,4 +1,4 @@ -import { NIP50, NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; +import { NIP50, NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify'; import Debug from '@soapbox/stickynotes/debug'; import { Kysely, type SelectQueryBuilder } from 'kysely'; @@ -7,7 +7,6 @@ import { DittoTables } from '@/db/DittoTables.ts'; import { normalizeFilters } from '@/filter.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { isDittoInternalKind, isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts'; -import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { purifyEvent } from '@/storages/hydrate.ts'; import { isNostrId, isURL } from '@/utils.ts'; import { abortError } from '@/utils/abort.ts'; @@ -412,7 +411,7 @@ function buildSearchContent(event: NostrEvent): string { /** Build search content for a user. */ function buildUserSearchContent(event: NostrEvent): string { - const { name, nip05, about } = jsonMetaContentSchema.parse(event.content); + const { name, nip05, about } = n.json().pipe(n.metadata()).catch({}).parse(event.content); return [name, nip05, about].filter(Boolean).join('\n'); } diff --git a/src/utils.ts b/src/utils.ts index 085d8af..e9213ed 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,17 +1,13 @@ -import { NostrEvent } from '@nostrify/nostrify'; -import { EventTemplate, getEventHash, nip19 } from 'nostr-tools'; +import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; import { z } from 'zod'; -import { nostrIdSchema } from '@/schemas/nostr.ts'; - /** Get the current time in Nostr format. */ const nostrNow = (): number => Math.floor(Date.now() / 1000); + /** Convenience function to convert Nostr dates into native Date objects. */ const nostrDate = (seconds: number): Date => new Date(seconds * 1000); -/** Pass to sort() to sort events by date. */ -const eventDateComparator = (a: NostrEvent, b: NostrEvent): number => b.created_at - a.created_at; - /** Get pubkey from bech32 string, if applicable. */ function bech32ToPubkey(bech32: string): string | undefined { try { @@ -82,74 +78,32 @@ async function sha256(message: string): Promise { return hashHex; } -/** Schema to parse a relay URL. */ -const relaySchema = z.string().max(255).startsWith('wss://').url(); - -/** Check whether the value is a valid relay URL. */ -const isRelay = (relay: string): relay is `wss://${string}` => relaySchema.safeParse(relay).success; - /** Deduplicate events by ID. */ function dedupeEvents(events: NostrEvent[]): NostrEvent[] { return [...new Map(events.map((event) => [event.id, event])).values()]; } -/** Return a copy of the event with the given tags removed. */ -function stripTags(event: E, tags: string[] = []): E { - if (!tags.length) return event; - return { - ...event, - tags: event.tags.filter(([name]) => !tags.includes(name)), - }; -} - -/** Ensure the template and event match on their shared keys. */ -function eventMatchesTemplate(event: NostrEvent, template: EventTemplate): boolean { - const whitelist = ['nonce']; - - event = stripTags(event, whitelist); - template = stripTags(template, whitelist); - - if (template.created_at > event.created_at) { - return false; - } - - return getEventHash(event) === getEventHash({ - pubkey: event.pubkey, - ...template, - created_at: event.created_at, - }); -} - /** Test whether the value is a Nostr ID. */ function isNostrId(value: unknown): boolean { - return nostrIdSchema.safeParse(value).success; + return n.id().safeParse(value).success; } /** Test whether the value is a URL. */ function isURL(value: unknown): boolean { - try { - new URL(value as string); - return true; - } catch (_) { - return false; - } + return z.string().url().safeParse(value).success; } export { bech32ToPubkey, dedupeEvents, eventAge, - eventDateComparator, - eventMatchesTemplate, findTag, isNostrId, - isRelay, isURL, type Nip05, nostrDate, nostrNow, parseNip05, - relaySchema, sha256, }; diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts index 74e60a4..c33da87 100644 --- a/src/utils/nip98.ts +++ b/src/utils/nip98.ts @@ -1,13 +1,13 @@ -import { NostrEvent } from '@nostrify/nostrify'; +import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { EventTemplate, nip13 } from 'nostr-tools'; -import { decode64Schema, jsonSchema } from '@/schema.ts'; +import { decode64Schema } from '@/schema.ts'; import { signedEventSchema } from '@/schemas/nostr.ts'; import { eventAge, findTag, nostrNow, sha256 } from '@/utils.ts'; import { Time } from '@/utils/time.ts'; /** Decode a Nostr event from a base64 encoded string. */ -const decode64EventSchema = decode64Schema.pipe(jsonSchema).pipe(signedEventSchema); +const decode64EventSchema = decode64Schema.pipe(n.json()).pipe(signedEventSchema); interface ParseAuthRequestOpts { /** Max event age (in ms). */ diff --git a/src/views/activitypub/actor.ts b/src/views/activitypub/actor.ts index 9ca9a27..cfd40ba 100644 --- a/src/views/activitypub/actor.ts +++ b/src/views/activitypub/actor.ts @@ -1,5 +1,6 @@ +import { NSchema as n } from '@nostrify/nostrify'; + import { Conf } from '@/config.ts'; -import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { getPublicKeyPem } from '@/utils/rsa.ts'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -7,7 +8,7 @@ import type { Actor } from '@/schemas/activitypub.ts'; /** Nostr metadata event to ActivityPub actor. */ async function renderActor(event: NostrEvent, username: string): Promise { - const content = jsonMetaContentSchema.parse(event.content); + const content = n.json().pipe(n.metadata()).catch({}).parse(event.content); return { type: 'Person', diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 18f3a9d..e00856e 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -1,9 +1,9 @@ +import { NSchema as n } from '@nostrify/nostrify'; import { nip19, UnsignedEvent } from 'nostr-tools'; import { Conf } from '@/config.ts'; import { lodash } from '@/deps.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { getLnurl } from '@/utils/lnurl.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; @@ -28,7 +28,7 @@ async function renderAccount( about, lud06, lud16, - } = jsonMetaContentSchema.parse(event.content); + } = n.json().pipe(n.metadata()).catch({}).parse(event.content); const npub = nip19.npubEncode(pubkey); const parsed05 = await parseAndVerifyNip05(nip05, pubkey); diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index f63c501..683c667 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -1,11 +1,10 @@ -import { NostrEvent } from '@nostrify/nostrify'; +import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; import { nip19 } from 'nostr-tools'; import { Conf } from '@/config.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { getMediaLinks, parseNoteContent } from '@/note.ts'; -import { jsonMediaDataSchema } from '@/schemas/nostr.ts'; import { Storages } from '@/storages.ts'; import { findReplyTag } from '@/tags.ts'; import { nostrDate } from '@/utils.ts'; @@ -13,6 +12,7 @@ import { unfurlCardCached } from '@/utils/unfurl.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts'; +import { mediaDataSchema } from '@/schemas/nostr.ts'; interface statusOpts { viewerPubkey?: string; @@ -78,7 +78,7 @@ async function renderStatus(event: DittoEvent, opts: statusOpts): Promise { const mediaTags: DittoAttachment[] = event.tags .filter((tag) => tag[0] === 'media') - .map(([_, url, json]) => ({ url, data: jsonMediaDataSchema.parse(json) })); + .map(([_, url, json]) => ({ url, data: n.json().pipe(mediaDataSchema).parse(json) })); const media = [...mediaLinks, ...mediaTags]; diff --git a/src/workers/trends.worker.ts b/src/workers/trends.worker.ts index 819883f..33fd1a1 100644 --- a/src/workers/trends.worker.ts +++ b/src/workers/trends.worker.ts @@ -1,8 +1,8 @@ +import { NSchema } from '@nostrify/nostrify'; import * as Comlink from 'comlink'; import { Sqlite } from '@/deps.ts'; import { hashtagSchema } from '@/schema.ts'; -import { nostrIdSchema } from '@/schemas/nostr.ts'; import { generateDateRange, Time } from '@/utils/time.ts'; interface GetTrendingTagsOpts { @@ -102,7 +102,7 @@ export const TrendsWorker = { }, addTagUsages(pubkey: string, hashtags: string[], date = new Date()): void { - const pubkey8 = nostrIdSchema.parse(pubkey).substring(0, 8); + const pubkey8 = NSchema.id().parse(pubkey).substring(0, 8); const tags = hashtagSchema.array().min(1).parse(hashtags); db.query(