1093 lines
31 KiB
TypeScript
1093 lines
31 KiB
TypeScript
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 { 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<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];
|
|
|
|
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<string, NostrFilter[]>;
|
|
public db: Database;
|
|
|
|
constructor(
|
|
socket: WebSocket,
|
|
subscriptions: Map<string, NostrFilter[]>,
|
|
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<number, string>;
|
|
}
|
|
>();
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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}`);
|
|
Deno.writeTextFileSync(ccnPrivPath, encodeBase64(privateKeyBytes));
|
|
|
|
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' },
|
|
},
|
|
);
|
|
},
|
|
});
|