diff --git a/index.ts b/index.ts deleted file mode 100644 index 2182675..0000000 --- a/index.ts +++ /dev/null @@ -1,1098 +0,0 @@ -import { bytesToHex, hexToBytes } from '@noble/ciphers/utils'; -import { randomBytes } from '@noble/ciphers/webcrypto'; -import { sha512 } from '@noble/hashes/sha2'; -import * as nostrTools from '@nostr/tools'; -import { base64 } from '@scure/base'; -import { Database } from 'jsr:@db/sqlite'; -import { NSchema as n } from 'jsr:@nostrify/nostrify'; -import type { - NostrClientREQ, - NostrEvent, - NostrFilter, -} from 'jsr:@nostrify/types'; -import { encodeBase64 } from 'jsr:@std/encoding@0.224/base64'; -import { - CHUNK_CLEANUP_INTERVAL, - CHUNK_MAX_AGE, - POW_TO_MINE, -} from './consts.ts'; -import { - ChunkedEventReceived, - EventAlreadyExistsException, - createEncryptedEvent, - createEncryptedEventForPubkey, - decryptEvent, -} from './eventEncryptionDecryption.ts'; -import { - createNewCCN, - getActiveCCN, - getAllCCNs, - getCCNPrivateKeyByPubkey, - isAddressableEvent, - isArray, - isCCNReplaceableEvent, - isLocalhost, - isReplaceableEvent, - isValidJSON, - parseATagQuery, -} from './utils.ts'; -import { encryptUint8Array, encryptionKey } from './utils/encryption.ts'; -import { getEveFilePath } from './utils/files.ts'; -import { log, setupLogger } from './utils/logs.ts'; -import { mixQuery, sql, sqlPartial } from './utils/queries.ts'; - -await setupLogger(); - -if (!Deno.env.has('ENCRYPTION_KEY')) { - log.error( - `Missing ENCRYPTION_KEY. Please set it in your env.\nA new one has been generated for you: ENCRYPTION_KEY="${encodeBase64( - randomBytes(32), - )}"`, - ); - Deno.exit(1); -} - -await Deno.mkdir(await getEveFilePath('ccn_keys'), { recursive: true }); - -const db = new Database(await getEveFilePath('db')); -const pool = new nostrTools.SimplePool(); -const relays = [ - 'wss://relay.arx-ccn.com/', - 'wss://relay.dannymorabito.com/', - 'wss://nos.lol/', - 'wss://nostr.einundzwanzig.space/', - 'wss://nostr.massmux.com/', - 'wss://nostr.mom/', - 'wss://nostr.wine/', - 'wss://purplerelay.com/', - 'wss://relay.damus.io/', - 'wss://relay.goodmorningbitcoin.com/', - 'wss://relay.lexingtonbitcoin.org/', - 'wss://relay.nostr.band/', - 'wss://relay.primal.net/', - 'wss://relay.snort.social/', - 'wss://strfry.iris.to/', - 'wss://cache2.primal.net/v1', -]; - -export function runMigrations(db: Database, latestVersion: number) { - const migrations = [...Deno.readDirSync(`${import.meta.dirname}/migrations`)]; - migrations.sort((a, b) => { - const aVersion = Number.parseInt(a.name.split('-')[0], 10); - const bVersion = Number.parseInt(b.name.split('-')[0], 10); - return aVersion - bVersion; - }); - for (const migrationFile of migrations) { - const migrationVersion = Number.parseInt( - migrationFile.name.split('-')[0], - 10, - ); - - if (migrationVersion > latestVersion) { - log.info( - `Running migration ${migrationFile.name} (version ${migrationVersion})`, - ); - const start = Date.now(); - const migrationSql = Deno.readTextFileSync( - `${import.meta.dirname}/migrations/${migrationFile.name}`, - ); - db.run('BEGIN TRANSACTION'); - try { - db.run(migrationSql); - const end = Date.now(); - const durationMs = end - start; - sql` - INSERT INTO migration_history (migration_version, migration_name, executed_at, duration_ms, status) VALUES (${migrationVersion}, ${migrationFile.name}, ${new Date().toISOString()}, ${durationMs}, 'success'); - db.run("COMMIT TRANSACTION"); - `(db); - } catch (e) { - db.run('ROLLBACK TRANSACTION'); - const error = - e instanceof Error - ? e - : typeof e === 'string' - ? new Error(e) - : new Error(JSON.stringify(e)); - const end = Date.now(); - const durationMs = end - start; - sql` - INSERT INTO migration_history (migration_version, migration_name, executed_at, duration_ms, status, error_message) VALUES (${migrationVersion}, ${migrationFile.name}, ${new Date().toISOString()}, ${durationMs}, 'failed', ${error.message}); - `(db); - throw e; - } - db.run('END TRANSACTION'); - } - } -} - -function addEventToDb( - decryptedEvent: nostrTools.VerifiedEvent, - encryptedEvent: nostrTools.VerifiedEvent, - ccnPubkey: string, -) { - const existingEvent = sql` - SELECT * FROM events WHERE id = ${decryptedEvent.id} - `(db)[0]; - - if (existingEvent) throw new EventAlreadyExistsException(); - - const isInvite = - decryptedEvent.tags.findIndex( - (tag: string[]) => tag[0] === 'type' && tag[1] === 'invite', - ) !== -1; - - if (isInvite) { - const shadContent = bytesToHex( - sha512.create().update(decryptedEvent.content).digest(), - ); - - const inviteUsed = sql` - SELECT COUNT(*) as count FROM inviter_invitee WHERE invite_hash = ${shadContent} - `(db)[0].count; - - if (inviteUsed > 0) { - throw new Error('Invite already used'); - } - - const inviteEvent = sql` - SELECT * FROM events WHERE kind = 9999 AND id IN ( - SELECT event_id FROM event_tags WHERE tag_name = 'i' AND tag_id IN ( - SELECT tag_id FROM event_tags_values WHERE value_position = 1 AND value = ${shadContent} - ) - ) - `(db)[0]; - - if (!inviteEvent) { - throw new Error('Invite event not found'); - } - - const inviterPubkey = inviteEvent.pubkey; - const inviteePubkey = decryptedEvent.pubkey; - - db.run('BEGIN TRANSACTION'); - - sql` - INSERT INTO inviter_invitee (ccn_pubkey, inviter_pubkey, invitee_pubkey, invite_hash) VALUES (${ccnPubkey}, ${inviterPubkey}, ${inviteePubkey}, ${shadContent}) - `(db); - - sql` - INSERT INTO allowed_writes (ccn_pubkey, pubkey) VALUES (${ccnPubkey}, ${inviteePubkey}) - `(db); - - db.run('COMMIT TRANSACTION'); - - const allowedPubkeys = sql` - SELECT pubkey FROM allowed_writes WHERE ccn_pubkey = ${ccnPubkey} - `(db).flatMap((row) => row.pubkey); - const ccnName = sql` - SELECT name FROM ccns WHERE pubkey = ${ccnPubkey} - `(db)[0].name; - - getCCNPrivateKeyByPubkey(ccnPubkey).then((ccnPrivateKey) => { - if (!ccnPrivateKey) { - throw new Error('CCN private key not found'); - } - - const tags = allowedPubkeys.map((pubkey) => ['p', pubkey]); - tags.push(['t', 'invite']); - tags.push(['name', ccnName]); - - const privateKeyEvent = nostrTools.finalizeEvent( - nostrTools.nip13.minePow( - { - kind: 9998, - created_at: Date.now(), - content: base64.encode(ccnPrivateKey), - tags, - pubkey: ccnPubkey, - }, - POW_TO_MINE, - ), - ccnPrivateKey, - ); - - const encryptedKeyEvent = createEncryptedEventForPubkey( - inviteePubkey, - privateKeyEvent, - ); - publishToRelays(encryptedKeyEvent); - }); - - return; - } - - const isAllowedWrite = sql` - SELECT COUNT(*) as count FROM allowed_writes WHERE ccn_pubkey = ${ccnPubkey} AND pubkey = ${decryptedEvent.pubkey} - `(db)[0].count; - - if (isAllowedWrite === 0) { - throw new Error('Not allowed to write to this CCN'); - } - - try { - db.run('BEGIN TRANSACTION'); - - if (isReplaceableEvent(decryptedEvent.kind)) { - sql` - UPDATE events - SET replaced = 1 - WHERE kind = ${decryptedEvent.kind} - AND pubkey = ${decryptedEvent.pubkey} - AND created_at < ${decryptedEvent.created_at} - AND ccn_pubkey = ${ccnPubkey} - `(db); - } - - if (isAddressableEvent(decryptedEvent.kind)) { - const dTag = decryptedEvent.tags.find((tag) => tag[0] === 'd')?.[1]; - if (dTag) { - sql` - UPDATE events - SET replaced = 1 - WHERE kind = ${decryptedEvent.kind} - AND pubkey = ${decryptedEvent.pubkey} - AND created_at < ${decryptedEvent.created_at} - AND ccn_pubkey = ${ccnPubkey} - AND id IN ( - SELECT event_id FROM event_tags - WHERE tag_name = 'd' - AND tag_id IN ( - SELECT tag_id FROM event_tags_values - WHERE value_position = 1 - AND value = ${dTag} - ) - ) - `(db); - } - } - - if (isCCNReplaceableEvent(decryptedEvent.kind)) { - const dTag = decryptedEvent.tags.find((tag) => tag[0] === 'd')?.[1]; - sql` - UPDATE events - SET replaced = 1 - WHERE kind = ${decryptedEvent.kind} - AND created_at < ${decryptedEvent.created_at} - AND ccn_pubkey = ${ccnPubkey} - AND id IN ( - SELECT event_id FROM event_tags - WHERE tag_name = 'd' - AND tag_id IN ( - SELECT tag_id FROM event_tags_values - WHERE value_position = 1 - AND value = ${dTag} - ) - ) - `(db); - } - - sql` - INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen, ccn_pubkey) VALUES ( - ${decryptedEvent.id}, - ${encryptedEvent.id}, - ${decryptedEvent.pubkey}, - ${decryptedEvent.created_at}, - ${decryptedEvent.kind}, - ${decryptedEvent.content}, - ${decryptedEvent.sig}, - unixepoch(), - ${ccnPubkey} - ) - `(db); - if (decryptedEvent.tags) { - for (let i = 0; i < decryptedEvent.tags.length; i++) { - const tag = sql` - INSERT INTO event_tags(event_id, tag_name, tag_index) VALUES ( - ${decryptedEvent.id}, - ${decryptedEvent.tags[i][0]}, - ${i} - ) RETURNING tag_id - `(db)[0]; - for (let j = 1; j < decryptedEvent.tags[i].length; j++) { - sql` - INSERT INTO event_tags_values(tag_id, value_position, value) VALUES ( - ${tag.tag_id}, - ${j}, - ${decryptedEvent.tags[i][j]} - ) - `(db); - } - } - } - db.run('COMMIT TRANSACTION'); - } catch (e) { - db.run('ROLLBACK TRANSACTION'); - throw e; - } -} - -function encryptedEventIsInDb(event: nostrTools.VerifiedEvent) { - return sql` - SELECT * FROM events WHERE original_id = ${event.id} - `(db)[0]; -} - -function cleanupOldChunks() { - const cutoffTime = Math.floor((Date.now() - CHUNK_MAX_AGE) / 1000); - sql`DELETE FROM event_chunks WHERE created_at < ${cutoffTime}`(db); -} - -let knownOriginalEventsCache: string[] = []; - -function updateKnownEventsCache() { - knownOriginalEventsCache = sql`SELECT original_id FROM events`(db).flatMap( - (row) => row.original_id, - ); -} - -/** - * Creates a subscription event handler for processing encrypted events. - * This handler decrypts and adds valid events to the database. - * @param database The database instance to use - * @returns An event handler function - */ -function createSubscriptionEventHandler(database: Database) { - return async (event: nostrTools.Event) => { - if (knownOriginalEventsCache.indexOf(event.id) >= 0) return; - if (!nostrTools.verifyEvent(event)) { - log.warn('Invalid event received'); - return; - } - if (encryptedEventIsInDb(event)) return; - try { - const decryptedEvent = await decryptEvent(database, event); - addEventToDb(decryptedEvent, event, decryptedEvent.ccnPubkey); - updateKnownEventsCache(); - } catch (e) { - if (e instanceof EventAlreadyExistsException) return; - if (e instanceof ChunkedEventReceived) { - return; - } - } - }; -} - -/** - * Publishes an event to relays, handling both single events and chunked events - * @param encryptedEvent The encrypted event or array of chunked events - */ -async function publishToRelays( - encryptedEvent: nostrTools.Event | nostrTools.Event[], -): Promise { - if (Array.isArray(encryptedEvent)) { - for (const chunk of encryptedEvent) { - await Promise.any(pool.publish(relays, chunk)); - } - } else { - await Promise.any(pool.publish(relays, encryptedEvent)); - } -} - -function setupAndSubscribeToExternalEvents() { - const isInitialized = sql` - SELECT name FROM sqlite_master WHERE type='table' AND name='migration_history' - `(db)[0]; - - if (!isInitialized) runMigrations(db, -1); - - const latestVersion = - sql` - SELECT migration_version FROM migration_history WHERE status = 'success' ORDER BY migration_version DESC LIMIT 1 - `(db)[0]?.migration_version ?? -1; - - runMigrations(db, latestVersion); - - const allCCNs = sql`SELECT pubkey FROM ccns`(db); - const ccnPubkeys = allCCNs.map((ccn) => ccn.pubkey); - - pool.subscribeMany( - relays, - [ - { - '#p': ccnPubkeys, - kinds: [1059], - }, - ], - { - onevent: createSubscriptionEventHandler(db), - }, - ); - - updateKnownEventsCache(); - setInterval(cleanupOldChunks, CHUNK_CLEANUP_INTERVAL); -} - -await setupAndSubscribeToExternalEvents(); - -class UserConnection { - public socket: WebSocket; - public subscriptions: Map; - public db: Database; - - constructor( - socket: WebSocket, - subscriptions: Map, - db: Database, - ) { - this.socket = socket; - this.subscriptions = subscriptions; - this.db = db; - } - - /** - * Sends a response to the client - * @param responseArray The response array to send - */ - sendResponse(responseArray: unknown[]): void { - this.socket.send(JSON.stringify(responseArray)); - } - - /** - * Sends a notice to the client - * @param message The message to send - */ - sendNotice(message: string): void { - this.sendResponse(['NOTICE', message]); - } - - /** - * Sends an event to the client - * @param subscriptionId The subscription ID - * @param event The event to send - */ - sendEvent(subscriptionId: string, event: NostrEvent): void { - this.sendResponse(['EVENT', subscriptionId, event]); - } - - /** - * Sends an end of stored events message - * @param subscriptionId The subscription ID - */ - sendEOSE(subscriptionId: string): void { - this.sendResponse(['EOSE', subscriptionId]); - } - - /** - * Sends an OK response - * @param eventId The event ID - * @param success Whether the operation was successful - * @param message The message to send - */ - sendOK(eventId: string, success: boolean, message: string): void { - this.sendResponse(['OK', eventId, success, message]); - } -} - -function filtersMatchingEvent( - event: NostrEvent, - connection: UserConnection, -): string[] { - const matching = []; - for (const subscription of connection.subscriptions.keys()) { - const filters = connection.subscriptions.get(subscription); - if (!filters) continue; - const isMatching = filters.every((filter) => - Object.entries(filter).every(([type, value]) => { - if (type === 'ids') return value.includes(event.id); - if (type === 'kinds') return value.includes(event.kind); - if (type === 'authors') return value.includes(event.pubkey); - if (type === 'since') return event.created_at >= value; - if (type === 'until') return event.created_at <= value; - if (type === 'limit') return event.created_at <= value; - if (type.startsWith('#')) { - const tagName = type.slice(1); - return event.tags.some( - (tag: string[]) => tag[0] === tagName && value.includes(tag[1]), - ); - } - return false; - }), - ); - if (isMatching) matching.push(subscription); - } - return matching; -} - -function handleRequest(connection: UserConnection, request: NostrClientREQ) { - const [, subscriptionId, ...filters] = request; - if (connection.subscriptions.has(subscriptionId)) { - return log.warn('Duplicate subscription ID'); - } - - log.info( - `New subscription: ${subscriptionId} with filters: ${JSON.stringify( - filters, - )}`, - ); - - const activeCCN = getActiveCCN(connection.db); - if (!activeCCN) { - connection.sendNotice('No active CCN found'); - return log.warn('No active CCN found'); - } - - let query = sqlPartial`SELECT * FROM events WHERE replaced = 0 AND ccn_pubkey = ${activeCCN.pubkey}`; - - const filtersAreNotEmpty = filters.some((filter) => { - return Object.values(filter).some((value) => { - return value.length > 0; - }); - }); - - if (filtersAreNotEmpty) { - query = mixQuery(query, sqlPartial`AND`); - - for (let i = 0; i < filters.length; i++) { - // filters act as OR, filter groups act as AND - query = mixQuery(query, sqlPartial`(`); - - const filter = Object.entries(filters[i]).filter(([type, value]) => { - if (type === 'ids') return value.length > 0; - if (type === 'authors') return value.length > 0; - if (type === 'kinds') return value.length > 0; - if (type.startsWith('#')) return value.length > 0; - if (type === 'since') return value > 0; - if (type === 'until') return value > 0; - return false; - }); - - for (let j = 0; j < filter.length; j++) { - const [type, value] = filter[j]; - - if (type === 'ids') { - const uniqueIds = [...new Set(value)]; - query = mixQuery(query, sqlPartial`id IN (`); - for (let k = 0; k < uniqueIds.length; k++) { - const id = uniqueIds[k] as string; - - query = mixQuery(query, sqlPartial`${id}`); - - if (k < uniqueIds.length - 1) { - query = mixQuery(query, sqlPartial`,`); - } - } - query = mixQuery(query, sqlPartial`)`); - } - - if (type === 'authors') { - const uniqueAuthors = [...new Set(value)]; - query = mixQuery(query, sqlPartial`pubkey IN (`); - for (let k = 0; k < uniqueAuthors.length; k++) { - const author = uniqueAuthors[k] as string; - - query = mixQuery(query, sqlPartial`${author}`); - - if (k < uniqueAuthors.length - 1) { - query = mixQuery(query, sqlPartial`,`); - } - } - query = mixQuery(query, sqlPartial`)`); - } - - if (type === 'kinds') { - const uniqueKinds = [...new Set(value)]; - query = mixQuery(query, sqlPartial`kind IN (`); - for (let k = 0; k < uniqueKinds.length; k++) { - const kind = uniqueKinds[k] as number; - - query = mixQuery(query, sqlPartial`${kind}`); - - if (k < uniqueKinds.length - 1) { - query = mixQuery(query, sqlPartial`,`); - } - } - query = mixQuery(query, sqlPartial`)`); - } - - if (type.startsWith('#')) { - const tag = type.slice(1); - const uniqueValues = [...new Set(value)]; - query = mixQuery(query, sqlPartial`(`); - for (let k = 0; k < uniqueValues.length; k++) { - const tagValue = uniqueValues[k] as string; - if (tag === 'a') { - const aTagInfo = parseATagQuery(tagValue); - - if (aTagInfo.dTag && aTagInfo.dTag !== '') { - if (isCCNReplaceableEvent(aTagInfo.kind)) { - // CCN replaceable event reference - query = mixQuery( - query, - sqlPartial`id IN ( - SELECT e.id - FROM events e - JOIN event_tags t ON e.id = t.event_id - JOIN event_tags_values v ON t.tag_id = v.tag_id - WHERE e.kind = ${aTagInfo.kind} - AND t.tag_name = 'd' - AND v.value_position = 1 - AND v.value = ${aTagInfo.dTag} - )`, - ); - } else { - // Addressable event reference - query = mixQuery( - query, - sqlPartial`id IN ( - SELECT e.id - FROM events e - JOIN event_tags t ON e.id = t.event_id - JOIN event_tags_values v ON t.tag_id = v.tag_id - WHERE e.kind = ${aTagInfo.kind} - AND e.pubkey = ${aTagInfo.pubkey} - AND t.tag_name = 'd' - AND v.value_position = 1 - AND v.value = ${aTagInfo.dTag} - )`, - ); - } - } else { - // Replaceable event reference - query = mixQuery( - query, - sqlPartial`id IN ( - SELECT id - FROM events - WHERE kind = ${aTagInfo.kind} - AND pubkey = ${aTagInfo.pubkey} - )`, - ); - } - } else { - // Regular tag handling (unchanged) - query = mixQuery( - query, - sqlPartial`id IN ( - SELECT t.event_id - FROM event_tags t - WHERE t.tag_name = ${tag} - AND t.tag_id IN ( - SELECT v.tag_id - FROM event_tags_values v - WHERE v.value_position = 1 - AND v.value = ${tagValue} - ) - )`, - ); - } - - if (k < uniqueValues.length - 1) { - query = mixQuery(query, sqlPartial`OR`); - } - } - query = mixQuery(query, sqlPartial`)`); - } - - if (type === 'since') { - query = mixQuery(query, sqlPartial`created_at >= ${value}`); - } - - if (type === 'until') { - query = mixQuery(query, sqlPartial`created_at <= ${value}`); - } - - if (j < filter.length - 1) query = mixQuery(query, sqlPartial`AND`); - } - - query = mixQuery(query, sqlPartial`)`); - - if (i < filters.length - 1) query = mixQuery(query, sqlPartial`OR`); - } - } - - query = mixQuery(query, sqlPartial`ORDER BY created_at ASC`); - - log.debug(query.query, ...query.values); - - const events = connection.db.prepare(query.query).all(...query.values); - - for (let i = 0; i < events.length; i++) { - const rawTags = sql`SELECT * FROM event_tags_view WHERE event_id = ${ - events[i].id - }`(connection.db); - const tagsByIndex = new Map< - number, - { - name: string; - values: Map; - } - >(); - - for (const tag of rawTags) { - let tagData = tagsByIndex.get(tag.tag_index); - if (!tagData) { - tagData = { - name: tag.tag_name, - values: new Map(), - }; - tagsByIndex.set(tag.tag_index, tagData); - } - - tagData.values.set(tag.tag_value_position, tag.tag_value); - } - - const tagsArray = Array.from(tagsByIndex.entries()) - .sort(([indexA], [indexB]) => indexA - indexB) - .map(([_, tagData]) => { - const { name, values } = tagData; - - return [ - name, - ...Array.from(values.entries()) - .sort(([posA], [posB]) => posA - posB) - .map(([_, value]) => value), - ]; - }); - - const event = { - id: events[i].id, - pubkey: events[i].pubkey, - created_at: events[i].created_at, - kind: events[i].kind, - tags: tagsArray, - content: events[i].content, - sig: events[i].sig, - }; - - connection.sendEvent(subscriptionId, event); - } - connection.sendEOSE(subscriptionId); - - connection.subscriptions.set(subscriptionId, filters); -} - -async function handleEvent( - connection: UserConnection, - event: nostrTools.Event, -) { - const valid = nostrTools.verifyEvent(event); - if (!valid) { - connection.sendNotice('Invalid event'); - return log.warn('Invalid event'); - } - - const activeCCN = getActiveCCN(connection.db); - if (!activeCCN) { - connection.sendNotice('No active CCN found'); - return log.warn('No active CCN found'); - } - - const encryptedEvent = await createEncryptedEvent(event, connection.db); - try { - if (Array.isArray(encryptedEvent)) { - await publishToRelays(encryptedEvent); - addEventToDb(event, encryptedEvent[0], activeCCN.pubkey); - } else { - addEventToDb(event, encryptedEvent, activeCCN.pubkey); - await publishToRelays(encryptedEvent); - } - } catch (e) { - if (e instanceof EventAlreadyExistsException) { - log.warn('Event already exists'); - return; - } - } - - connection.sendOK(event.id, true, 'Event added'); - - const filtersThatMatchEvent = filtersMatchingEvent(event, connection); - - for (let i = 0; i < filtersThatMatchEvent.length; i++) { - const filter = filtersThatMatchEvent[i]; - connection.sendEvent(filter, event); - } -} - -function handleClose(connection: UserConnection, subscriptionId: string) { - if (!connection.subscriptions.has(subscriptionId)) { - return log.warn( - `Closing unknown subscription? That's weird. Subscription ID: ${subscriptionId}`, - ); - } - - connection.subscriptions.delete(subscriptionId); -} - -/** - * Activates a CCN by setting it as the active one in the database - * @param database The database instance to use - * @param pubkey The public key of the CCN to activate - */ -function activateCCN(database: Database, pubkey: string): void { - sql`UPDATE ccns SET is_active = 0`(database); - sql`UPDATE ccns SET is_active = 1 WHERE pubkey = ${pubkey}`(database); -} - -/** - * Handles errors in socket operations, logs them and sends a notification to the client - * @param connection The WebSocket connection - * @param operation The operation that failed - * @param error The error that occurred - */ -function handleSocketError( - connection: UserConnection, - operation: string, - error: unknown, -): void { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - log.error(`Error ${operation}: ${errorMessage}`); - connection.sendNotice(`Failed to ${operation}`); -} - -async function handleCreateCCN( - connection: UserConnection, - data: { name: string; seed?: string; creator: string }, -): Promise { - try { - if (!data.name || typeof data.name !== 'string') { - connection.sendNotice('Name is required'); - return; - } - - if (!data.creator || typeof data.creator !== 'string') { - connection.sendNotice('Creator is required'); - return; - } - - const newCcn = await createNewCCN( - connection.db, - data.name, - data.creator, - data.seed, - ); - - activateCCN(connection.db, newCcn.pubkey); - - pool.subscribeMany( - relays, - [ - { - '#p': [newCcn.pubkey], - kinds: [1059], - }, - ], - { - onevent: createSubscriptionEventHandler(connection.db), - }, - ); - - connection.sendResponse([ - 'OK', - 'CCN CREATED', - true, - JSON.stringify({ - pubkey: newCcn.pubkey, - name: data.name, - }), - ]); - - log.info(`CCN created: ${data.name}`); - } catch (error: unknown) { - handleSocketError(connection, 'create CCN', error); - } -} - -function handleGetCCNs(connection: UserConnection): void { - try { - const ccns = getAllCCNs(connection.db); - connection.sendResponse(['OK', 'CCN LIST', true, JSON.stringify(ccns)]); - } catch (error: unknown) { - handleSocketError(connection, 'get CCNs', error); - } -} - -function handleActivateCCN( - connection: UserConnection, - data: { pubkey: string }, -): void { - try { - if (!data.pubkey || typeof data.pubkey !== 'string') { - connection.sendNotice('CCN pubkey is required'); - return; - } - - const ccnExists = sql` - SELECT COUNT(*) as count FROM ccns WHERE pubkey = ${data.pubkey} - `(connection.db)[0].count; - - if (ccnExists === 0) { - connection.sendNotice('CCN not found'); - return; - } - - for (const subscriptionId of connection.subscriptions.keys()) { - connection.sendResponse([ - 'CLOSED', - subscriptionId, - 'Subscription closed due to CCN activation', - ]); - } - - connection.subscriptions.clear(); - log.info('All subscriptions cleared due to CCN activation'); - - activateCCN(connection.db, data.pubkey); - - const activatedCCN = sql` - SELECT pubkey, name FROM ccns WHERE pubkey = ${data.pubkey} - `(connection.db)[0]; - - connection.sendResponse([ - 'OK', - 'CCN ACTIVATED', - true, - JSON.stringify(activatedCCN), - ]); - - log.info(`CCN activated: ${activatedCCN.name}`); - } catch (error: unknown) { - handleSocketError(connection, 'activate CCN', error); - } -} - -async function handleAddCCN( - connection: UserConnection, - data: { name: string; allowedPubkeys: string[]; privateKey: string }, -): Promise { - try { - if (!data.privateKey || typeof data.privateKey !== 'string') { - connection.sendNotice('CCN private key is required'); - return; - } - - const privateKeyBytes = hexToBytes(data.privateKey); - const pubkey = nostrTools.getPublicKey(privateKeyBytes); - - const ccnExists = sql` - SELECT COUNT(*) as count FROM ccns WHERE pubkey = ${pubkey} - `(connection.db)[0].count; - - if (ccnExists > 0) { - connection.sendNotice('CCN already exists'); - return; - } - - const ccnPublicKey = nostrTools.getPublicKey(privateKeyBytes); - const ccnPrivPath = await getEveFilePath(`ccn_keys/${ccnPublicKey}`); - const encryptedPrivateKey = encryptUint8Array( - privateKeyBytes, - encryptionKey, - ); - Deno.writeTextFileSync(ccnPrivPath, encodeBase64(encryptedPrivateKey)); - - db.run('BEGIN TRANSACTION'); - - sql`INSERT INTO ccns (pubkey, name) VALUES (${ccnPublicKey}, ${data.name})`( - db, - ); - for (const allowedPubkey of data.allowedPubkeys) - sql`INSERT INTO allowed_writes (ccn_pubkey, pubkey) VALUES (${ccnPublicKey}, ${allowedPubkey})`( - db, - ); - - db.run('COMMIT TRANSACTION'); - - activateCCN(connection.db, ccnPublicKey); - - pool.subscribeMany( - relays, - [ - { - '#p': [ccnPublicKey], - kinds: [1059], - }, - ], - { - onevent: createSubscriptionEventHandler(connection.db), - }, - ); - - connection.sendResponse([ - 'OK', - 'CCN ADDED', - true, - JSON.stringify({ - pubkey: ccnPublicKey, - name: 'New CCN', - }), - ]); - } catch (error: unknown) { - handleSocketError(connection, 'ADD CCN', error); - } -} - -function handleCCNCommands( - connection: UserConnection, - command: string, - data: unknown, -) { - switch (command) { - case 'CREATE': - return handleCreateCCN( - connection, - data as { name: string; seed?: string; creator: string }, - ); - case 'ADD': - return handleAddCCN( - connection, - data as { name: string; allowedPubkeys: string[]; privateKey: string }, - ); - case 'LIST': - return handleGetCCNs(connection); - case 'ACTIVATE': - return handleActivateCCN(connection, data as { pubkey: string }); - default: - return log.warn('Invalid CCN command'); - } -} - -Deno.serve({ - port: 6942, - handler: (request) => { - if (request.headers.get('upgrade') === 'websocket') { - if (!isLocalhost(request)) { - return new Response( - 'Forbidden. Please read the Arx-CCN documentation for more information on how to interact with the relay.', - { status: 403 }, - ); - } - - const { socket, response } = Deno.upgradeWebSocket(request); - - const connection = new UserConnection(socket, new Map(), db); - - socket.onopen = () => log.info('User connected'); - socket.onmessage = (event) => { - log.debug(`Received: ${event.data}`); - if (typeof event.data !== 'string' || !isValidJSON(event.data)) { - return log.warn('Invalid request'); - } - const data = JSON.parse(event.data); - if (!isArray(data)) return log.warn('Invalid request'); - - const msgType = data[0]; - switch (msgType) { - case 'REQ': - return handleRequest(connection, n.clientREQ().parse(data)); - case 'EVENT': - return handleEvent(connection, n.clientEVENT().parse(data)[1]); - case 'CLOSE': - return handleClose(connection, n.clientCLOSE().parse(data)[1]); - case 'CCN': - return handleCCNCommands(connection, data[1] as string, data[2]); - default: - return log.warn('Invalid request'); - } - }; - socket.onclose = () => log.info('User disconnected'); - - return response; - } - return new Response( - Deno.readTextFileSync(`${import.meta.dirname}/public/landing.html`), - { - headers: { 'Content-Type': 'text/html' }, - }, - ); - }, -});