✨ Feat: Implement support for multiple CCNs
This commit is contained in:
parent
097f02938d
commit
a8ffce918e
7 changed files with 778 additions and 169 deletions
14
Makefile
Normal file
14
Makefile
Normal file
|
@ -0,0 +1,14 @@
|
|||
TARGETS = x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(TARGETS:%=dist/relay-%)
|
||||
|
||||
dist/relay-%: | dist/
|
||||
deno compile -A -r --target $* --include migrations --include public --no-lock --output $@ index.ts
|
||||
|
||||
dist/:
|
||||
mkdir -p dist
|
||||
|
||||
clean:
|
||||
rm -f dist/*
|
|
@ -3,10 +3,12 @@ import * as nostrTools from '@nostr/tools';
|
|||
import { nip44 } from '@nostr/tools';
|
||||
import { MAX_CHUNK_SIZE, MIN_POW, POW_TO_MINE } from './consts.ts';
|
||||
import {
|
||||
getCCNPrivateKey,
|
||||
getCCNPubkey,
|
||||
getActiveCCN,
|
||||
getAllCCNs,
|
||||
getCCNPrivateKeyByPubkey,
|
||||
randomTimeUpTo2DaysInThePast,
|
||||
} from './utils.ts';
|
||||
import { None, type Option, Some, flatMap, map } from './utils/option.ts';
|
||||
import { sql } from './utils/queries.ts';
|
||||
|
||||
export class EventAlreadyExistsException extends Error {}
|
||||
|
@ -14,12 +16,16 @@ export class ChunkedEventReceived extends Error {}
|
|||
|
||||
export async function createEncryptedEvent(
|
||||
event: nostrTools.VerifiedEvent,
|
||||
db: Database,
|
||||
): Promise<nostrTools.VerifiedEvent | nostrTools.VerifiedEvent[]> {
|
||||
if (!event.id) throw new Error('Event must have an ID');
|
||||
if (!event.sig) throw new Error('Event must be signed');
|
||||
|
||||
const ccnPubKey = await getCCNPubkey();
|
||||
const ccnPrivateKey = await getCCNPrivateKey();
|
||||
const activeCCN = getActiveCCN(db);
|
||||
if (!activeCCN) throw new Error('No active CCN found');
|
||||
|
||||
const ccnPubKey = activeCCN.pubkey;
|
||||
const ccnPrivateKey = await getCCNPrivateKeyByPubkey(ccnPubKey);
|
||||
|
||||
const eventJson = JSON.stringify(event);
|
||||
if (eventJson.length <= MAX_CHUNK_SIZE) {
|
||||
|
@ -95,12 +101,101 @@ export async function createEncryptedEvent(
|
|||
|
||||
return encryptedChunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to decrypt an event using a specific CCN private key
|
||||
* @returns The decrypted event with CCN pubkey if successful, None otherwise
|
||||
*/
|
||||
function attemptDecryptWithKey(
|
||||
event: nostrTools.Event,
|
||||
ccnPrivkey: Uint8Array,
|
||||
ccnPubkey: string,
|
||||
): Option<nostrTools.VerifiedEvent> {
|
||||
const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey);
|
||||
const sealResult = map(
|
||||
Some(nip44.decrypt(event.content, conversationKey)),
|
||||
JSON.parse,
|
||||
);
|
||||
|
||||
return flatMap(sealResult, (seal) => {
|
||||
if (!seal || seal.kind !== 13) return None();
|
||||
|
||||
const chunkTag = seal.tags.find((tag: string[]) => tag[0] === 'chunk');
|
||||
if (!chunkTag) {
|
||||
const contentResult = map(
|
||||
Some(nip44.decrypt(seal.content, conversationKey)),
|
||||
JSON.parse,
|
||||
);
|
||||
return map(contentResult, (content) => ({ ...content, ccnPubkey }));
|
||||
}
|
||||
|
||||
return None();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a chunked message by storing it in the database and checking if all chunks are received
|
||||
* @returns The complete decrypted event if all chunks are received, throws ChunkedEventReceived otherwise
|
||||
*/
|
||||
function handleChunkedMessage(
|
||||
db: Database,
|
||||
event: nostrTools.Event,
|
||||
ccnPrivkey: Uint8Array,
|
||||
ccnPubkey: string,
|
||||
): nostrTools.VerifiedEvent {
|
||||
const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey);
|
||||
const sealResult = map(
|
||||
Some(nip44.decrypt(event.content, conversationKey)),
|
||||
JSON.parse,
|
||||
);
|
||||
|
||||
const seal = sealResult.isSome ? sealResult.value : null;
|
||||
if (!seal) {
|
||||
throw new Error('Invalid chunked message format');
|
||||
}
|
||||
|
||||
const chunkTag = seal.tags.find((tag: string[]) => tag[0] === 'chunk');
|
||||
if (!chunkTag) {
|
||||
throw new Error('Invalid chunked message format');
|
||||
}
|
||||
|
||||
const [_, chunkIndex, totalChunks, messageId] = chunkTag;
|
||||
const chunk = nip44.decrypt(seal.content, conversationKey);
|
||||
|
||||
const storedChunks = sql`
|
||||
SELECT COUNT(*) as count
|
||||
FROM event_chunks
|
||||
WHERE message_id = ${messageId}
|
||||
`(db)[0].count;
|
||||
|
||||
sql`
|
||||
INSERT INTO event_chunks (message_id, chunk_index, total_chunks, content, created_at, ccn_pubkey)
|
||||
VALUES (${messageId}, ${chunkIndex}, ${totalChunks}, ${chunk}, ${Math.floor(Date.now() / 1000)}, ${ccnPubkey})
|
||||
`(db);
|
||||
|
||||
if (storedChunks + 1 === Number(totalChunks)) {
|
||||
const allChunks = sql`
|
||||
SELECT * FROM event_chunks
|
||||
WHERE message_id = ${messageId}
|
||||
ORDER BY chunk_index
|
||||
`(db);
|
||||
|
||||
let fullContent = '';
|
||||
for (const chunk of allChunks) {
|
||||
fullContent += chunk.content;
|
||||
}
|
||||
|
||||
const content = JSON.parse(fullContent);
|
||||
return { ...content, ccnPubkey };
|
||||
}
|
||||
|
||||
throw new ChunkedEventReceived();
|
||||
}
|
||||
|
||||
export async function decryptEvent(
|
||||
db: Database,
|
||||
event: nostrTools.Event,
|
||||
): Promise<nostrTools.VerifiedEvent> {
|
||||
const ccnPrivkey = await getCCNPrivateKey();
|
||||
|
||||
): Promise<nostrTools.VerifiedEvent & { ccnPubkey: string }> {
|
||||
if (event.kind !== 1059) {
|
||||
throw new Error('Cannot decrypt event -- not a gift wrap');
|
||||
}
|
||||
|
@ -111,64 +206,30 @@ export async function decryptEvent(
|
|||
throw new Error('Cannot decrypt event -- PoW too low');
|
||||
}
|
||||
|
||||
const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey);
|
||||
const seal = JSON.parse(nip44.decrypt(event.content, conversationKey));
|
||||
if (!seal) throw new Error('Cannot decrypt event -- no seal');
|
||||
if (seal.kind !== 13) {
|
||||
throw new Error('Cannot decrypt event subevent -- not a seal');
|
||||
const allCCNs = getAllCCNs(db);
|
||||
if (allCCNs.length === 0) {
|
||||
throw new Error('No CCNs found');
|
||||
}
|
||||
|
||||
const chunkTag = seal.tags.find((tag: string[]) => tag[0] === 'chunk');
|
||||
if (!chunkTag) {
|
||||
const content = JSON.parse(nip44.decrypt(seal.content, conversationKey));
|
||||
return content as nostrTools.VerifiedEvent;
|
||||
for (const ccn of allCCNs) {
|
||||
const ccnPrivkey = await getCCNPrivateKeyByPubkey(ccn.pubkey);
|
||||
const decryptedEvent = attemptDecryptWithKey(event, ccnPrivkey, ccn.pubkey);
|
||||
if (decryptedEvent.isSome) {
|
||||
return { ...decryptedEvent.value, ccnPubkey: ccn.pubkey };
|
||||
}
|
||||
}
|
||||
|
||||
const [_, chunkIndex, totalChunks, messageId] = chunkTag;
|
||||
const chunk = nip44.decrypt(seal.content, conversationKey);
|
||||
|
||||
for (const ccn of allCCNs) {
|
||||
const ccnPrivkey = await getCCNPrivateKeyByPubkey(ccn.pubkey);
|
||||
try {
|
||||
sql`
|
||||
INSERT INTO event_chunks (
|
||||
message_id,
|
||||
chunk_index,
|
||||
total_chunks,
|
||||
chunk_data,
|
||||
conversation_key,
|
||||
created_at
|
||||
) VALUES (
|
||||
${messageId},
|
||||
${Number(chunkIndex)},
|
||||
${Number(totalChunks)},
|
||||
${chunk},
|
||||
${conversationKey},
|
||||
${Math.floor(Date.now() / 1000)}
|
||||
)
|
||||
`(db);
|
||||
|
||||
const chunks = sql`
|
||||
SELECT chunk_data
|
||||
FROM event_chunks
|
||||
WHERE message_id = ${messageId}
|
||||
ORDER BY chunk_index ASC
|
||||
`(db);
|
||||
|
||||
if (chunks.length === Number(totalChunks)) {
|
||||
const completeEventJson = chunks.map((c) => c.chunk_data).join('');
|
||||
|
||||
sql`DELETE FROM event_chunks WHERE message_id = ${messageId}`(db);
|
||||
|
||||
return JSON.parse(completeEventJson) as nostrTools.VerifiedEvent;
|
||||
}
|
||||
|
||||
throw new ChunkedEventReceived(
|
||||
`Chunked event received (${chunks.length}/${totalChunks}) - messageId: ${messageId}`,
|
||||
);
|
||||
const chuncked = handleChunkedMessage(db, event, ccnPrivkey, ccn.pubkey);
|
||||
return { ...chuncked, ccnPubkey: ccn.pubkey };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('UNIQUE constraint failed'))
|
||||
throw new Error(
|
||||
`Duplicate chunk received (${Number(chunkIndex) + 1}/${totalChunks}) - messageId: ${messageId}`,
|
||||
);
|
||||
if (e instanceof ChunkedEventReceived) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to decrypt event with any CCN key');
|
||||
}
|
||||
|
|
367
index.ts
367
index.ts
|
@ -16,8 +16,9 @@ import {
|
|||
decryptEvent,
|
||||
} from './eventEncryptionDecryption.ts';
|
||||
import {
|
||||
getCCNPrivateKey,
|
||||
getCCNPubkey,
|
||||
createNewCCN,
|
||||
getActiveCCN,
|
||||
getAllCCNs,
|
||||
isAddressableEvent,
|
||||
isArray,
|
||||
isCCNReplaceableEvent,
|
||||
|
@ -115,6 +116,7 @@ export function runMigrations(db: Database, latestVersion: number) {
|
|||
function addEventToDb(
|
||||
decryptedEvent: nostrTools.VerifiedEvent,
|
||||
encryptedEvent: nostrTools.VerifiedEvent,
|
||||
ccnPubkey: string,
|
||||
) {
|
||||
const existingEvent = sql`
|
||||
SELECT * FROM events WHERE id = ${decryptedEvent.id}
|
||||
|
@ -131,6 +133,7 @@ function addEventToDb(
|
|||
WHERE kind = ${decryptedEvent.kind}
|
||||
AND pubkey = ${decryptedEvent.pubkey}
|
||||
AND created_at < ${decryptedEvent.created_at}
|
||||
AND ccn_pubkey = ${ccnPubkey}
|
||||
`(db);
|
||||
}
|
||||
|
||||
|
@ -143,6 +146,7 @@ function addEventToDb(
|
|||
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'
|
||||
|
@ -163,6 +167,7 @@ function addEventToDb(
|
|||
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'
|
||||
|
@ -176,7 +181,7 @@ function addEventToDb(
|
|||
}
|
||||
|
||||
sql`
|
||||
INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen) VALUES (
|
||||
INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen, ccn_pubkey) VALUES (
|
||||
${decryptedEvent.id},
|
||||
${encryptedEvent.id},
|
||||
${decryptedEvent.pubkey},
|
||||
|
@ -184,7 +189,8 @@ function addEventToDb(
|
|||
${decryptedEvent.kind},
|
||||
${decryptedEvent.content},
|
||||
${decryptedEvent.sig},
|
||||
unixepoch()
|
||||
unixepoch(),
|
||||
${ccnPubkey}
|
||||
)
|
||||
`(db);
|
||||
if (decryptedEvent.tags) {
|
||||
|
@ -225,9 +231,58 @@ function cleanupOldChunks() {
|
|||
sql`DELETE FROM event_chunks WHERE created_at < ${cutoffTime}`(db);
|
||||
}
|
||||
|
||||
async function setupAndSubscribeToExternalEvents() {
|
||||
const ccnPubkey = await getCCNPubkey();
|
||||
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<void> {
|
||||
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];
|
||||
|
@ -241,72 +296,23 @@ async function setupAndSubscribeToExternalEvents() {
|
|||
|
||||
runMigrations(db, latestVersion);
|
||||
|
||||
const allCCNs = sql`SELECT pubkey FROM ccns`(db);
|
||||
const ccnPubkeys = allCCNs.map((ccn) => ccn.pubkey);
|
||||
|
||||
pool.subscribeMany(
|
||||
relays,
|
||||
[
|
||||
{
|
||||
'#p': [ccnPubkey],
|
||||
'#p': ccnPubkeys,
|
||||
kinds: [1059],
|
||||
},
|
||||
],
|
||||
{
|
||||
async onevent(event: nostrTools.Event) {
|
||||
if (timer) {
|
||||
timerCleaned = true;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (knownOriginalEvents.indexOf(event.id) >= 0) return;
|
||||
if (!nostrTools.verifyEvent(event)) {
|
||||
log.warn('Invalid event received');
|
||||
return;
|
||||
}
|
||||
if (encryptedEventIsInDb(event)) return;
|
||||
try {
|
||||
const decryptedEvent = await decryptEvent(db, event);
|
||||
addEventToDb(decryptedEvent, event);
|
||||
} catch (e) {
|
||||
if (e instanceof EventAlreadyExistsException) return;
|
||||
if (e instanceof ChunkedEventReceived) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
onevent: createSubscriptionEventHandler(db),
|
||||
},
|
||||
);
|
||||
|
||||
let timerCleaned = false;
|
||||
|
||||
const knownOriginalEvents = sql`SELECT original_id FROM events`(db).flatMap(
|
||||
(row) => row.original_id,
|
||||
);
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
// if nothing is found in 10 seconds, create a new CCN, TODO: change logic
|
||||
const ccnCreationEventTemplate = {
|
||||
kind: 0,
|
||||
content: JSON.stringify({
|
||||
display_name: 'New CCN',
|
||||
name: 'New CCN',
|
||||
bot: true,
|
||||
}),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', ccnPubkey]],
|
||||
};
|
||||
const ccnCreationEvent = nostrTools.finalizeEvent(
|
||||
ccnCreationEventTemplate,
|
||||
await getCCNPrivateKey(),
|
||||
);
|
||||
const encryptedCCNCreationEvent =
|
||||
await createEncryptedEvent(ccnCreationEvent);
|
||||
if (timerCleaned) return; // in case we get an event before the timer is cleaned
|
||||
if (Array.isArray(encryptedCCNCreationEvent)) {
|
||||
for (const event of encryptedCCNCreationEvent)
|
||||
await Promise.any(pool.publish(relays, event));
|
||||
} else {
|
||||
await Promise.any(pool.publish(relays, encryptedCCNCreationEvent));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
updateKnownEventsCache();
|
||||
setInterval(cleanupOldChunks, CHUNK_CLEANUP_INTERVAL);
|
||||
}
|
||||
|
||||
|
@ -326,6 +332,49 @@ class UserConnection {
|
|||
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(
|
||||
|
@ -370,7 +419,13 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) {
|
|||
)}`,
|
||||
);
|
||||
|
||||
let query = sqlPartial`SELECT * FROM events WHERE replaced = 0`;
|
||||
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) => {
|
||||
|
@ -593,9 +648,9 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) {
|
|||
sig: events[i].sig,
|
||||
};
|
||||
|
||||
connection.socket.send(JSON.stringify(['EVENT', subscriptionId, event]));
|
||||
connection.sendEvent(subscriptionId, event);
|
||||
}
|
||||
connection.socket.send(JSON.stringify(['EOSE', subscriptionId]));
|
||||
connection.sendEOSE(subscriptionId);
|
||||
|
||||
connection.subscriptions.set(subscriptionId, filters);
|
||||
}
|
||||
|
@ -606,20 +661,24 @@ async function handleEvent(
|
|||
) {
|
||||
const valid = nostrTools.verifyEvent(event);
|
||||
if (!valid) {
|
||||
connection.socket.send(JSON.stringify(['NOTICE', 'Invalid event']));
|
||||
connection.sendNotice('Invalid event');
|
||||
return log.warn('Invalid event');
|
||||
}
|
||||
|
||||
const encryptedEvent = await createEncryptedEvent(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 Promise.all(
|
||||
encryptedEvent.map((chunk) => Promise.any(pool.publish(relays, chunk))),
|
||||
);
|
||||
addEventToDb(event, encryptedEvent[0]);
|
||||
await publishToRelays(encryptedEvent);
|
||||
addEventToDb(event, encryptedEvent[0], activeCCN.pubkey);
|
||||
} else {
|
||||
addEventToDb(event, encryptedEvent);
|
||||
await Promise.any(pool.publish(relays, encryptedEvent));
|
||||
addEventToDb(event, encryptedEvent, activeCCN.pubkey);
|
||||
await publishToRelays(encryptedEvent);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof EventAlreadyExistsException) {
|
||||
|
@ -628,13 +687,13 @@ async function handleEvent(
|
|||
}
|
||||
}
|
||||
|
||||
connection.socket.send(JSON.stringify(['OK', event.id, true, 'Event added']));
|
||||
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.socket.send(JSON.stringify(['EVENT', filter, event]));
|
||||
connection.sendEvent(filter, event);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -648,6 +707,153 @@ function handleClose(connection: UserConnection, subscriptionId: string) {
|
|||
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 },
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!data.name || typeof data.name !== 'string') {
|
||||
connection.sendNotice('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const newCcn = await createNewCCN(connection.db, data.name, 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);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCCNCommands(
|
||||
connection: UserConnection,
|
||||
command: string,
|
||||
data: unknown,
|
||||
) {
|
||||
switch (command) {
|
||||
case 'CREATE':
|
||||
return handleCreateCCN(
|
||||
connection,
|
||||
data as { name: string; seed?: 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) => {
|
||||
|
@ -672,14 +878,16 @@ Deno.serve({
|
|||
const data = JSON.parse(event.data);
|
||||
if (!isArray(data)) return log.warn('Invalid request');
|
||||
|
||||
const msg = n.clientMsg().parse(data);
|
||||
switch (msg[0]) {
|
||||
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');
|
||||
}
|
||||
|
@ -688,6 +896,11 @@ Deno.serve({
|
|||
|
||||
return response;
|
||||
}
|
||||
return new Response('Eve Relay');
|
||||
return new Response(
|
||||
Deno.readTextFileSync(`${import.meta.dirname}/public/landing.html`),
|
||||
{
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
21
migrations/5-multiCCN.sql
Normal file
21
migrations/5-multiCCN.sql
Normal file
|
@ -0,0 +1,21 @@
|
|||
CREATE TABLE ccns (
|
||||
ccn_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
pubkey TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
is_active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
ALTER TABLE events
|
||||
ADD COLUMN ccn_pubkey TEXT;
|
||||
|
||||
CREATE INDEX idx_events_ccn_pubkey ON events(ccn_pubkey);
|
||||
|
||||
ALTER TABLE event_chunks RENAME COLUMN chunk_data TO content;
|
||||
ALTER TABLE event_chunks ADD COLUMN ccn_pubkey TEXT;
|
||||
ALTER TABLE event_chunks DROP COLUMN conversation_key;
|
||||
CREATE INDEX idx_event_chunks_ccn_pubkey ON event_chunks(ccn_pubkey);
|
||||
|
||||
UPDATE ccns SET is_active = 0;
|
||||
UPDATE ccns SET is_active = 1
|
||||
WHERE pubkey = (SELECT pubkey FROM ccns LIMIT 1);
|
212
public/landing.html
Normal file
212
public/landing.html
Normal file
|
@ -0,0 +1,212 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Eve Relay - Secure Nostr Relay with CCN</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3498db;
|
||||
--primary-dark: #2980b9;
|
||||
--text: #2c3e50;
|
||||
--text-light: #7f8c8d;
|
||||
--background: #ffffff;
|
||||
--background-alt: #f8f9fa;
|
||||
--border: #e9ecef;
|
||||
--success: #2ecc71;
|
||||
--warning: #f1c40f;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
background: var(--background-alt);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--primary);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-light);
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 2.5rem 0 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--background-alt);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--background-alt);
|
||||
padding: 1.25rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.note {
|
||||
background: #fffde7;
|
||||
border-left: 4px solid var(--warning);
|
||||
padding: 1.25rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.command-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.command-list li {
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--background-alt);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.command-list code {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">
|
||||
<span class="logo-text">E</span>
|
||||
</div>
|
||||
<h1>Eve Relay</h1>
|
||||
<p class="subtitle">A secure and efficient Nostr relay with Closed Community Network (CCN) functionality</p>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="note">
|
||||
<strong>Important:</strong> This relay is designed for WebSocket connections only. HTTP requests are not supported for data operations.
|
||||
</div>
|
||||
|
||||
<h2>Connection Details</h2>
|
||||
<p>Connect to the relay using WebSocket:</p>
|
||||
<pre>ws://localhost:6942</pre>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Nostr Commands</h3>
|
||||
<ul class="command-list">
|
||||
<li><code>REQ</code> - Subscribe to events</li>
|
||||
<li><code>EVENT</code> - Publish an event</li>
|
||||
<li><code>CLOSE</code> - Close a subscription</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>CCN Commands</h3>
|
||||
<ul class="command-list">
|
||||
<li><code>CCN CREATE</code> - Create a new CCN</li>
|
||||
<li><code>CCN LIST</code> - List all active CCNs</li>
|
||||
<li><code>CCN ACTIVATE</code> - Activate a specific CCN</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Documentation</h2>
|
||||
<p>For detailed information about Arx-CCN functionality and best practices, please refer to the official documentation.</p>
|
||||
<a href="#" class="cta-button">View Documentation</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
104
utils.ts
104
utils.ts
|
@ -1,13 +1,15 @@
|
|||
import { decodeBase64, encodeBase64 } from 'jsr:@std/encoding@0.224/base64';
|
||||
import { exists } from 'jsr:@std/fs';
|
||||
import * as nostrTools from '@nostr/tools';
|
||||
import * as nip06 from '@nostr/tools/nip06';
|
||||
import type { Database } from 'jsr:@db/sqlite';
|
||||
import { decodeBase64, encodeBase64 } from 'jsr:@std/encoding@0.224/base64';
|
||||
import { exists } from 'jsr:@std/fs';
|
||||
import {
|
||||
decryptUint8Array,
|
||||
encryptUint8Array,
|
||||
encryptionKey,
|
||||
} from './utils/encryption.ts';
|
||||
import { getEveFilePath } from './utils/files.ts';
|
||||
import { sql } from './utils/queries.ts';
|
||||
|
||||
export function isLocalhost(req: Request): boolean {
|
||||
const url = new URL(req.url);
|
||||
|
@ -38,37 +40,68 @@ export function randomTimeUpTo2DaysInThePast() {
|
|||
);
|
||||
}
|
||||
|
||||
export async function getCCNPubkey(): Promise<string> {
|
||||
const ccnPubPath = await getEveFilePath('ccn.pub');
|
||||
const seedPath = await getEveFilePath('ccn.seed');
|
||||
const doWeHaveKey = await exists(ccnPubPath);
|
||||
if (doWeHaveKey) return Deno.readTextFileSync(ccnPubPath);
|
||||
const ccnSeed =
|
||||
Deno.env.get('CCN_SEED') ||
|
||||
((await exists(seedPath))
|
||||
? Deno.readTextFileSync(seedPath)
|
||||
: nip06.generateSeedWords());
|
||||
const ccnPrivateKey = nip06.privateKeyFromSeedWords(ccnSeed);
|
||||
const ccnPublicKey = nostrTools.getPublicKey(ccnPrivateKey);
|
||||
const encryptedPrivateKey = encryptUint8Array(ccnPrivateKey, encryptionKey);
|
||||
|
||||
Deno.writeTextFileSync(ccnPubPath, ccnPublicKey);
|
||||
Deno.writeTextFileSync(
|
||||
await getEveFilePath('ccn.priv'),
|
||||
encodeBase64(encryptedPrivateKey),
|
||||
);
|
||||
Deno.writeTextFileSync(seedPath, ccnSeed);
|
||||
|
||||
return ccnPublicKey;
|
||||
/**
|
||||
* Get all CCNs from the database
|
||||
*/
|
||||
export function getAllCCNs(db: Database): { pubkey: string; name: string }[] {
|
||||
return sql`SELECT pubkey, name FROM ccns`(db) as {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function getCCNPrivateKey(): Promise<Uint8Array> {
|
||||
const encryptedPrivateKey = Deno.readTextFileSync(
|
||||
await getEveFilePath('ccn.priv'),
|
||||
);
|
||||
/**
|
||||
* Create a new CCN and store it in the database
|
||||
*
|
||||
* @param db - The database instance
|
||||
* @param name - The name of the CCN
|
||||
* @param seed - The seed words for the CCN
|
||||
* @returns The public key and private key of the CCN
|
||||
*/
|
||||
export async function createNewCCN(
|
||||
db: Database,
|
||||
name: string,
|
||||
seed?: string,
|
||||
): Promise<{ pubkey: string; privkey: Uint8Array }> {
|
||||
const ccnSeed = seed || nip06.generateSeedWords();
|
||||
const ccnPrivateKey = nip06.privateKeyFromSeedWords(ccnSeed);
|
||||
const ccnPublicKey = nostrTools.getPublicKey(ccnPrivateKey);
|
||||
|
||||
const ccnSeedPath = await getEveFilePath(`ccn_seeds/${ccnPublicKey}`);
|
||||
const ccnPrivPath = await getEveFilePath(`ccn_keys/${ccnPublicKey}`);
|
||||
|
||||
await Deno.mkdir(await getEveFilePath('ccn_seeds'), { recursive: true });
|
||||
await Deno.mkdir(await getEveFilePath('ccn_keys'), { recursive: true });
|
||||
|
||||
const encryptedPrivateKey = encryptUint8Array(ccnPrivateKey, encryptionKey);
|
||||
|
||||
Deno.writeTextFileSync(ccnSeedPath, ccnSeed);
|
||||
Deno.writeTextFileSync(ccnPrivPath, encodeBase64(encryptedPrivateKey));
|
||||
|
||||
sql`INSERT INTO ccns (pubkey, name) VALUES (${ccnPublicKey}, ${name})`(db);
|
||||
|
||||
return {
|
||||
pubkey: ccnPublicKey,
|
||||
privkey: ccnPrivateKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the private key for a specific CCN
|
||||
*/
|
||||
export async function getCCNPrivateKeyByPubkey(
|
||||
pubkey: string,
|
||||
): Promise<Uint8Array> {
|
||||
const ccnPrivPath = await getEveFilePath(`ccn_keys/${pubkey}`);
|
||||
|
||||
if (await exists(ccnPrivPath)) {
|
||||
const encryptedPrivateKey = Deno.readTextFileSync(ccnPrivPath);
|
||||
return decryptUint8Array(decodeBase64(encryptedPrivateKey), encryptionKey);
|
||||
}
|
||||
|
||||
throw new Error(`CCN private key for ${pubkey} not found`);
|
||||
}
|
||||
|
||||
export function isReplaceableEvent(kind: number): boolean {
|
||||
return (kind >= 10000 && kind < 20000) || kind === 0 || kind === 3;
|
||||
}
|
||||
|
@ -104,3 +137,18 @@ export function parseATagQuery(aTagValue: string): {
|
|||
dTag: parts.length > 2 ? parts[2] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the single active CCN from the database
|
||||
* @returns The active CCN or null if none is active
|
||||
*/
|
||||
export function getActiveCCN(
|
||||
db: Database,
|
||||
): { pubkey: string; name: string } | null {
|
||||
const result = sql`SELECT pubkey, name FROM ccns WHERE is_active = 1 LIMIT 1`(
|
||||
db,
|
||||
);
|
||||
return result.length > 0
|
||||
? (result[0] as { pubkey: string; name: string })
|
||||
: null;
|
||||
}
|
||||
|
|
40
utils/option.ts
Normal file
40
utils/option.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
export type Option<T> =
|
||||
| {
|
||||
value: T;
|
||||
isSome: true;
|
||||
}
|
||||
| {
|
||||
value: undefined;
|
||||
isSome: false;
|
||||
};
|
||||
|
||||
export function Some<T>(value: T): Option<T> {
|
||||
return { value, isSome: true };
|
||||
}
|
||||
|
||||
export function None<T>(): Option<T> {
|
||||
return { value: undefined, isSome: false };
|
||||
}
|
||||
|
||||
export function map<T, U>(option: Option<T>, fn: (value: T) => U): Option<U> {
|
||||
return option.isSome ? Some(fn(option.value)) : None();
|
||||
}
|
||||
|
||||
export function flatMap<T, U>(
|
||||
option: Option<T>,
|
||||
fn: (value: T) => Option<U>,
|
||||
): Option<U> {
|
||||
return option.isSome ? fn(option.value) : None();
|
||||
}
|
||||
|
||||
export function getOrElse<T>(option: Option<T>, defaultValue: T): T {
|
||||
return option.isSome ? option.value : defaultValue;
|
||||
}
|
||||
|
||||
export function fold<T, U>(
|
||||
option: Option<T>,
|
||||
onNone: () => U,
|
||||
onSome: (value: T) => U,
|
||||
): U {
|
||||
return option.isSome ? onSome(option.value) : onNone();
|
||||
}
|
Loading…
Add table
Reference in a new issue