diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..2182675 --- /dev/null +++ b/index.ts @@ -0,0 +1,1098 @@ +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' }, + }, + ); + }, +});