This commit is contained in:
hzrd149
2025-02-06 16:11:00 -06:00
parent c3216e43a9
commit 9efd5019e3
66 changed files with 3759 additions and 3201 deletions

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
dist
data
nostrudel
node_modules

View File

@@ -11,7 +11,7 @@
"start": "node .", "start": "node .",
"dev": "nodemon --loader @swc-node/register/esm src/index.ts", "dev": "nodemon --loader @swc-node/register/esm src/index.ts",
"build": "tsc", "build": "tsc",
"format": "prettier -w . --ignore-path .gitignore" "format": "prettier -w ."
}, },
"files": [ "files": [
"dist", "dist",
@@ -24,12 +24,14 @@
"author": "hzrd149", "author": "hzrd149",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@diva.exchange/i2p-sam": "^5.4.1", "@diva.exchange/i2p-sam": "^5.4.2",
"@noble/hashes": "^1.7.0", "@noble/hashes": "^1.7.1",
"@satellite-earth/core": "^0.5.0", "@satellite-earth/core": "^0.5.0",
"applesauce-core": "^0.10.0", "applesauce-core": "next",
"applesauce-signer": "^0.10.0", "applesauce-factory": "0.0.0-next-20250206174509",
"better-sqlite3": "^11.7.2", "applesauce-loaders": "^0.10.0",
"applesauce-signers": "next",
"better-sqlite3": "^11.8.1",
"blossom-client-sdk": "^2.1.1", "blossom-client-sdk": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
@@ -49,23 +51,25 @@
"nostr-tools": "^2.10.4", "nostr-tools": "^2.10.4",
"pac-proxy-agent": "^7.1.0", "pac-proxy-agent": "^7.1.0",
"process-streams": "^1.0.3", "process-streams": "^1.0.3",
"streamx": "^2.21.1", "rx-nostr": "^3.5.0",
"rxjs": "^7.8.1",
"streamx": "^2.22.0",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.27.11", "@changesets/cli": "^2.27.12",
"@swc-node/register": "^1.10.9", "@swc-node/register": "^1.10.9",
"@swc/core": "^1.10.7", "@swc/core": "^1.10.14",
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/lodash.throttle": "^4.1.9", "@types/lodash.throttle": "^4.1.9",
"@types/node": "^22.10.6", "@types/node": "^22.13.1",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@types/ws": "^8.5.13", "@types/ws": "^8.5.14",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
@@ -76,5 +80,6 @@
], ],
"exec": "node", "exec": "node",
"signal": "SIGTERM" "signal": "SIGTERM"
} },
"packageManager": "pnpm@9.14.4"
} }

1032
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ import { kinds } from "nostr-tools";
import { AbstractRelay } from "nostr-tools/abstract-relay"; import { AbstractRelay } from "nostr-tools/abstract-relay";
import express, { Express } from "express"; import express, { Express } from "express";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { SimpleSigner } from "applesauce-signer/signers/simple-signer"; import { SimpleSigner } from "applesauce-signers/signers/simple-signer";
import cors from "cors"; import cors from "cors";
import { logger } from "../logger.js"; import { logger } from "../logger.js";

23
src/core.ts Normal file
View File

@@ -0,0 +1,23 @@
import { createRxNostr, noopVerifier } from "rx-nostr";
import { verifyEvent } from "nostr-tools/wasm";
import { EventStore, QueryStore } from "applesauce-core";
import { logger } from "./logger.js";
const log = logger.extend("rx-nostr");
export const rxNostr = createRxNostr({
verifier: async (event) => {
try {
return verifyEvent(event);
} catch (error) {
return false;
}
},
});
rxNostr.createConnectionStateObservable().subscribe((packet) => {
log(`${packet.state} ${packet.from}`);
});
export const eventStore = new EventStore();
export const queryStore = new QueryStore(eventStore);

4
src/helpers/array.ts Normal file
View File

@@ -0,0 +1,4 @@
export function arrayFallback<T>(arr: T[], fallback: T[]): T[] {
if (arr.length === 0) return fallback;
else return arr;
}

View File

@@ -1,19 +1,19 @@
import os from 'node:os'; import os from "node:os";
export function getIPAddresses() { export function getIPAddresses() {
var ifaces = os.networkInterfaces(); var ifaces = os.networkInterfaces();
var addresses: string[] = []; var addresses: string[] = [];
for (const [name, info] of Object.entries(ifaces)) { for (const [name, info] of Object.entries(ifaces)) {
if (!info) continue; if (!info) continue;
for (const interfaceInfo of info) { for (const interfaceInfo of info) {
// skip over internal (i.e. 127.0.0.1) and non-ipv4 addresses // skip over internal (i.e. 127.0.0.1) and non-ipv4 addresses
if (interfaceInfo.internal) continue; if (interfaceInfo.internal) continue;
addresses.push(interfaceInfo.address); addresses.push(interfaceInfo.address);
} }
} }
return addresses; return addresses;
} }

View File

@@ -1,23 +1,23 @@
import net from 'net'; import net from "net";
export function testTCPConnection(host: string, port: number, timeout = 5000) { export function testTCPConnection(host: string, port: number, timeout = 5000) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const socket = new net.Socket(); const socket = new net.Socket();
const timer = setTimeout(() => { const timer = setTimeout(() => {
socket.destroy(); socket.destroy();
reject(new Error('Connection timed out')); reject(new Error("Connection timed out"));
}, timeout); }, timeout);
socket.connect(port, host, () => { socket.connect(port, host, () => {
clearTimeout(timer); clearTimeout(timer);
socket.destroy(); socket.destroy();
resolve(true); resolve(true);
}); });
socket.on('error', (err) => { socket.on("error", (err) => {
clearTimeout(timer); clearTimeout(timer);
reject(err); reject(err);
}); });
}); });
} }

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import "./polyfill.js";
import process from "node:process"; import process from "node:process";
import path from "node:path"; import path from "node:path";
@@ -7,9 +8,7 @@ import { mkdirp } from "mkdirp";
import dayjs from "dayjs"; import dayjs from "dayjs";
import duration from "dayjs/plugin/duration.js"; import duration from "dayjs/plugin/duration.js";
import localizedFormat from "dayjs/plugin/localizedFormat.js"; import localizedFormat from "dayjs/plugin/localizedFormat.js";
import { useWebSocketImplementation } from "nostr-tools/relay";
import OutboundProxyWebSocket from "./modules/network/outbound/websocket.js";
import App from "./app/index.js"; import App from "./app/index.js";
import { DATA_PATH, PUBLIC_ADDRESS } from "./env.js"; import { DATA_PATH, PUBLIC_ADDRESS } from "./env.js";
import { addListener, logger } from "./logger.js"; import { addListener, logger } from "./logger.js";
@@ -19,10 +18,6 @@ import { pathExists } from "./helpers/fs.js";
dayjs.extend(duration); dayjs.extend(duration);
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
// @ts-expect-error
global.WebSocket = OutboundProxyWebSocket;
useWebSocketImplementation(OutboundProxyWebSocket);
// create app // create app
await mkdirp(DATA_PATH); await mkdirp(DATA_PATH);
const app = new App(DATA_PATH); const app = new App(DATA_PATH);

View File

@@ -1,66 +1,66 @@
import { NostrEvent, kinds } from 'nostr-tools'; import { NostrEvent, kinds } from "nostr-tools";
import _throttle from 'lodash.throttle'; import _throttle from "lodash.throttle";
import { getInboxes, getOutboxes } from '@satellite-earth/core/helpers/nostr/mailboxes.js'; import { getInboxes, getOutboxes } from "@satellite-earth/core/helpers/nostr/mailboxes.js";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
import App from '../app/index.js'; import App from "../app/index.js";
import PubkeyBatchLoader from './pubkey-batch-loader.js'; import PubkeyBatchLoader from "./pubkey-batch-loader.js";
/** Loads 10002 events for pubkeys */ /** Loads 10002 events for pubkeys */
export default class AddressBook { export default class AddressBook {
log = logger.extend('AddressBook'); log = logger.extend("AddressBook");
app: App; app: App;
loader: PubkeyBatchLoader; loader: PubkeyBatchLoader;
get extraRelays() { get extraRelays() {
return this.loader.extraRelays; return this.loader.extraRelays;
} }
set extraRelays(v: string[]) { set extraRelays(v: string[]) {
this.loader.extraRelays = v; this.loader.extraRelays = v;
} }
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
this.loader = new PubkeyBatchLoader(kinds.RelayList, this.app.pool, (pubkey) => { this.loader = new PubkeyBatchLoader(kinds.RelayList, this.app.pool, (pubkey) => {
return this.app.eventStore.getEventsForFilters([{ kinds: [kinds.RelayList], authors: [pubkey] }])?.[0]; return this.app.eventStore.getEventsForFilters([{ kinds: [kinds.RelayList], authors: [pubkey] }])?.[0];
}); });
this.loader.on('event', (event) => this.app.eventStore.addEvent(event)); this.loader.on("event", (event) => this.app.eventStore.addEvent(event));
this.loader.on('batch', (found, failed) => { this.loader.on("batch", (found, failed) => {
this.log(`Found ${found}, failed ${failed}, pending ${this.loader.queue}`); this.log(`Found ${found}, failed ${failed}, pending ${this.loader.queue}`);
}); });
} }
getMailboxes(pubkey: string) { getMailboxes(pubkey: string) {
return this.loader.getEvent(pubkey); return this.loader.getEvent(pubkey);
} }
getOutboxes(pubkey: string) { getOutboxes(pubkey: string) {
const mailboxes = this.getMailboxes(pubkey); const mailboxes = this.getMailboxes(pubkey);
return mailboxes && getOutboxes(mailboxes); return mailboxes && getOutboxes(mailboxes);
} }
getInboxes(pubkey: string) { getInboxes(pubkey: string) {
const mailboxes = this.getMailboxes(pubkey); const mailboxes = this.getMailboxes(pubkey);
return mailboxes && getInboxes(mailboxes); return mailboxes && getInboxes(mailboxes);
} }
handleEvent(event: NostrEvent) { handleEvent(event: NostrEvent) {
this.loader.handleEvent(event); this.loader.handleEvent(event);
} }
async loadMailboxes(pubkey: string, relays?: string[]) { async loadMailboxes(pubkey: string, relays?: string[]) {
return this.loader.getOrLoadEvent(pubkey, relays); return this.loader.getOrLoadEvent(pubkey, relays);
} }
async loadOutboxes(pubkey: string, relays?: string[]) { async loadOutboxes(pubkey: string, relays?: string[]) {
const mailboxes = await this.loadMailboxes(pubkey, relays); const mailboxes = await this.loadMailboxes(pubkey, relays);
return mailboxes && getOutboxes(mailboxes); return mailboxes && getOutboxes(mailboxes);
} }
async loadInboxes(pubkey: string, relays?: string[]) { async loadInboxes(pubkey: string, relays?: string[]) {
const mailboxes = await this.loadMailboxes(pubkey, relays); const mailboxes = await this.loadMailboxes(pubkey, relays);
return mailboxes && getInboxes(mailboxes); return mailboxes && getInboxes(mailboxes);
} }
} }

View File

@@ -1,95 +1,95 @@
import EventEmitter from 'events'; import EventEmitter from "events";
import { SimplePool, VerifiedEvent } from 'nostr-tools'; import { SimplePool, VerifiedEvent } from "nostr-tools";
import { AbstractRelay } from 'nostr-tools/relay'; import { AbstractRelay } from "nostr-tools/relay";
import { normalizeURL } from 'nostr-tools/utils'; import { normalizeURL } from "nostr-tools/utils";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
export type TestRelay = (relay: AbstractRelay, challenge: string) => boolean; export type TestRelay = (relay: AbstractRelay, challenge: string) => boolean;
type EventMap = { type EventMap = {
challenge: [AbstractRelay, string]; challenge: [AbstractRelay, string];
connected: [AbstractRelay]; connected: [AbstractRelay];
closed: [AbstractRelay]; closed: [AbstractRelay];
}; };
export default class CautiousPool extends SimplePool { export default class CautiousPool extends SimplePool {
log = logger.extend('CautiousPool'); log = logger.extend("CautiousPool");
isSelf?: TestRelay; isSelf?: TestRelay;
blacklist = new Set<string>(); blacklist = new Set<string>();
challenges = new Map<string, string>(); challenges = new Map<string, string>();
authenticated = new Map<string, boolean>(); authenticated = new Map<string, boolean>();
emitter = new EventEmitter<EventMap>(); emitter = new EventEmitter<EventMap>();
constructor(isSelf?: TestRelay) { constructor(isSelf?: TestRelay) {
super(); super();
this.isSelf = isSelf; this.isSelf = isSelf;
} }
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> { async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
url = normalizeURL(url); url = normalizeURL(url);
const parsed = new URL(url); const parsed = new URL(url);
if (parsed.host === 'localhost' || parsed.host === '127.0.0.1') throw new Error('Cant connect to localhost'); if (parsed.host === "localhost" || parsed.host === "127.0.0.1") throw new Error("Cant connect to localhost");
if (this.blacklist.has(url)) throw new Error('Cant connect to self'); if (this.blacklist.has(url)) throw new Error("Cant connect to self");
const relay = await super.ensureRelay(url, params); const relay = await super.ensureRelay(url, params);
if (this.checkRelay(relay)) throw new Error('Cant connect to self'); if (this.checkRelay(relay)) throw new Error("Cant connect to self");
this.emitter.emit('connected', relay); this.emitter.emit("connected", relay);
relay._onauth = (challenge) => { relay._onauth = (challenge) => {
if (this.checkRelay(relay, challenge)) { if (this.checkRelay(relay, challenge)) {
this.authenticated.set(relay.url, false); this.authenticated.set(relay.url, false);
this.challenges.set(relay.url, challenge); this.challenges.set(relay.url, challenge);
this.emitter.emit('challenge', relay, challenge); this.emitter.emit("challenge", relay, challenge);
} }
}; };
relay.onnotice = () => {}; relay.onnotice = () => {};
relay.onclose = () => { relay.onclose = () => {
this.challenges.delete(relay.url); this.challenges.delete(relay.url);
this.authenticated.delete(relay.url); this.authenticated.delete(relay.url);
this.emitter.emit('closed', relay); this.emitter.emit("closed", relay);
}; };
return relay; return relay;
} }
private checkRelay(relay: AbstractRelay, challenge?: string) { private checkRelay(relay: AbstractRelay, challenge?: string) {
// @ts-expect-error // @ts-expect-error
challenge = challenge || relay.challenge; challenge = challenge || relay.challenge;
if (challenge) { if (challenge) {
if (this.isSelf && this.isSelf(relay, challenge)) { if (this.isSelf && this.isSelf(relay, challenge)) {
this.log(`Found ${relay.url} connects to ourselves, adding to blacklist`); this.log(`Found ${relay.url} connects to ourselves, adding to blacklist`);
this.blacklist.add(relay.url); this.blacklist.add(relay.url);
relay.close(); relay.close();
relay.connect = () => { relay.connect = () => {
throw new Error('Cant connect to self'); throw new Error("Cant connect to self");
}; };
return true; return true;
} }
} }
return false; return false;
} }
isAuthenticated(relay: string | AbstractRelay) { isAuthenticated(relay: string | AbstractRelay) {
return !!this.authenticated.get(typeof relay === 'string' ? relay : relay.url); return !!this.authenticated.get(typeof relay === "string" ? relay : relay.url);
} }
async authenticate(url: string | AbstractRelay, auth: VerifiedEvent) { async authenticate(url: string | AbstractRelay, auth: VerifiedEvent) {
const relay = typeof url === 'string' ? await this.ensureRelay(url) : url; const relay = typeof url === "string" ? await this.ensureRelay(url) : url;
return await relay.auth(async (draft) => auth); return await relay.auth(async (draft) => auth);
} }
[Symbol.iterator](): IterableIterator<[string, AbstractRelay]> { [Symbol.iterator](): IterableIterator<[string, AbstractRelay]> {
return this.relays[Symbol.iterator](); return this.relays[Symbol.iterator]();
} }
} }

View File

@@ -1,89 +1,89 @@
import { type Database } from 'better-sqlite3'; import { type Database } from "better-sqlite3";
import { WebSocket, WebSocketServer } from 'ws'; import { WebSocket, WebSocketServer } from "ws";
import { type IncomingMessage } from 'http'; import { type IncomingMessage } from "http";
import { randomBytes } from 'crypto'; import { randomBytes } from "crypto";
import { NostrEvent, SimplePool } from 'nostr-tools'; import { NostrEvent, SimplePool } from "nostr-tools";
import { HyperConnectionManager } from './hyper-connection-manager.js'; import { HyperConnectionManager } from "./hyper-connection-manager.js";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
import { CommunityProxy } from './community-proxy.js'; import { CommunityProxy } from "./community-proxy.js";
import { IEventStore } from '@satellite-earth/core'; import { IEventStore } from "@satellite-earth/core";
export class CommunityMultiplexer { export class CommunityMultiplexer {
log = logger.extend('community-multiplexer'); log = logger.extend("community-multiplexer");
db: Database; db: Database;
eventStore: IEventStore; eventStore: IEventStore;
pool: SimplePool; pool: SimplePool;
connectionManager: HyperConnectionManager; connectionManager: HyperConnectionManager;
communities = new Map<string, CommunityProxy>(); communities = new Map<string, CommunityProxy>();
constructor(db: Database, eventStore: IEventStore) { constructor(db: Database, eventStore: IEventStore) {
this.db = db; this.db = db;
this.eventStore = eventStore; this.eventStore = eventStore;
this.pool = new SimplePool(); this.pool = new SimplePool();
this.connectionManager = new HyperConnectionManager(randomBytes(32).toString('hex')); this.connectionManager = new HyperConnectionManager(randomBytes(32).toString("hex"));
this.syncCommunityDefinitions(); this.syncCommunityDefinitions();
} }
attachToServer(wss: WebSocketServer) { attachToServer(wss: WebSocketServer) {
wss.on('connection', this.handleConnection.bind(this)); wss.on("connection", this.handleConnection.bind(this));
} }
handleConnection(ws: WebSocket, req: IncomingMessage) { handleConnection(ws: WebSocket, req: IncomingMessage) {
if (!req.url) return false; if (!req.url) return false;
const url = new URL(req.url, `http://${req.headers.host}`); const url = new URL(req.url, `http://${req.headers.host}`);
const pubkey = url.pathname.split('/')[1] as string | undefined; const pubkey = url.pathname.split("/")[1] as string | undefined;
if (!pubkey || pubkey.length !== 64) return false; if (!pubkey || pubkey.length !== 64) return false;
try { try {
let community = this.communities.get(pubkey); let community = this.communities.get(pubkey);
if (!community) community = this.getCommunityProxy(pubkey); if (!community) community = this.getCommunityProxy(pubkey);
// connect the socket to the relay // connect the socket to the relay
community.relay.handleConnection(ws, req); community.relay.handleConnection(ws, req);
return true; return true;
} catch (error) { } catch (error) {
this.log('Failed handle ws connection to', pubkey); this.log("Failed handle ws connection to", pubkey);
console.log(error); console.log(error);
return false; return false;
} }
} }
syncCommunityDefinitions() { syncCommunityDefinitions() {
this.log('Syncing community definitions'); this.log("Syncing community definitions");
const sub = this.pool.subscribeMany(['wss://nostrue.com'], [{ kinds: [12012] }], { const sub = this.pool.subscribeMany(["wss://nostrue.com"], [{ kinds: [12012] }], {
onevent: (event) => this.eventStore.addEvent(event), onevent: (event) => this.eventStore.addEvent(event),
oneose: () => sub.close(), oneose: () => sub.close(),
}); });
} }
getCommunityProxy(pubkey: string) { getCommunityProxy(pubkey: string) {
this.log('Looking for community definition', pubkey); this.log("Looking for community definition", pubkey);
let definition: NostrEvent | undefined = undefined; let definition: NostrEvent | undefined = undefined;
const local = this.eventStore.getEventsForFilters([{ kinds: [12012], authors: [pubkey] }]); const local = this.eventStore.getEventsForFilters([{ kinds: [12012], authors: [pubkey] }]);
if (local[0]) definition = local[0]; if (local[0]) definition = local[0];
if (!definition) throw new Error('Failed to find community definition'); if (!definition) throw new Error("Failed to find community definition");
this.log('Creating community proxy', pubkey); this.log("Creating community proxy", pubkey);
const community = new CommunityProxy(this.db, definition, this.connectionManager); const community = new CommunityProxy(this.db, definition, this.connectionManager);
community.connect(); community.connect();
this.communities.set(pubkey, community); this.communities.set(pubkey, community);
return community; return community;
} }
stop() { stop() {
for (const [pubkey, community] of this.communities) { for (const [pubkey, community] of this.communities) {
community.stop(); community.stop();
} }
this.communities.clear(); this.communities.clear();
this.connectionManager.stop(); this.connectionManager.stop();
} }
} }

View File

@@ -1,54 +1,54 @@
import { NostrEvent, kinds } from 'nostr-tools'; import { NostrEvent, kinds } from "nostr-tools";
import _throttle from 'lodash.throttle'; import _throttle from "lodash.throttle";
import { COMMON_CONTACT_RELAYS } from '../env.js'; import { COMMON_CONTACT_RELAYS } from "../env.js";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
import App from '../app/index.js'; import App from "../app/index.js";
import PubkeyBatchLoader from './pubkey-batch-loader.js'; import PubkeyBatchLoader from "./pubkey-batch-loader.js";
/** Loads 3 contact lists for pubkeys */ /** Loads 3 contact lists for pubkeys */
export default class ContactBook { export default class ContactBook {
log = logger.extend('ContactsBook'); log = logger.extend("ContactsBook");
app: App; app: App;
loader: PubkeyBatchLoader; loader: PubkeyBatchLoader;
extraRelays = COMMON_CONTACT_RELAYS; extraRelays = COMMON_CONTACT_RELAYS;
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
this.loader = new PubkeyBatchLoader(kinds.Contacts, this.app.pool, (pubkey) => { this.loader = new PubkeyBatchLoader(kinds.Contacts, this.app.pool, (pubkey) => {
return this.app.eventStore.getEventsForFilters([{ kinds: [kinds.Contacts], authors: [pubkey] }])?.[0]; return this.app.eventStore.getEventsForFilters([{ kinds: [kinds.Contacts], authors: [pubkey] }])?.[0];
}); });
this.loader.on('event', (event) => this.app.eventStore.addEvent(event)); this.loader.on("event", (event) => this.app.eventStore.addEvent(event));
this.loader.on('batch', (found, failed) => { this.loader.on("batch", (found, failed) => {
this.log(`Found ${found}, failed ${failed}, pending ${this.loader.queue}`); this.log(`Found ${found}, failed ${failed}, pending ${this.loader.queue}`);
}); });
} }
getContacts(pubkey: string) { getContacts(pubkey: string) {
return this.loader.getEvent(pubkey); return this.loader.getEvent(pubkey);
} }
getFollowedPubkeys(pubkey: string): string[] { getFollowedPubkeys(pubkey: string): string[] {
const contacts = this.getContacts(pubkey); const contacts = this.getContacts(pubkey);
if (contacts) { if (contacts) {
return contacts.tags return contacts.tags
.filter((tag) => { .filter((tag) => {
return tag[0] === 'p'; return tag[0] === "p";
}) })
.map((tag) => { .map((tag) => {
return tag[1]; return tag[1];
}); });
} }
return []; return [];
} }
handleEvent(event: NostrEvent) { handleEvent(event: NostrEvent) {
this.loader.handleEvent(event); this.loader.handleEvent(event);
} }
async loadContacts(pubkey: string, relays: string[] = []) { async loadContacts(pubkey: string, relays: string[] = []) {
return this.loader.getOrLoadEvent(pubkey, relays); return this.loader.getOrLoadEvent(pubkey, relays);
} }
} }

View File

@@ -1,49 +1,49 @@
import { WebSocket } from 'ws'; import { WebSocket } from "ws";
import { ConfigMessage, ConfigResponse } from '@satellite-earth/core/types/control-api/config.js'; import { ConfigMessage, ConfigResponse } from "@satellite-earth/core/types/control-api/config.js";
import type App from '../../app/index.js'; import type App from "../../app/index.js";
import { type ControlMessageHandler } from './control-api.js'; import { type ControlMessageHandler } from "./control-api.js";
/** handles ['CONTROL', 'CONFIG', ...] messages */ /** handles ['CONTROL', 'CONFIG', ...] messages */
export default class ConfigActions implements ControlMessageHandler { export default class ConfigActions implements ControlMessageHandler {
app: App; app: App;
name = 'CONFIG'; name = "CONFIG";
private subscribed = new Set<WebSocket | NodeJS.Process>(); private subscribed = new Set<WebSocket | NodeJS.Process>();
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
// when config changes send it to the subscribed sockets // when config changes send it to the subscribed sockets
this.app.config.on('changed', (config) => { this.app.config.on("changed", (config) => {
for (const sock of this.subscribed) { for (const sock of this.subscribed) {
this.send(sock, ['CONTROL', 'CONFIG', 'CHANGED', config]); this.send(sock, ["CONTROL", "CONFIG", "CHANGED", config]);
} }
}); });
} }
handleMessage(sock: WebSocket | NodeJS.Process, message: ConfigMessage) { handleMessage(sock: WebSocket | NodeJS.Process, message: ConfigMessage) {
const method = message[2]; const method = message[2];
switch (method) { switch (method) {
case 'SUBSCRIBE': case "SUBSCRIBE":
this.subscribed.add(sock); this.subscribed.add(sock);
sock.once('close', () => this.subscribed.delete(sock)); sock.once("close", () => this.subscribed.delete(sock));
this.send(sock, ['CONTROL', 'CONFIG', 'CHANGED', this.app.config.data]); this.send(sock, ["CONTROL", "CONFIG", "CHANGED", this.app.config.data]);
return true; return true;
case 'SET': case "SET":
const field = message[3]; const field = message[3];
const value = message[4]; const value = message[4];
this.app.config.setField(field, value); this.app.config.setField(field, value);
return true; return true;
default: default:
return false; return false;
} }
} }
send(sock: WebSocket | NodeJS.Process, response: ConfigResponse) { send(sock: WebSocket | NodeJS.Process, response: ConfigResponse) {
sock.send?.(JSON.stringify(response)); sock.send?.(JSON.stringify(response));
} }
} }

View File

@@ -1,130 +1,130 @@
import { WebSocket, WebSocketServer } from 'ws'; import { WebSocket, WebSocketServer } from "ws";
import { type IncomingMessage } from 'http'; import { type IncomingMessage } from "http";
import { ControlResponse } from '@satellite-earth/core/types/control-api/index.js'; import { ControlResponse } from "@satellite-earth/core/types/control-api/index.js";
import type App from '../../app/index.js'; import type App from "../../app/index.js";
import { logger } from '../../logger.js'; import { logger } from "../../logger.js";
export type ControlMessage = ['CONTROL', string, string, ...any[]]; export type ControlMessage = ["CONTROL", string, string, ...any[]];
export interface ControlMessageHandler { export interface ControlMessageHandler {
app: App; app: App;
name: string; name: string;
handleConnection?(ws: WebSocket | NodeJS.Process): void; handleConnection?(ws: WebSocket | NodeJS.Process): void;
handleDisconnect?(socket: WebSocket): void; handleDisconnect?(socket: WebSocket): void;
handleMessage(sock: WebSocket | NodeJS.Process, message: ControlMessage): boolean | Promise<boolean>; handleMessage(sock: WebSocket | NodeJS.Process, message: ControlMessage): boolean | Promise<boolean>;
} }
/** handles web socket connections and 'CONTROL' messages */ /** handles web socket connections and 'CONTROL' messages */
export default class ControlApi { export default class ControlApi {
app: App; app: App;
auth?: string; auth?: string;
log = logger.extend('ControlApi'); log = logger.extend("ControlApi");
handlers = new Map<string, ControlMessageHandler>(); handlers = new Map<string, ControlMessageHandler>();
authenticatedConnections = new Set<WebSocket | NodeJS.Process>(); authenticatedConnections = new Set<WebSocket | NodeJS.Process>();
constructor(app: App, auth?: string) { constructor(app: App, auth?: string) {
this.app = app; this.app = app;
this.auth = auth; this.auth = auth;
} }
registerHandler(handler: ControlMessageHandler) { registerHandler(handler: ControlMessageHandler) {
this.handlers.set(handler.name, handler); this.handlers.set(handler.name, handler);
} }
unregisterHandler(handler: ControlMessageHandler) { unregisterHandler(handler: ControlMessageHandler) {
this.handlers.delete(handler.name); this.handlers.delete(handler.name);
} }
/** start listening for incoming ws connections */ /** start listening for incoming ws connections */
attachToServer(wss: WebSocketServer) { attachToServer(wss: WebSocketServer) {
wss.on('connection', this.handleConnection.bind(this)); wss.on("connection", this.handleConnection.bind(this));
} }
handleConnection(ws: WebSocket, req: IncomingMessage) { handleConnection(ws: WebSocket, req: IncomingMessage) {
ws.on('message', (data, isBinary) => { ws.on("message", (data, isBinary) => {
this.handleRawMessage(ws, data as Buffer); this.handleRawMessage(ws, data as Buffer);
}); });
for (const [id, handler] of this.handlers) { for (const [id, handler] of this.handlers) {
handler.handleConnection?.(ws); handler.handleConnection?.(ws);
} }
ws.once('close', () => this.handleDisconnect(ws)); ws.once("close", () => this.handleDisconnect(ws));
} }
handleDisconnect(ws: WebSocket) { handleDisconnect(ws: WebSocket) {
this.authenticatedConnections.delete(ws); this.authenticatedConnections.delete(ws);
for (const [id, handler] of this.handlers) { for (const [id, handler] of this.handlers) {
handler.handleDisconnect?.(ws); handler.handleDisconnect?.(ws);
} }
} }
attachToProcess(p: NodeJS.Process) { attachToProcess(p: NodeJS.Process) {
p.on('message', (message) => { p.on("message", (message) => {
if ( if (
Array.isArray(message) && Array.isArray(message) &&
message[0] === 'CONTROL' && message[0] === "CONTROL" &&
typeof message[1] === 'string' && typeof message[1] === "string" &&
typeof message[2] === 'string' typeof message[2] === "string"
) { ) {
this.handleMessage(p, message as ControlMessage); this.handleMessage(p, message as ControlMessage);
} }
}); });
for (const [id, handler] of this.handlers) { for (const [id, handler] of this.handlers) {
handler.handleConnection?.(p); handler.handleConnection?.(p);
} }
} }
/** handle a ws message */ /** handle a ws message */
async handleRawMessage(ws: WebSocket | NodeJS.Process, message: Buffer) { async handleRawMessage(ws: WebSocket | NodeJS.Process, message: Buffer) {
try { try {
const data = JSON.parse(message.toString()) as string[]; const data = JSON.parse(message.toString()) as string[];
try { try {
if ( if (
Array.isArray(data) && Array.isArray(data) &&
data[0] === 'CONTROL' && data[0] === "CONTROL" &&
typeof data[1] === 'string' && typeof data[1] === "string" &&
typeof data[2] === 'string' typeof data[2] === "string"
) { ) {
if (this.authenticatedConnections.has(ws) || data[1] === 'AUTH') { if (this.authenticatedConnections.has(ws) || data[1] === "AUTH") {
await this.handleMessage(ws, data as ControlMessage); await this.handleMessage(ws, data as ControlMessage);
} }
} }
} catch (err) { } catch (err) {
this.log('Failed to handle Control message', message.toString('utf-8')); this.log("Failed to handle Control message", message.toString("utf-8"));
this.log(err); this.log(err);
} }
} catch (error) { } catch (error) {
// failed to parse JSON, do nothing // failed to parse JSON, do nothing
} }
} }
/** handle a ['CONTROL', ...] message */ /** handle a ['CONTROL', ...] message */
async handleMessage(sock: WebSocket | NodeJS.Process, message: ControlMessage) { async handleMessage(sock: WebSocket | NodeJS.Process, message: ControlMessage) {
// handle ['CONTROL', 'AUTH', <code>] messages // handle ['CONTROL', 'AUTH', <code>] messages
if (message[1] === 'AUTH' && message[2] === 'CODE') { if (message[1] === "AUTH" && message[2] === "CODE") {
const code = message[3]; const code = message[3];
if (code === this.auth) { if (code === this.auth) {
this.authenticatedConnections.add(sock); this.authenticatedConnections.add(sock);
this.send(sock, ['CONTROL', 'AUTH', 'SUCCESS']); this.send(sock, ["CONTROL", "AUTH", "SUCCESS"]);
} else { } else {
this.send(sock, ['CONTROL', 'AUTH', 'INVALID', 'Invalid Auth Code']); this.send(sock, ["CONTROL", "AUTH", "INVALID", "Invalid Auth Code"]);
} }
return true; return true;
} }
const handler = this.handlers.get(message[1]); const handler = this.handlers.get(message[1]);
if (handler) { if (handler) {
return await handler.handleMessage(sock, message); return await handler.handleMessage(sock, message);
} }
this.log('Failed to handle Control message', message); this.log("Failed to handle Control message", message);
return false; return false;
} }
send(sock: WebSocket | NodeJS.Process, response: ControlResponse) { send(sock: WebSocket | NodeJS.Process, response: ControlResponse) {
sock.send?.(JSON.stringify(response)); sock.send?.(JSON.stringify(response));
} }
} }

View File

@@ -1,50 +1,50 @@
import { WebSocket } from 'ws'; import { WebSocket } from "ws";
import { import {
DecryptionCacheMessage, DecryptionCacheMessage,
DecryptionCacheResponse, DecryptionCacheResponse,
} from '@satellite-earth/core/types/control-api/decryption-cache.js'; } from "@satellite-earth/core/types/control-api/decryption-cache.js";
import type App from '../../app/index.js'; import type App from "../../app/index.js";
import { type ControlMessageHandler } from './control-api.js'; import { type ControlMessageHandler } from "./control-api.js";
/** handles ['CONTROL', 'DECRYPTION-CACHE', ...] messages */ /** handles ['CONTROL', 'DECRYPTION-CACHE', ...] messages */
export default class DecryptionCacheActions implements ControlMessageHandler { export default class DecryptionCacheActions implements ControlMessageHandler {
app: App; app: App;
name = 'DECRYPTION-CACHE'; name = "DECRYPTION-CACHE";
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
handleMessage(sock: WebSocket | NodeJS.Process, message: DecryptionCacheMessage) { handleMessage(sock: WebSocket | NodeJS.Process, message: DecryptionCacheMessage) {
const method = message[2]; const method = message[2];
switch (method) { switch (method) {
case 'ADD-CONTENT': case "ADD-CONTENT":
this.app.decryptionCache.addEventContent(message[3], message[4]); this.app.decryptionCache.addEventContent(message[3], message[4]);
return true; return true;
case 'CLEAR-PUBKEY': case "CLEAR-PUBKEY":
this.app.decryptionCache.clearPubkey(message[3]); this.app.decryptionCache.clearPubkey(message[3]);
return true; return true;
case 'CLEAR': case "CLEAR":
this.app.decryptionCache.clearAll(); this.app.decryptionCache.clearAll();
return true; return true;
case 'REQUEST': case "REQUEST":
this.app.decryptionCache.getEventsContent(message[3]).then((contents) => { this.app.decryptionCache.getEventsContent(message[3]).then((contents) => {
for (const { event, content } of contents) for (const { event, content } of contents)
this.send(sock, ['CONTROL', 'DECRYPTION-CACHE', 'CONTENT', event, content]); this.send(sock, ["CONTROL", "DECRYPTION-CACHE", "CONTENT", event, content]);
this.send(sock, ['CONTROL', 'DECRYPTION-CACHE', 'END']); this.send(sock, ["CONTROL", "DECRYPTION-CACHE", "END"]);
}); });
return true; return true;
default: default:
return false; return false;
} }
} }
send(sock: WebSocket | NodeJS.Process, response: DecryptionCacheResponse) { send(sock: WebSocket | NodeJS.Process, response: DecryptionCacheResponse) {
sock.send?.(JSON.stringify(response)); sock.send?.(JSON.stringify(response));
} }
} }

View File

@@ -1,31 +1,31 @@
import { WebSocket } from 'ws'; import { WebSocket } from "ws";
import { DirectMessageMessage } from '@satellite-earth/core/types/control-api/direct-messages.js'; import { DirectMessageMessage } from "@satellite-earth/core/types/control-api/direct-messages.js";
import type App from '../../app/index.js'; import type App from "../../app/index.js";
import { type ControlMessageHandler } from './control-api.js'; import { type ControlMessageHandler } from "./control-api.js";
/** handles ['CONTROL', 'DM', ...] messages */ /** handles ['CONTROL', 'DM', ...] messages */
export default class DirectMessageActions implements ControlMessageHandler { export default class DirectMessageActions implements ControlMessageHandler {
app: App; app: App;
name = 'DM'; name = "DM";
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
handleMessage(sock: WebSocket | NodeJS.Process, message: DirectMessageMessage) { handleMessage(sock: WebSocket | NodeJS.Process, message: DirectMessageMessage) {
const method = message[2]; const method = message[2];
switch (method) { switch (method) {
case 'OPEN': case "OPEN":
this.app.directMessageManager.openConversation(message[3], message[4]); this.app.directMessageManager.openConversation(message[3], message[4]);
return true; return true;
case 'CLOSE': case "CLOSE":
this.app.directMessageManager.closeConversation(message[3], message[4]); this.app.directMessageManager.closeConversation(message[3], message[4]);
return true; return true;
default: default:
return false; return false;
} }
} }
} }

View File

@@ -1,27 +1,27 @@
import { WebSocket } from 'ws'; import { WebSocket } from "ws";
import { LogsMessage } from '@satellite-earth/core/types/control-api/logs.js'; import { LogsMessage } from "@satellite-earth/core/types/control-api/logs.js";
import type App from '../../app/index.js'; import type App from "../../app/index.js";
import { type ControlMessageHandler } from './control-api.js'; import { type ControlMessageHandler } from "./control-api.js";
/** handles ['CONTROL', 'DM', ...] messages */ /** handles ['CONTROL', 'DM', ...] messages */
export default class LogsActions implements ControlMessageHandler { export default class LogsActions implements ControlMessageHandler {
app: App; app: App;
name = 'LOGS'; name = "LOGS";
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
handleMessage(sock: WebSocket | NodeJS.Process, message: LogsMessage) { handleMessage(sock: WebSocket | NodeJS.Process, message: LogsMessage) {
const method = message[2]; const method = message[2];
switch (method) { switch (method) {
case 'CLEAR': case "CLEAR":
this.app.logStore.clearLogs(message[3] ? { service: message[3] } : undefined); this.app.logStore.clearLogs(message[3] ? { service: message[3] } : undefined);
return true; return true;
default: default:
return false; return false;
} }
} }
} }

View File

@@ -1,43 +1,43 @@
import { WebSocket } from 'ws'; import { WebSocket } from "ws";
import { NotificationsMessage, NotificationsResponse } from '@satellite-earth/core/types/control-api/notifications.js'; import { NotificationsMessage, NotificationsResponse } from "@satellite-earth/core/types/control-api/notifications.js";
import { ControlMessageHandler } from './control-api.js'; import { ControlMessageHandler } from "./control-api.js";
import type App from '../../app/index.js'; import type App from "../../app/index.js";
import { NostrEvent } from 'nostr-tools'; import { NostrEvent } from "nostr-tools";
export default class NotificationActions implements ControlMessageHandler { export default class NotificationActions implements ControlMessageHandler {
app: App; app: App;
name = 'NOTIFICATIONS'; name = "NOTIFICATIONS";
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
handleMessage(sock: WebSocket | NodeJS.Process, message: NotificationsMessage): boolean { handleMessage(sock: WebSocket | NodeJS.Process, message: NotificationsMessage): boolean {
const action = message[2]; const action = message[2];
switch (action) { switch (action) {
case 'GET-VAPID-KEY': case "GET-VAPID-KEY":
this.send(sock, ['CONTROL', 'NOTIFICATIONS', 'VAPID-KEY', this.app.notifications.webPushKeys.publicKey]); this.send(sock, ["CONTROL", "NOTIFICATIONS", "VAPID-KEY", this.app.notifications.webPushKeys.publicKey]);
return true; return true;
case 'REGISTER': case "REGISTER":
this.app.notifications.addOrUpdateChannel(message[3]); this.app.notifications.addOrUpdateChannel(message[3]);
return true; return true;
case 'NOTIFY': case "NOTIFY":
const event: NostrEvent | undefined = this.app.eventStore.getEventsForFilters([{ ids: [message[3]] }])?.[0]; const event: NostrEvent | undefined = this.app.eventStore.getEventsForFilters([{ ids: [message[3]] }])?.[0];
if (event) this.app.notifications.notify(event); if (event) this.app.notifications.notify(event);
return true; return true;
case 'UNREGISTER': case "UNREGISTER":
this.app.notifications.removeChannel(message[3]); this.app.notifications.removeChannel(message[3]);
return true; return true;
default: default:
return false; return false;
} }
} }
send(sock: WebSocket | NodeJS.Process, response: NotificationsResponse) { send(sock: WebSocket | NodeJS.Process, response: NotificationsResponse) {
sock.send?.(JSON.stringify(response)); sock.send?.(JSON.stringify(response));
} }
} }

View File

@@ -1,29 +1,29 @@
import { WebSocket } from 'ws'; import { WebSocket } from "ws";
import { ReceiverMessage } from '@satellite-earth/core/types/control-api/receiver.js'; import { ReceiverMessage } from "@satellite-earth/core/types/control-api/receiver.js";
import type App from '../../app/index.js'; import type App from "../../app/index.js";
import { type ControlMessageHandler } from './control-api.js'; import { type ControlMessageHandler } from "./control-api.js";
export default class ReceiverActions implements ControlMessageHandler { export default class ReceiverActions implements ControlMessageHandler {
app: App; app: App;
name = 'RECEIVER'; name = "RECEIVER";
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
handleMessage(sock: WebSocket | NodeJS.Process, message: ReceiverMessage): boolean { handleMessage(sock: WebSocket | NodeJS.Process, message: ReceiverMessage): boolean {
const action = message[2]; const action = message[2];
switch (action) { switch (action) {
case 'START': case "START":
this.app.receiver.start(); this.app.receiver.start();
return true; return true;
case 'STOP': case "STOP":
this.app.receiver.stop(); this.app.receiver.stop();
return true; return true;
default: default:
return false; return false;
} }
} }
} }

View File

@@ -1,69 +1,69 @@
import { WebSocket } from 'ws'; import { WebSocket } from "ws";
import { verifyEvent } from 'nostr-tools'; import { verifyEvent } from "nostr-tools";
import { RemoteAuthMessage, RemoteAuthResponse } from '@satellite-earth/core/types/control-api/remote-auth.js'; import { RemoteAuthMessage, RemoteAuthResponse } from "@satellite-earth/core/types/control-api/remote-auth.js";
import type App from '../../app/index.js'; import type App from "../../app/index.js";
import { type ControlMessageHandler } from './control-api.js'; import { type ControlMessageHandler } from "./control-api.js";
/** handles ['CONTROL', 'REMOTE-AUTH', ...] messages */ /** handles ['CONTROL', 'REMOTE-AUTH', ...] messages */
export default class RemoteAuthActions implements ControlMessageHandler { export default class RemoteAuthActions implements ControlMessageHandler {
app: App; app: App;
name = 'REMOTE-AUTH'; name = "REMOTE-AUTH";
private subscribed = new Set<WebSocket | NodeJS.Process>(); private subscribed = new Set<WebSocket | NodeJS.Process>();
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
// when config changes send it to the subscribed sockets // when config changes send it to the subscribed sockets
this.app.pool.emitter.on('challenge', (relay, challenge) => { this.app.pool.emitter.on("challenge", (relay, challenge) => {
for (const sock of this.subscribed) { for (const sock of this.subscribed) {
this.send(sock, [ this.send(sock, [
'CONTROL', "CONTROL",
'REMOTE-AUTH', "REMOTE-AUTH",
'STATUS', "STATUS",
relay.url, relay.url,
challenge, challenge,
!!this.app.pool.authenticated.get(relay.url), !!this.app.pool.authenticated.get(relay.url),
]); ]);
} }
}); });
} }
sendAllStatuses(sock: WebSocket | NodeJS.Process) { sendAllStatuses(sock: WebSocket | NodeJS.Process) {
for (const [url, relay] of this.app.pool) { for (const [url, relay] of this.app.pool) {
const challenge = this.app.pool.challenges.get(url); const challenge = this.app.pool.challenges.get(url);
const authenticated = this.app.pool.isAuthenticated(url); const authenticated = this.app.pool.isAuthenticated(url);
if (challenge) { if (challenge) {
this.send(sock, ['CONTROL', 'REMOTE-AUTH', 'STATUS', url, challenge, authenticated]); this.send(sock, ["CONTROL", "REMOTE-AUTH", "STATUS", url, challenge, authenticated]);
} }
} }
} }
async handleMessage(sock: WebSocket | NodeJS.Process, message: RemoteAuthMessage) { async handleMessage(sock: WebSocket | NodeJS.Process, message: RemoteAuthMessage) {
const method = message[2]; const method = message[2];
switch (method) { switch (method) {
case 'SUBSCRIBE': case "SUBSCRIBE":
this.subscribed.add(sock); this.subscribed.add(sock);
sock.once('close', () => this.subscribed.delete(sock)); sock.once("close", () => this.subscribed.delete(sock));
this.sendAllStatuses(sock); this.sendAllStatuses(sock);
return true; return true;
case 'UNSUBSCRIBE': case "UNSUBSCRIBE":
this.subscribed.delete(sock); this.subscribed.delete(sock);
return true; return true;
case 'AUTHENTICATE': case "AUTHENTICATE":
const event = message[3]; const event = message[3];
if (verifyEvent(event)) { if (verifyEvent(event)) {
const relay = event.tags.find((t) => (t[0] = 'relay'))?.[1]; const relay = event.tags.find((t) => (t[0] = "relay"))?.[1];
if (relay) await this.app.pool.authenticate(relay, event); if (relay) await this.app.pool.authenticate(relay, event);
} }
default: default:
return false; return false;
} }
} }
send(sock: WebSocket | NodeJS.Process, response: RemoteAuthResponse) { send(sock: WebSocket | NodeJS.Process, response: RemoteAuthResponse) {
sock.send?.(JSON.stringify(response)); sock.send?.(JSON.stringify(response));
} }
} }

View File

@@ -1,93 +1,93 @@
import { WebSocket } from 'ws'; import { WebSocket } from "ws";
import { ReportArguments } from '@satellite-earth/core/types'; import { ReportArguments } from "@satellite-earth/core/types";
import { ReportsMessage } from '@satellite-earth/core/types/control-api/reports.js'; import { ReportsMessage } from "@satellite-earth/core/types/control-api/reports.js";
import type App from '../../app/index.js'; import type App from "../../app/index.js";
import { type ControlMessageHandler } from './control-api.js'; import { type ControlMessageHandler } from "./control-api.js";
import Report from '../reports/report.js'; import Report from "../reports/report.js";
import { logger } from '../../logger.js'; import { logger } from "../../logger.js";
import REPORT_CLASSES from '../reports/reports/index.js'; import REPORT_CLASSES from "../reports/reports/index.js";
/** handles ['CONTROL', 'REPORT', ...] messages */ /** handles ['CONTROL', 'REPORT', ...] messages */
export default class ReportActions implements ControlMessageHandler { export default class ReportActions implements ControlMessageHandler {
app: App; app: App;
name = 'REPORT'; name = "REPORT";
log = logger.extend('ReportActions'); log = logger.extend("ReportActions");
types: { types: {
[k in keyof ReportArguments]?: typeof Report<k>; [k in keyof ReportArguments]?: typeof Report<k>;
} = REPORT_CLASSES; } = REPORT_CLASSES;
private reports = new Map<WebSocket | NodeJS.Process, Map<string, Report<any>>>(); private reports = new Map<WebSocket | NodeJS.Process, Map<string, Report<any>>>();
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
private getReportsForSocket(socket: WebSocket | NodeJS.Process) { private getReportsForSocket(socket: WebSocket | NodeJS.Process) {
let map = this.reports.get(socket); let map = this.reports.get(socket);
if (map) return map; if (map) return map;
map = new Map(); map = new Map();
this.reports.set(socket, map); this.reports.set(socket, map);
return map; return map;
} }
handleDisconnect(ws: WebSocket): void { handleDisconnect(ws: WebSocket): void {
// close all reports for socket on disconnect // close all reports for socket on disconnect
const reports = this.reports.get(ws); const reports = this.reports.get(ws);
if (reports) { if (reports) {
for (const [id, report] of reports) report.close(); for (const [id, report] of reports) report.close();
if (reports.size) this.log(`Closed ${reports.size} reports for disconnected socket`); if (reports.size) this.log(`Closed ${reports.size} reports for disconnected socket`);
this.reports.delete(ws); this.reports.delete(ws);
} }
} }
// TODO: maybe move some of this logic out to a manager class so the control action class can be simpler // TODO: maybe move some of this logic out to a manager class so the control action class can be simpler
async handleMessage(sock: WebSocket | NodeJS.Process, message: ReportsMessage) { async handleMessage(sock: WebSocket | NodeJS.Process, message: ReportsMessage) {
const method = message[2]; const method = message[2];
switch (method) { switch (method) {
case 'SUBSCRIBE': { case "SUBSCRIBE": {
const reports = this.getReportsForSocket(sock); const reports = this.getReportsForSocket(sock);
const id = message[3]; const id = message[3];
const type = message[4]; const type = message[4];
const args = message[5]; const args = message[5];
let report = reports.get(id) as Report<typeof type> | undefined; let report = reports.get(id) as Report<typeof type> | undefined;
if (!report) { if (!report) {
const ReportClass = this.types[type]; const ReportClass = this.types[type];
if (!ReportClass) throw new Error('Missing class for report type: ' + type); if (!ReportClass) throw new Error("Missing class for report type: " + type);
this.log(`Creating ${type} ${id} report with args`, JSON.stringify(args)); this.log(`Creating ${type} ${id} report with args`, JSON.stringify(args));
report = new ReportClass(id, this.app, sock); report = new ReportClass(id, this.app, sock);
reports.set(id, report); reports.set(id, report);
} }
await report.run(args); await report.run(args);
return true; return true;
} }
case 'CLOSE': { case "CLOSE": {
const reports = this.getReportsForSocket(sock); const reports = this.getReportsForSocket(sock);
const id = message[3]; const id = message[3];
const report = reports.get(id); const report = reports.get(id);
if (report) { if (report) {
await report.close(); await report.close();
reports.delete(id); reports.delete(id);
} }
return true; return true;
} }
default: default:
return false; return false;
} }
} }
cleanup() { cleanup() {
for (const [sock, reports] of this.reports) { for (const [sock, reports] of this.reports) {
for (const [id, report] of reports) { for (const [id, report] of reports) {
report.close(); report.close();
} }
} }
} }
} }

View File

@@ -1,37 +1,37 @@
import { WebSocket } from 'ws'; import { WebSocket } from "ws";
import { ScrapperMessage } from '@satellite-earth/core/types/control-api/scrapper.js'; import { ScrapperMessage } from "@satellite-earth/core/types/control-api/scrapper.js";
import type App from '../../app/index.js'; import type App from "../../app/index.js";
import { type ControlMessageHandler } from './control-api.js'; import { type ControlMessageHandler } from "./control-api.js";
export default class ScrapperActions implements ControlMessageHandler { export default class ScrapperActions implements ControlMessageHandler {
app: App; app: App;
name = 'SCRAPPER'; name = "SCRAPPER";
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
handleMessage(sock: WebSocket | NodeJS.Process, message: ScrapperMessage): boolean { handleMessage(sock: WebSocket | NodeJS.Process, message: ScrapperMessage): boolean {
const action = message[2]; const action = message[2];
switch (action) { switch (action) {
case 'START': case "START":
this.app.scrapper.start(); this.app.scrapper.start();
return true; return true;
case 'STOP': case "STOP":
this.app.scrapper.stop(); this.app.scrapper.stop();
return true; return true;
case 'ADD-PUBKEY': case "ADD-PUBKEY":
this.app.scrapper.addPubkey(message[3]); this.app.scrapper.addPubkey(message[3]);
return true; return true;
case 'REMOVE-PUBKEY': case "REMOVE-PUBKEY":
this.app.scrapper.removePubkey(message[3]); this.app.scrapper.removePubkey(message[3]);
return true; return true;
default: default:
return false; return false;
} }
} }
} }

View File

@@ -1,153 +1,153 @@
import { mapParams } from '@satellite-earth/core/helpers/sql.js'; import { mapParams } from "@satellite-earth/core/helpers/sql.js";
import { MigrationSet } from '@satellite-earth/core/sqlite'; import { MigrationSet } from "@satellite-earth/core/sqlite";
import { type Database } from 'better-sqlite3'; import { type Database } from "better-sqlite3";
import { EventEmitter } from 'events'; import { EventEmitter } from "events";
import { logger } from '../../logger.js'; import { logger } from "../../logger.js";
import { EventRow, parseEventRow } from '@satellite-earth/core/sqlite-event-store'; import { EventRow, parseEventRow } from "@satellite-earth/core/sqlite-event-store";
import { NostrEvent } from 'nostr-tools'; import { NostrEvent } from "nostr-tools";
const migrations = new MigrationSet('decryption-cache'); const migrations = new MigrationSet("decryption-cache");
// Version 1 // Version 1
migrations.addScript(1, async (db, log) => { migrations.addScript(1, async (db, log) => {
db.prepare( db.prepare(
` `
CREATE TABLE "decryption_cache" ( CREATE TABLE "decryption_cache" (
"event" TEXT(64) NOT NULL, "event" TEXT(64) NOT NULL,
"content" TEXT NOT NULL, "content" TEXT NOT NULL,
PRIMARY KEY("event") PRIMARY KEY("event")
); );
`, `,
).run(); ).run();
}); });
// Version 2, search // Version 2, search
migrations.addScript(2, async (db, log) => { migrations.addScript(2, async (db, log) => {
// create external Content fts5 table // create external Content fts5 table
db.prepare( db.prepare(
`CREATE VIRTUAL TABLE IF NOT EXISTS decryption_cache_fts USING fts5(content, content='decryption_cache', tokenize='trigram')`, `CREATE VIRTUAL TABLE IF NOT EXISTS decryption_cache_fts USING fts5(content, content='decryption_cache', tokenize='trigram')`,
).run(); ).run();
log(`Created decryption cache search table`); log(`Created decryption cache search table`);
// create triggers to sync table // create triggers to sync table
db.prepare( db.prepare(
` `
CREATE TRIGGER IF NOT EXISTS decryption_cache_ai AFTER INSERT ON decryption_cache BEGIN CREATE TRIGGER IF NOT EXISTS decryption_cache_ai AFTER INSERT ON decryption_cache BEGIN
INSERT INTO decryption_cache_fts(rowid, content) VALUES (NEW.rowid, NEW.content); INSERT INTO decryption_cache_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
END; END;
`, `,
).run(); ).run();
db.prepare( db.prepare(
` `
CREATE TRIGGER IF NOT EXISTS decryption_cache_ad AFTER DELETE ON decryption_cache BEGIN CREATE TRIGGER IF NOT EXISTS decryption_cache_ad AFTER DELETE ON decryption_cache BEGIN
INSERT INTO decryption_cache_ai(decryption_cache_ai, rowid, content) VALUES('delete', OLD.rowid, OLD.content); INSERT INTO decryption_cache_ai(decryption_cache_ai, rowid, content) VALUES('delete', OLD.rowid, OLD.content);
END; END;
`, `,
).run(); ).run();
// populate table // populate table
const inserted = db const inserted = db
.prepare(`INSERT INTO decryption_cache_fts (rowid, content) SELECT rowid, content FROM decryption_cache`) .prepare(`INSERT INTO decryption_cache_fts (rowid, content) SELECT rowid, content FROM decryption_cache`)
.run(); .run();
log(`Indexed ${inserted.changes} decrypted events in search table`); log(`Indexed ${inserted.changes} decrypted events in search table`);
}); });
type EventMap = { type EventMap = {
cache: [string, string]; cache: [string, string];
}; };
export default class DecryptionCache extends EventEmitter<EventMap> { export default class DecryptionCache extends EventEmitter<EventMap> {
database: Database; database: Database;
log = logger.extend('DecryptionCache'); log = logger.extend("DecryptionCache");
constructor(database: Database) { constructor(database: Database) {
super(); super();
this.database = database; this.database = database;
} }
setup() { setup() {
return migrations.run(this.database); return migrations.run(this.database);
} }
/** cache the decrypted content of an event */ /** cache the decrypted content of an event */
addEventContent(id: string, plaintext: string) { addEventContent(id: string, plaintext: string) {
const result = this.database const result = this.database
.prepare<[string, string]>(`INSERT INTO decryption_cache (event, content) VALUES (?, ?)`) .prepare<[string, string]>(`INSERT INTO decryption_cache (event, content) VALUES (?, ?)`)
.run(id, plaintext); .run(id, plaintext);
if (result.changes > 0) { if (result.changes > 0) {
this.log(`Saved content for ${id}`); this.log(`Saved content for ${id}`);
this.emit('cache', id, plaintext); this.emit("cache", id, plaintext);
} }
} }
/** remove all cached content relating to a pubkey */ /** remove all cached content relating to a pubkey */
clearPubkey(pubkey: string) { clearPubkey(pubkey: string) {
// this.database.prepare<string>(`DELETE FROM decryption_cache INNER JOIN events ON event=events.id`) // this.database.prepare<string>(`DELETE FROM decryption_cache INNER JOIN events ON event=events.id`)
} }
/** clear all cached content */ /** clear all cached content */
clearAll() { clearAll() {
this.database.prepare(`DELETE FROM decryption_cache`).run(); this.database.prepare(`DELETE FROM decryption_cache`).run();
} }
async search( async search(
search: string, search: string,
filter?: { conversation?: [string, string]; order?: 'rank' | 'created_at' }, filter?: { conversation?: [string, string]; order?: "rank" | "created_at" },
): Promise<{ event: NostrEvent; plaintext: string }[]> { ): Promise<{ event: NostrEvent; plaintext: string }[]> {
const params: any[] = []; const params: any[] = [];
const andConditions: string[] = []; const andConditions: string[] = [];
let sql = `SELECT events.*, decryption_cache.content as plaintext FROM decryption_cache_fts let sql = `SELECT events.*, decryption_cache.content as plaintext FROM decryption_cache_fts
INNER JOIN decryption_cache ON decryption_cache_fts.rowid = decryption_cache.rowid INNER JOIN decryption_cache ON decryption_cache_fts.rowid = decryption_cache.rowid
INNER JOIN events ON decryption_cache.event = events.id`; INNER JOIN events ON decryption_cache.event = events.id`;
andConditions.push('decryption_cache_fts MATCH ?'); andConditions.push("decryption_cache_fts MATCH ?");
params.push(search); params.push(search);
// filter down by authors // filter down by authors
if (filter?.conversation) { if (filter?.conversation) {
sql += `\nINNER JOIN tags ON tag.e = events.id AND tags.t = 'p'`; sql += `\nINNER JOIN tags ON tag.e = events.id AND tags.t = 'p'`;
andConditions.push(`(tags.v = ? AND events.pubkey = ?) OR (tags.v = ? AND events.pubkey = ?)`); andConditions.push(`(tags.v = ? AND events.pubkey = ?) OR (tags.v = ? AND events.pubkey = ?)`);
params.push(...filter.conversation, ...Array.from(filter.conversation).reverse()); params.push(...filter.conversation, ...Array.from(filter.conversation).reverse());
} }
if (andConditions.length > 0) { if (andConditions.length > 0) {
sql += ` WHERE ${andConditions.join(' AND ')}`; sql += ` WHERE ${andConditions.join(" AND ")}`;
} }
switch (filter?.order) { switch (filter?.order) {
case 'rank': case "rank":
sql += ' ORDER BY rank'; sql += " ORDER BY rank";
break; break;
case 'created_at': case "created_at":
default: default:
sql += ' ORDER BY events.created_at DESC'; sql += " ORDER BY events.created_at DESC";
break; break;
} }
return this.database return this.database
.prepare<any[], EventRow & { plaintext: string }>(sql) .prepare<any[], EventRow & { plaintext: string }>(sql)
.all(...params) .all(...params)
.map((row) => ({ event: parseEventRow(row), plaintext: row.plaintext })); .map((row) => ({ event: parseEventRow(row), plaintext: row.plaintext }));
} }
async getEventContent(id: string) { async getEventContent(id: string) {
const result = this.database const result = this.database
.prepare<[string], { event: string; content: string }>(`SELECT * FROM decryption_cache WHERE event=?`) .prepare<[string], { event: string; content: string }>(`SELECT * FROM decryption_cache WHERE event=?`)
.get(id); .get(id);
return result?.content; return result?.content;
} }
async getEventsContent(ids: string[]) { async getEventsContent(ids: string[]) {
return this.database return this.database
.prepare< .prepare<
string[], string[],
{ event: string; content: string } { event: string; content: string }
>(`SELECT * FROM decryption_cache WHERE event IN ${mapParams(ids)}`) >(`SELECT * FROM decryption_cache WHERE event IN ${mapParams(ids)}`)
.all(...ids); .all(...ids);
} }
} }

View File

@@ -1,168 +1,169 @@
import { NostrEvent, kinds } from 'nostr-tools'; import { NostrEvent, kinds } from "nostr-tools";
import { SubCloser } from 'nostr-tools/abstract-pool'; import { SubCloser } from "nostr-tools/abstract-pool";
import { Subscription } from 'nostr-tools/abstract-relay'; import { Subscription } from "nostr-tools/abstract-relay";
import { EventEmitter } from 'events'; import { getInboxes } from "applesauce-core/helpers";
import { EventEmitter } from "events";
import { getInboxes } from '@satellite-earth/core/helpers/nostr/mailboxes.js'; import { getRelaysFromContactList } from "@satellite-earth/core/helpers/nostr/contacts.js";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
import type App from '../app/index.js'; import type App from "../app/index.js";
import { getRelaysFromContactList } from '@satellite-earth/core/helpers/nostr/contacts.js'; import { arrayFallback } from "../helpers/array.js";
type EventMap = { type EventMap = {
open: [string, string]; open: [string, string];
close: [string, string]; close: [string, string];
message: [NostrEvent]; message: [NostrEvent];
}; };
/** handles sending and receiving direct messages */ /** handles sending and receiving direct messages */
export default class DirectMessageManager extends EventEmitter<EventMap> { export default class DirectMessageManager extends EventEmitter<EventMap> {
log = logger.extend('DirectMessageManager'); log = logger.extend("DirectMessageManager");
app: App; app: App;
private explicitRelays: string[] = []; private explicitRelays: string[] = [];
constructor(app: App) { constructor(app: App) {
super(); super();
this.app = app; this.app = app;
// Load profiles for participants when // Load profiles for participants when
// a conversation thread is opened // a conversation thread is opened
this.on('open', (a, b) => { this.on("open", (a, b) => {
this.app.profileBook.loadProfile(a, this.app.addressBook.getOutboxes(a)); this.app.profileBook.loadProfile(a, this.app.addressBook.getOutboxes(a));
this.app.profileBook.loadProfile(b, this.app.addressBook.getOutboxes(b)); this.app.profileBook.loadProfile(b, this.app.addressBook.getOutboxes(b));
}); });
// emit a "message" event when a new kind4 message is detected // emit a "message" event when a new kind4 message is detected
this.app.eventStore.on('event:inserted', (event) => { this.app.eventStore.on("event:inserted", (event) => {
if (event.kind === kinds.EncryptedDirectMessage) this.emit('message', event); if (event.kind === kinds.EncryptedDirectMessage) this.emit("message", event);
}); });
} }
/** sends a DM event to the receivers inbox relays */ /** sends a DM event to the receivers inbox relays */
async forwardMessage(event: NostrEvent) { async forwardMessage(event: NostrEvent) {
if (event.kind !== kinds.EncryptedDirectMessage) return; if (event.kind !== kinds.EncryptedDirectMessage) return;
const addressedTo = event.tags.find((t) => t[0] === 'p')?.[1]; const addressedTo = event.tags.find((t) => t[0] === "p")?.[1];
if (!addressedTo) return; if (!addressedTo) return;
// get users inboxes // get users inboxes
let relays = await this.app.addressBook.loadInboxes(addressedTo); let relays = await this.app.addressBook.loadInboxes(addressedTo);
if (!relays || relays.length === 0) { if (!relays || relays.length === 0) {
// try to send the DM to the users legacy app relays // try to send the DM to the users legacy app relays
const contacts = await this.app.contactBook.loadContacts(addressedTo); const contacts = await this.app.contactBook.loadContacts(addressedTo);
if (contacts) { if (contacts) {
const appRelays = getRelaysFromContactList(contacts); const appRelays = getRelaysFromContactList(contacts);
if (appRelays) relays = appRelays.filter((r) => r.write).map((r) => r.url); if (appRelays) relays = appRelays.filter((r) => r.write).map((r) => r.url);
} }
} }
if (!relays || relays.length === 0) { if (!relays || relays.length === 0) {
// use fallback relays // use fallback relays
relays = this.explicitRelays; relays = this.explicitRelays;
} }
this.log(`Forwarding message to ${relays.length} relays`); this.log(`Forwarding message to ${relays.length} relays`);
const results = await Promise.allSettled(this.app.pool.publish(relays, event)); const results = await Promise.allSettled(this.app.pool.publish(relays, event));
return results; return results;
} }
private getConversationKey(a: string, b: string) { private getConversationKey(a: string, b: string) {
if (a < b) return a + ':' + b; if (a < b) return a + ":" + b;
else return b + ':' + a; else return b + ":" + a;
} }
watching = new Map<string, Map<string, Subscription>>(); watching = new Map<string, Map<string, Subscription>>();
async watchInbox(pubkey: string) { async watchInbox(pubkey: string) {
if (this.watching.has(pubkey)) return; if (this.watching.has(pubkey)) return;
this.log(`Watching ${pubkey} inboxes for mail`); this.log(`Watching ${pubkey} inboxes for mail`);
const mailboxes = await this.app.addressBook.loadMailboxes(pubkey); const mailboxes = await this.app.addressBook.loadMailboxes(pubkey);
if (!mailboxes) { if (!mailboxes) {
this.log(`Failed to get ${pubkey} mailboxes`); this.log(`Failed to get ${pubkey} mailboxes`);
return; return;
} }
const relays = getInboxes(mailboxes, this.explicitRelays); const relays = arrayFallback(getInboxes(mailboxes), this.explicitRelays);
const subscriptions = new Map<string, Subscription>(); const subscriptions = new Map<string, Subscription>();
for (const url of relays) { for (const url of relays) {
const subscribe = async () => { const subscribe = async () => {
const relay = await this.app.pool.ensureRelay(url); const relay = await this.app.pool.ensureRelay(url);
const sub = relay.subscribe([{ kinds: [kinds.EncryptedDirectMessage], '#p': [pubkey] }], { const sub = relay.subscribe([{ kinds: [kinds.EncryptedDirectMessage], "#p": [pubkey] }], {
onevent: (event) => { onevent: (event) => {
this.app.eventStore.addEvent(event); this.app.eventStore.addEvent(event);
}, },
onclose: () => { onclose: () => {
// reconnect if we are still watching this pubkey // reconnect if we are still watching this pubkey
if (this.watching.has(pubkey)) { if (this.watching.has(pubkey)) {
this.log(`Reconnecting to ${relay.url} for ${pubkey} inbox DMs`); this.log(`Reconnecting to ${relay.url} for ${pubkey} inbox DMs`);
setTimeout(() => subscribe(), 30_000); setTimeout(() => subscribe(), 30_000);
} }
}, },
}); });
subscriptions.set(relay.url, sub); subscriptions.set(relay.url, sub);
}; };
subscribe(); subscribe();
} }
this.watching.set(pubkey, subscriptions); this.watching.set(pubkey, subscriptions);
} }
stopWatchInbox(pubkey: string) { stopWatchInbox(pubkey: string) {
const subs = this.watching.get(pubkey); const subs = this.watching.get(pubkey);
if (subs) { if (subs) {
this.watching.delete(pubkey); this.watching.delete(pubkey);
for (const [_, sub] of subs) { for (const [_, sub] of subs) {
sub.close(); sub.close();
} }
} }
} }
subscriptions = new Map<string, SubCloser>(); subscriptions = new Map<string, SubCloser>();
async openConversation(a: string, b: string) { async openConversation(a: string, b: string) {
const key = this.getConversationKey(a, b); const key = this.getConversationKey(a, b);
if (this.subscriptions.has(key)) return; if (this.subscriptions.has(key)) return;
const aMailboxes = await this.app.addressBook.loadMailboxes(a); const aMailboxes = await this.app.addressBook.loadMailboxes(a);
const bMailboxes = await this.app.addressBook.loadMailboxes(b); const bMailboxes = await this.app.addressBook.loadMailboxes(b);
// If inboxes for either user cannot be determined, either because nip65 // If inboxes for either user cannot be determined, either because nip65
// was not found, or nip65 had no listed read relays, fall back to explicit // was not found, or nip65 had no listed read relays, fall back to explicit
const aInboxes = aMailboxes ? getInboxes(aMailboxes, this.explicitRelays) : this.explicitRelays; const aInboxes = aMailboxes ? arrayFallback(getInboxes(aMailboxes), this.explicitRelays) : this.explicitRelays;
const bInboxes = bMailboxes ? getInboxes(bMailboxes, this.explicitRelays) : this.explicitRelays; const bInboxes = bMailboxes ? arrayFallback(getInboxes(bMailboxes), this.explicitRelays) : this.explicitRelays;
const relays = new Set([...aInboxes, ...bInboxes]); const relays = new Set([...aInboxes, ...bInboxes]);
let events = 0; let events = 0;
const sub = this.app.pool.subscribeMany( const sub = this.app.pool.subscribeMany(
Array.from(relays), Array.from(relays),
[{ kinds: [kinds.EncryptedDirectMessage], authors: [a, b], '#p': [a, b] }], [{ kinds: [kinds.EncryptedDirectMessage], authors: [a, b], "#p": [a, b] }],
{ {
onevent: (event) => { onevent: (event) => {
events += +this.app.eventStore.addEvent(event); events += +this.app.eventStore.addEvent(event);
}, },
oneose: () => { oneose: () => {
if (events) this.log(`Found ${events} new messages`); if (events) this.log(`Found ${events} new messages`);
}, },
}, },
); );
this.log(`Opened conversation ${key} on ${relays.size} relays`); this.log(`Opened conversation ${key} on ${relays.size} relays`);
this.subscriptions.set(key, sub); this.subscriptions.set(key, sub);
this.emit('open', a, b); this.emit("open", a, b);
} }
closeConversation(a: string, b: string) { closeConversation(a: string, b: string) {
const key = this.getConversationKey(a, b); const key = this.getConversationKey(a, b);
const sub = this.subscriptions.get(key); const sub = this.subscriptions.get(key);
if (sub) { if (sub) {
sub.close(); sub.close();
this.subscriptions.delete(key); this.subscriptions.delete(key);
this.emit('close', a, b); this.emit("close", a, b);
} }
} }
} }

View File

@@ -1,132 +1,132 @@
import { SimpleSigner } from 'applesauce-signer/signers/simple-signer'; import { SimpleSigner } from "applesauce-signers/signers/simple-signer";
import { EventTemplate, SimplePool } from 'nostr-tools'; import { EventTemplate, SimplePool } from "nostr-tools";
import { getTagValue } from 'applesauce-core/helpers'; import { getTagValue } from "applesauce-core/helpers";
import { IEventStore, NostrRelay } from '@satellite-earth/core'; import { IEventStore, NostrRelay } from "@satellite-earth/core";
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from "dayjs";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
import InboundNetworkManager from './network/inbound/index.js'; import InboundNetworkManager from "./network/inbound/index.js";
function buildGossipTemplate(self: string, address: string, network: string): EventTemplate { function buildGossipTemplate(self: string, address: string, network: string): EventTemplate {
return { return {
kind: 30166, kind: 30166,
content: '', content: "",
tags: [ tags: [
['d', address], ["d", address],
['n', network], ["n", network],
['p', self], ["p", self],
['T', 'PrivateInbox'], ["T", "PrivateInbox"],
...NostrRelay.SUPPORTED_NIPS.map((nip) => ['N', String(nip)]), ...NostrRelay.SUPPORTED_NIPS.map((nip) => ["N", String(nip)]),
], ],
created_at: dayjs().unix(), created_at: dayjs().unix(),
}; };
} }
export default class Gossip { export default class Gossip {
log = logger.extend('Gossip'); log = logger.extend("Gossip");
network: InboundNetworkManager; network: InboundNetworkManager;
signer: SimpleSigner; signer: SimpleSigner;
pool: SimplePool; pool: SimplePool;
relay: NostrRelay; relay: NostrRelay;
eventStore: IEventStore; eventStore: IEventStore;
running = false; running = false;
// default every 30 minutes // default every 30 minutes
interval = 30 * 60_000; interval = 30 * 60_000;
broadcastRelays: string[] = []; broadcastRelays: string[] = [];
constructor( constructor(
network: InboundNetworkManager, network: InboundNetworkManager,
signer: SimpleSigner, signer: SimpleSigner,
pool: SimplePool, pool: SimplePool,
relay: NostrRelay, relay: NostrRelay,
eventStore: IEventStore, eventStore: IEventStore,
) { ) {
this.network = network; this.network = network;
this.signer = signer; this.signer = signer;
this.pool = pool; this.pool = pool;
this.relay = relay; this.relay = relay;
this.eventStore = eventStore; this.eventStore = eventStore;
} }
async gossip() { async gossip() {
const pubkey = await this.signer.getPublicKey(); const pubkey = await this.signer.getPublicKey();
if (this.broadcastRelays.length === 0) return; if (this.broadcastRelays.length === 0) return;
if (this.network.hyper.available && this.network.hyper.address) { if (this.network.hyper.available && this.network.hyper.address) {
this.log('Publishing hyper gossip'); this.log("Publishing hyper gossip");
await this.pool.publish( await this.pool.publish(
this.broadcastRelays, this.broadcastRelays,
await this.signer.signEvent(buildGossipTemplate(pubkey, this.network.hyper.address, 'hyper')), await this.signer.signEvent(buildGossipTemplate(pubkey, this.network.hyper.address, "hyper")),
); );
} }
if (this.network.tor.available && this.network.tor.address) { if (this.network.tor.available && this.network.tor.address) {
this.log('Publishing tor gossip'); this.log("Publishing tor gossip");
await this.pool.publish( await this.pool.publish(
this.broadcastRelays, this.broadcastRelays,
await this.signer.signEvent(buildGossipTemplate(pubkey, this.network.tor.address, 'tor')), await this.signer.signEvent(buildGossipTemplate(pubkey, this.network.tor.address, "tor")),
); );
} }
if (this.network.i2p.available && this.network.i2p.address) { if (this.network.i2p.available && this.network.i2p.address) {
this.log('Publishing i2p gossip'); this.log("Publishing i2p gossip");
await this.pool.publish( await this.pool.publish(
this.broadcastRelays, this.broadcastRelays,
await this.signer.signEvent(buildGossipTemplate(pubkey, this.network.i2p.address, 'i2p')), await this.signer.signEvent(buildGossipTemplate(pubkey, this.network.i2p.address, "i2p")),
); );
} }
} }
private async update() { private async update() {
if (!this.running) return; if (!this.running) return;
await this.gossip(); await this.gossip();
setTimeout(this.update.bind(this), this.interval); setTimeout(this.update.bind(this), this.interval);
} }
start() { start() {
if (this.running) return; if (this.running) return;
this.running = true; this.running = true;
this.log(`Starting gossip on ${this.broadcastRelays.join(', ')}`); this.log(`Starting gossip on ${this.broadcastRelays.join(", ")}`);
setTimeout(this.update.bind(this), 5000); setTimeout(this.update.bind(this), 5000);
} }
stop() { stop() {
this.log('Stopping gossip'); this.log("Stopping gossip");
this.running = false; this.running = false;
} }
private lookups = new Map<string, Dayjs>(); private lookups = new Map<string, Dayjs>();
async lookup(pubkey: string) { async lookup(pubkey: string) {
const last = this.lookups.get(pubkey); const last = this.lookups.get(pubkey);
const filter = { authors: [pubkey], '#p': [pubkey], kinds: [30166] }; const filter = { authors: [pubkey], "#p": [pubkey], kinds: [30166] };
// no cache or expired // no cache or expired
if (last === undefined || !last.isAfter(dayjs())) { if (last === undefined || !last.isAfter(dayjs())) {
await new Promise<void>((res) => { await new Promise<void>((res) => {
this.lookups.set(pubkey, dayjs().add(1, 'hour')); this.lookups.set(pubkey, dayjs().add(1, "hour"));
const sub = this.pool.subscribeMany(this.broadcastRelays, [filter], { const sub = this.pool.subscribeMany(this.broadcastRelays, [filter], {
onevent: (event) => this.eventStore.addEvent(event), onevent: (event) => this.eventStore.addEvent(event),
oneose: () => { oneose: () => {
sub.close(); sub.close();
res(); res();
}, },
}); });
}); });
} }
const events = this.eventStore.getEventsForFilters([filter]); const events = this.eventStore.getEventsForFilters([filter]);
const addresses: string[] = []; const addresses: string[] = [];
for (const event of events) { for (const event of events) {
const url = getTagValue(event, 'd'); const url = getTagValue(event, "d");
if (url) addresses.push(url); if (url) addresses.push(url);
} }
return addresses; return addresses;
} }
} }

View File

@@ -1,105 +1,105 @@
import { NostrEvent, kinds } from 'nostr-tools'; import { NostrEvent, kinds } from "nostr-tools";
import App from '../../app/index.js'; import App from "../../app/index.js";
export type Node = { p: string; z: number; n: number }; export type Node = { p: string; z: number; n: number };
// TODO: this should be moved to core // TODO: this should be moved to core
export default class Graph { export default class Graph {
contacts: Record<string, { created_at: number; set: Set<string> }> = {}; contacts: Record<string, { created_at: number; set: Set<string> }> = {};
app: App; app: App;
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
init() { init() {
const events = this.app.eventStore.getEventsForFilters([{ kinds: [kinds.Contacts] }]); const events = this.app.eventStore.getEventsForFilters([{ kinds: [kinds.Contacts] }]);
for (let event of events) { for (let event of events) {
this.add(event); this.add(event);
} }
} }
add(event: NostrEvent) { add(event: NostrEvent) {
if (event.kind === kinds.Contacts) { if (event.kind === kinds.Contacts) {
this.addContacts(event); this.addContacts(event);
} }
} }
addContacts(event: NostrEvent) { addContacts(event: NostrEvent) {
const existing = this.contacts[event.pubkey]; const existing = this.contacts[event.pubkey];
// Add or overwrite an existing (older) contacts list // Add or overwrite an existing (older) contacts list
if (!existing || existing.created_at < event.created_at) { if (!existing || existing.created_at < event.created_at) {
const following = new Set(event.tags.filter((tag) => tag[0] === 'p').map((tag) => tag[1])); const following = new Set(event.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]));
this.contacts[event.pubkey] = { this.contacts[event.pubkey] = {
created_at: event.created_at, created_at: event.created_at,
set: following, set: following,
}; };
} }
} }
getNodes(roots: string[] = []): Node[] { getNodes(roots: string[] = []): Node[] {
const u: Record<string, { z: number; n: number }> = {}; const u: Record<string, { z: number; n: number }> = {};
// Init u with root pubkeys // Init u with root pubkeys
for (let p of roots) { for (let p of roots) {
u[p] = { z: 0, n: 1 }; u[p] = { z: 0, n: 1 };
} }
const populate = (pubkeys: string[], z: number) => { const populate = (pubkeys: string[], z: number) => {
for (let p of pubkeys) { for (let p of pubkeys) {
// If pubkey's contacts don't exist, skip it // If pubkey's contacts don't exist, skip it
if (!this.contacts[p]) { if (!this.contacts[p]) {
continue; continue;
} }
// Iterate across pubkey's contacts, if the // Iterate across pubkey's contacts, if the
// contact has not been recorded, create an // contact has not been recorded, create an
// entry at the current degrees of separation, // entry at the current degrees of separation,
// otherwise increment the number of occurances // otherwise increment the number of occurances
this.contacts[p].set.forEach((c) => { this.contacts[p].set.forEach((c) => {
// Don't count self-follow // Don't count self-follow
if (p === c) { if (p === c) {
return; return;
} }
if (!u[c]) { if (!u[c]) {
u[c] = { z, n: 1 }; u[c] = { z, n: 1 };
} else { } else {
if (u[c].z > z) { if (u[c].z > z) {
return; return;
} }
u[c].n++; u[c].n++;
} }
}); });
} }
}; };
// Populate u with all the pubkeys that // Populate u with all the pubkeys that
// are directly followed by root pubkey // are directly followed by root pubkey
populate(roots, 1); populate(roots, 1);
// On the second pass, populate u with // On the second pass, populate u with
// all the pubkeys that are followed // all the pubkeys that are followed
// by any pubkey that root follows // by any pubkey that root follows
populate( populate(
Object.keys(u).filter((p) => { Object.keys(u).filter((p) => {
return u[p].z > 0; return u[p].z > 0;
}), }),
2, 2,
); );
// Return list of pubkeys sorted by degrees // Return list of pubkeys sorted by degrees
// of separation and number of occurances // of separation and number of occurances
return Object.keys(u) return Object.keys(u)
.map((p) => { .map((p) => {
return { ...u[p], p }; return { ...u[p], p };
}) })
.sort((a, b) => { .sort((a, b) => {
return a.z === b.z ? b.n - a.n : a.z - b.z; return a.z === b.z ? b.n - a.n : a.z - b.z;
}); });
} }
} }

View File

@@ -1,65 +1,65 @@
import net from 'net'; import net from "net";
import HyperDHT from 'hyperdht'; import HyperDHT from "hyperdht";
import { pipeline } from 'streamx'; import { pipeline } from "streamx";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
const START_PORT = 25100; const START_PORT = 25100;
export class HyperConnectionManager { export class HyperConnectionManager {
log = logger.extend(`hyper-connection-manager`); log = logger.extend(`hyper-connection-manager`);
sockets = new Map<string, net.Socket>(); sockets = new Map<string, net.Socket>();
servers = new Map<string, net.Server>(); servers = new Map<string, net.Server>();
node: HyperDHT; node: HyperDHT;
lastPort = START_PORT; lastPort = START_PORT;
constructor(privateKey: string) { constructor(privateKey: string) {
this.node = new HyperDHT({ this.node = new HyperDHT({
keyPair: HyperDHT.keyPair(Buffer.from(privateKey, 'hex')), keyPair: HyperDHT.keyPair(Buffer.from(privateKey, "hex")),
}); });
} }
protected bind(pubkey: string) { protected bind(pubkey: string) {
return new Promise<net.Server>((res) => { return new Promise<net.Server>((res) => {
const proxy = net.createServer({ allowHalfOpen: true }, (socket_) => { const proxy = net.createServer({ allowHalfOpen: true }, (socket_) => {
const socket = this.node.connect(Buffer.from(pubkey, 'hex'), { const socket = this.node.connect(Buffer.from(pubkey, "hex"), {
reusableSocket: true, reusableSocket: true,
}); });
// @ts-expect-error // @ts-expect-error
socket.setKeepAlive(5000); socket.setKeepAlive(5000);
socket.on('open', () => { socket.on("open", () => {
// connect the sockets // connect the sockets
pipeline(socket_, socket, socket_); pipeline(socket_, socket, socket_);
}); });
socket.on('error', (error) => { socket.on("error", (error) => {
this.log('Failed to connect to', pubkey); this.log("Failed to connect to", pubkey);
this.log(error); this.log(error);
}); });
}); });
this.servers.set(pubkey, proxy); this.servers.set(pubkey, proxy);
const port = this.lastPort++; const port = this.lastPort++;
proxy.listen(port, '127.0.0.1', () => { proxy.listen(port, "127.0.0.1", () => {
this.log('Bound hyper address', pubkey, 'to port:', port); this.log("Bound hyper address", pubkey, "to port:", port);
res(proxy); res(proxy);
}); });
}); });
} }
async getLocalAddress(pubkey: string) { async getLocalAddress(pubkey: string) {
let server = this.servers.get(pubkey); let server = this.servers.get(pubkey);
if (!server) server = await this.bind(pubkey); if (!server) server = await this.bind(pubkey);
return server!.address() as net.AddressInfo; return server!.address() as net.AddressInfo;
} }
stop() { stop() {
for (const [pubkey, server] of this.servers) { for (const [pubkey, server] of this.servers) {
server.close(); server.close();
} }
this.servers.clear(); this.servers.clear();
} }
} }

View File

@@ -1,79 +1,79 @@
import { Database } from 'better-sqlite3'; import { Database } from "better-sqlite3";
import { Filter, NostrEvent } from 'nostr-tools'; import { Filter, NostrEvent } from "nostr-tools";
import { IEventStore, SQLiteEventStore } from '@satellite-earth/core'; import { IEventStore, SQLiteEventStore } from "@satellite-earth/core";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
import { MigrationSet } from '@satellite-earth/core/sqlite'; import { MigrationSet } from "@satellite-earth/core/sqlite";
export function mapParams(params: any[]) { export function mapParams(params: any[]) {
return `(${params.map(() => `?`).join(', ')})`; return `(${params.map(() => `?`).join(", ")})`;
} }
const migrations = new MigrationSet('labeled-event-store'); const migrations = new MigrationSet("labeled-event-store");
// Version 1 // Version 1
migrations.addScript(1, async (db, log) => { migrations.addScript(1, async (db, log) => {
db.prepare( db.prepare(
` `
CREATE TABLE IF NOT EXISTS event_labels ( CREATE TABLE IF NOT EXISTS event_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
event TEXT(64) REFERENCES events(id), event TEXT(64) REFERENCES events(id),
label TEXT label TEXT
) )
`, `,
).run(); ).run();
db.prepare('CREATE INDEX IF NOT EXISTS event_labels_label ON event_labels(label)').run(); db.prepare("CREATE INDEX IF NOT EXISTS event_labels_label ON event_labels(label)").run();
db.prepare('CREATE INDEX IF NOT EXISTS event_labels_event ON event_labels(event)').run(); db.prepare("CREATE INDEX IF NOT EXISTS event_labels_event ON event_labels(event)").run();
}); });
/** An event store that is can only see a subset of events int the database */ /** An event store that is can only see a subset of events int the database */
export class LabeledEventStore extends SQLiteEventStore implements IEventStore { export class LabeledEventStore extends SQLiteEventStore implements IEventStore {
label: string; label: string;
readAll = false; readAll = false;
constructor(db: Database, label: string) { constructor(db: Database, label: string) {
super(db); super(db);
this.label = label; this.label = label;
this.log = logger.extend(`event-store:` + label); this.log = logger.extend(`event-store:` + label);
} }
async setup() { async setup() {
await super.setup(); await super.setup();
await migrations.run(this.db); await migrations.run(this.db);
} }
override buildConditionsForFilters(filter: Filter) { override buildConditionsForFilters(filter: Filter) {
const parts = super.buildConditionsForFilters(filter); const parts = super.buildConditionsForFilters(filter);
if (!this.readAll) { if (!this.readAll) {
parts.joins.push('INNER JOIN event_labels ON events.id = event_labels.event'); parts.joins.push("INNER JOIN event_labels ON events.id = event_labels.event");
parts.conditions.push('event_labels.label = ?'); parts.conditions.push("event_labels.label = ?");
parts.parameters.push(this.label); parts.parameters.push(this.label);
return parts; return parts;
} }
return parts; return parts;
} }
addEvent(event: NostrEvent) { addEvent(event: NostrEvent) {
const inserted = super.addEvent(event); const inserted = super.addEvent(event);
const hasLabel = !!this.db const hasLabel = !!this.db
.prepare('SELECT * FROM event_labels WHERE event = ? AND label = ?') .prepare("SELECT * FROM event_labels WHERE event = ? AND label = ?")
.get(event.id, this.label); .get(event.id, this.label);
if (!hasLabel) this.db.prepare(`INSERT INTO event_labels (event, label) VALUES (?, ?)`).run(event.id, this.label); if (!hasLabel) this.db.prepare(`INSERT INTO event_labels (event, label) VALUES (?, ?)`).run(event.id, this.label);
return inserted; return inserted;
} }
removeEvents(ids: string[]) { removeEvents(ids: string[]) {
this.db.prepare(`DELETE FROM event_labels WHERE event IN ${mapParams(ids)}`).run(...ids); this.db.prepare(`DELETE FROM event_labels WHERE event IN ${mapParams(ids)}`).run(...ids);
return super.removeEvents(ids); return super.removeEvents(ids);
} }
removeEvent(id: string) { removeEvent(id: string) {
this.db.prepare(`DELETE FROM event_labels WHERE event = ?`).run(id); this.db.prepare(`DELETE FROM event_labels WHERE event = ?`).run(id);
return super.removeEvent(id); return super.removeEvent(id);
} }
} }

View File

@@ -1,32 +1,32 @@
import { type Database as SQLDatabase } from 'better-sqlite3'; import { type Database as SQLDatabase } from "better-sqlite3";
import { MigrationSet } from '@satellite-earth/core/sqlite'; import { MigrationSet } from "@satellite-earth/core/sqlite";
import EventEmitter from 'events'; import EventEmitter from "events";
import { nanoid } from 'nanoid'; import { nanoid } from "nanoid";
import { Debugger } from 'debug'; import { Debugger } from "debug";
import { logger } from '../../logger.js'; import { logger } from "../../logger.js";
type EventMap = { type EventMap = {
log: [LogEntry]; log: [LogEntry];
clear: [string | undefined]; clear: [string | undefined];
}; };
export type LogEntry = { export type LogEntry = {
id: string; id: string;
service: string; service: string;
timestamp: number; timestamp: number;
message: string; message: string;
}; };
export type DatabaseLogEntry = LogEntry & { export type DatabaseLogEntry = LogEntry & {
id: number | bigint; id: number | bigint;
}; };
const migrations = new MigrationSet('log-store'); const migrations = new MigrationSet("log-store");
// version 1 // version 1
migrations.addScript(1, async (db, log) => { migrations.addScript(1, async (db, log) => {
db.prepare( db.prepare(
` `
CREATE TABLE IF NOT EXISTS "logs" ( CREATE TABLE IF NOT EXISTS "logs" (
"id" TEXT NOT NULL UNIQUE, "id" TEXT NOT NULL UNIQUE,
"timestamp" INTEGER NOT NULL, "timestamp" INTEGER NOT NULL,
@@ -35,129 +35,129 @@ migrations.addScript(1, async (db, log) => {
PRIMARY KEY("id") PRIMARY KEY("id")
); );
`, `,
).run(); ).run();
log('Created logs table'); log("Created logs table");
db.prepare('CREATE INDEX IF NOT EXISTS logs_service ON logs(service)'); db.prepare("CREATE INDEX IF NOT EXISTS logs_service ON logs(service)");
log('Created logs service index'); log("Created logs service index");
}); });
export default class LogStore extends EventEmitter<EventMap> { export default class LogStore extends EventEmitter<EventMap> {
database: SQLDatabase; database: SQLDatabase;
debug: Debugger; debug: Debugger;
constructor(database: SQLDatabase) { constructor(database: SQLDatabase) {
super(); super();
this.database = database; this.database = database;
this.debug = logger; this.debug = logger;
} }
async setup() { async setup() {
return await migrations.run(this.database); return await migrations.run(this.database);
} }
addEntry(service: string, timestamp: Date | number, message: string) { addEntry(service: string, timestamp: Date | number, message: string) {
const unix = timestamp instanceof Date ? Math.round(timestamp.valueOf() / 1000) : timestamp; const unix = timestamp instanceof Date ? Math.round(timestamp.valueOf() / 1000) : timestamp;
const entry = { const entry = {
id: nanoid(), id: nanoid(),
service, service,
timestamp: unix, timestamp: unix,
message, message,
}; };
this.queue.push(entry); this.queue.push(entry);
this.emit('log', entry); this.emit("log", entry);
if (!this.running) this.write(); if (!this.running) this.write();
} }
running = false; running = false;
queue: LogEntry[] = []; queue: LogEntry[] = [];
private write() { private write() {
if (this.running) return; if (this.running) return;
this.running = true; this.running = true;
const BATCH_SIZE = 5000; const BATCH_SIZE = 5000;
const inserted: (number | bigint)[] = []; const inserted: (number | bigint)[] = [];
const failed: LogEntry[] = []; const failed: LogEntry[] = [];
this.database.transaction(() => { this.database.transaction(() => {
let i = 0; let i = 0;
while (this.queue.length) { while (this.queue.length) {
const entry = this.queue.shift()!; const entry = this.queue.shift()!;
try { try {
const { lastInsertRowid } = this.database const { lastInsertRowid } = this.database
.prepare< .prepare<
[string, string, number, string] [string, string, number, string]
>(`INSERT INTO "logs" (id, service, timestamp, message) VALUES (?, ?, ?, ?)`) >(`INSERT INTO "logs" (id, service, timestamp, message) VALUES (?, ?, ?, ?)`)
.run(entry.id, entry.service, entry.timestamp, entry.message); .run(entry.id, entry.service, entry.timestamp, entry.message);
inserted.push(lastInsertRowid); inserted.push(lastInsertRowid);
} catch (error) { } catch (error) {
failed.push(entry); failed.push(entry);
} }
if (++i >= BATCH_SIZE) break; if (++i >= BATCH_SIZE) break;
} }
})(); })();
for (const entry of failed) { for (const entry of failed) {
// Don't know what to do here... // Don't know what to do here...
} }
if (this.queue.length > 0) setTimeout(this.write.bind(this), 1000); if (this.queue.length > 0) setTimeout(this.write.bind(this), 1000);
else this.running = false; else this.running = false;
} }
getLogs(filter?: { service?: string; since?: number; until?: number; limit?: number }) { getLogs(filter?: { service?: string; since?: number; until?: number; limit?: number }) {
const conditions: string[] = []; const conditions: string[] = [];
const parameters: (string | number)[] = []; const parameters: (string | number)[] = [];
let sql = `SELECT * FROM logs`; let sql = `SELECT * FROM logs`;
if (filter?.service) { if (filter?.service) {
conditions.push(`service LIKE CONCAT(?,'%')`); conditions.push(`service LIKE CONCAT(?,'%')`);
parameters.push(filter?.service); parameters.push(filter?.service);
} }
if (filter?.since) { if (filter?.since) {
conditions.push('timestamp>=?'); conditions.push("timestamp>=?");
parameters.push(filter?.since); parameters.push(filter?.since);
} }
if (filter?.until) { if (filter?.until) {
conditions.push('timestamp<=?'); conditions.push("timestamp<=?");
parameters.push(filter?.until); parameters.push(filter?.until);
} }
if (conditions.length > 0) sql += ` WHERE ${conditions.join(' AND ')}`; if (conditions.length > 0) sql += ` WHERE ${conditions.join(" AND ")}`;
if (filter?.limit) { if (filter?.limit) {
sql += ' LIMIT ?'; sql += " LIMIT ?";
parameters.push(filter.limit); parameters.push(filter.limit);
} }
return this.database.prepare<any[], DatabaseLogEntry>(sql).all(...parameters); return this.database.prepare<any[], DatabaseLogEntry>(sql).all(...parameters);
} }
clearLogs(filter?: { service?: string; since?: number; until?: number }) { clearLogs(filter?: { service?: string; since?: number; until?: number }) {
const conditions: string[] = []; const conditions: string[] = [];
const parameters: (string | number)[] = []; const parameters: (string | number)[] = [];
let sql = `DELETE FROM logs`; let sql = `DELETE FROM logs`;
if (filter?.service) { if (filter?.service) {
conditions.push('service=?'); conditions.push("service=?");
parameters.push(filter?.service); parameters.push(filter?.service);
} }
if (filter?.since) { if (filter?.since) {
conditions.push('timestamp>=?'); conditions.push("timestamp>=?");
parameters.push(filter?.since); parameters.push(filter?.since);
} }
if (filter?.until) { if (filter?.until) {
conditions.push('timestamp<=?'); conditions.push("timestamp<=?");
parameters.push(filter?.until); parameters.push(filter?.until);
} }
if (conditions.length > 0) sql += ` WHERE ${conditions.join(' AND ')}`; if (conditions.length > 0) sql += ` WHERE ${conditions.join(" AND ")}`;
this.database.prepare<any[], DatabaseLogEntry>(sql).run(...parameters); this.database.prepare<any[], DatabaseLogEntry>(sql).run(...parameters);
this.emit('clear', filter?.service); this.emit("clear", filter?.service);
} }
} }

View File

@@ -1,71 +1,71 @@
import HolesailServer from 'holesail-server'; import HolesailServer from "holesail-server";
import { encodeAddress } from 'hyper-address'; import { encodeAddress } from "hyper-address";
import { hexToBytes } from '@noble/hashes/utils'; import { hexToBytes } from "@noble/hashes/utils";
import { AddressInfo } from 'net'; import { AddressInfo } from "net";
import App from '../../../app/index.js'; import App from "../../../app/index.js";
import { InboundInterface } from '../interfaces.js'; import { InboundInterface } from "../interfaces.js";
import { logger } from '../../../logger.js'; import { logger } from "../../../logger.js";
/** manages a holesail-server instance that points to the app.server http server */ /** manages a holesail-server instance that points to the app.server http server */
export default class HyperInbound implements InboundInterface { export default class HyperInbound implements InboundInterface {
app: App; app: App;
hyper?: HolesailServer; hyper?: HolesailServer;
log = logger.extend('Network:Inbound:Hyper'); log = logger.extend("Network:Inbound:Hyper");
get available() { get available() {
return true; return true;
} }
running = false; running = false;
error?: Error; error?: Error;
address?: string; address?: string;
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
async start(address: AddressInfo) { async start(address: AddressInfo) {
try { try {
this.running = true; this.running = true;
this.error = undefined; this.error = undefined;
this.log(`Importing and starting hyperdht node`); this.log(`Importing and starting hyperdht node`);
const { default: HolesailServer } = await import('holesail-server'); const { default: HolesailServer } = await import("holesail-server");
const { getOrCreateNode } = await import('../../../sidecars/hyperdht.js'); const { getOrCreateNode } = await import("../../../sidecars/hyperdht.js");
const hyper = (this.hyper = new HolesailServer()); const hyper = (this.hyper = new HolesailServer());
hyper.dht = getOrCreateNode(); hyper.dht = getOrCreateNode();
return new Promise<void>((res) => { return new Promise<void>((res) => {
hyper.serve( hyper.serve(
{ {
port: address.port, port: address.port,
address: address.address, address: address.address,
secure: false, secure: false,
buffSeed: this.app.secrets.get('hyperKey'), buffSeed: this.app.secrets.get("hyperKey"),
}, },
() => { () => {
const address = 'http://' + encodeAddress(hexToBytes(hyper.getPublicKey())); const address = "http://" + encodeAddress(hexToBytes(hyper.getPublicKey()));
this.address = address; this.address = address;
this.log(`Listening on ${address}`); this.log(`Listening on ${address}`);
res(); res();
}, },
); );
}); });
} catch (error) { } catch (error) {
this.running = false; this.running = false;
if (error instanceof Error) this.error = error; if (error instanceof Error) this.error = error;
} }
} }
async stop() { async stop() {
this.log('Shutting down'); this.log("Shutting down");
// disabled because holesail-server destroys the hyperdht node // disabled because holesail-server destroys the hyperdht node
// this.hyper?.destroy(); // this.hyper?.destroy();
this.running = false; this.running = false;
this.address = undefined; this.address = undefined;
this.error = undefined; this.error = undefined;
} }
} }

View File

@@ -1,76 +1,76 @@
import type { AddressInfo } from 'net'; import type { AddressInfo } from "net";
import type { I2pSamStream } from '@diva.exchange/i2p-sam'; import type { I2pSamStream } from "@diva.exchange/i2p-sam";
import App from '../../../app/index.js'; import App from "../../../app/index.js";
import { I2P_SAM_ADDRESS } from '../../../env.js'; import { I2P_SAM_ADDRESS } from "../../../env.js";
import { logger } from '../../../logger.js'; import { logger } from "../../../logger.js";
import { InboundInterface } from '../interfaces.js'; import { InboundInterface } from "../interfaces.js";
export default class I2PInbound implements InboundInterface { export default class I2PInbound implements InboundInterface {
app: App; app: App;
log = logger.extend('Network:Inbound:I2P'); log = logger.extend("Network:Inbound:I2P");
available = !!I2P_SAM_ADDRESS; available = !!I2P_SAM_ADDRESS;
running = false; running = false;
address?: string; address?: string;
error?: Error; error?: Error;
private forward?: I2pSamStream; private forward?: I2pSamStream;
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
async start(address: AddressInfo) { async start(address: AddressInfo) {
try { try {
if (this.running) return; if (this.running) return;
this.running = true; this.running = true;
const [host, port] = I2P_SAM_ADDRESS?.split(':') ?? []; const [host, port] = I2P_SAM_ADDRESS?.split(":") ?? [];
if (!host || !port) throw new Error(`Malformed proxy address ${I2P_SAM_ADDRESS}`); if (!host || !port) throw new Error(`Malformed proxy address ${I2P_SAM_ADDRESS}`);
this.log('Importing I2P SAM package'); this.log("Importing I2P SAM package");
const { createForward } = await import('@diva.exchange/i2p-sam'); const { createForward } = await import("@diva.exchange/i2p-sam");
// try to get the last key pair that was used // try to get the last key pair that was used
const privateKey = this.app.secrets.get('i2pPrivateKey'); const privateKey = this.app.secrets.get("i2pPrivateKey");
const publicKey = this.app.secrets.get('i2pPublicKey'); const publicKey = this.app.secrets.get("i2pPublicKey");
this.log('Creating forwarding stream'); this.log("Creating forwarding stream");
this.forward = await createForward({ this.forward = await createForward({
sam: { sam: {
host: host, host: host,
portTCP: parseInt(port), portTCP: parseInt(port),
privateKey, privateKey,
publicKey, publicKey,
}, },
forward: { forward: {
host: address.address, host: address.address,
port: address.port, port: address.port,
}, },
}); });
this.address = 'http://' + this.forward.getB32Address(); this.address = "http://" + this.forward.getB32Address();
this.log(`Listening on ${this.address}`); this.log(`Listening on ${this.address}`);
// save the key pair for later // save the key pair for later
this.app.secrets.set('i2pPrivateKey', this.forward.getPrivateKey()); this.app.secrets.set("i2pPrivateKey", this.forward.getPrivateKey());
this.app.secrets.set('i2pPublicKey', this.forward.getPublicKey()); this.app.secrets.set("i2pPublicKey", this.forward.getPublicKey());
} catch (error) { } catch (error) {
this.running = false; this.running = false;
if (error instanceof Error) this.error = error; if (error instanceof Error) this.error = error;
} }
} }
async stop() { async stop() {
if (!this.running) return; if (!this.running) return;
this.running = false; this.running = false;
if (this.forward) { if (this.forward) {
this.log('Closing forwarding stream'); this.log("Closing forwarding stream");
this.forward.close(); this.forward.close();
this.forward = undefined; this.forward = undefined;
} }
} }
} }

View File

@@ -1,79 +1,79 @@
import App from '../../../app/index.js'; import App from "../../../app/index.js";
import HyperInbound from './hyper.js'; import HyperInbound from "./hyper.js";
import { logger } from '../../../logger.js'; import { logger } from "../../../logger.js";
import { getIPAddresses } from '../../../helpers/ip.js'; import { getIPAddresses } from "../../../helpers/ip.js";
import TorInbound from './tor.js'; import TorInbound from "./tor.js";
import ConfigManager from '../../config-manager.js'; import ConfigManager from "../../config-manager.js";
import I2PInbound from './i2p.js'; import I2PInbound from "./i2p.js";
/** manages all inbound servers on other networks: hyper, tor, i2p, etc... */ /** manages all inbound servers on other networks: hyper, tor, i2p, etc... */
export default class InboundNetworkManager { export default class InboundNetworkManager {
app: App; app: App;
log = logger.extend('Network:Inbound'); log = logger.extend("Network:Inbound");
hyper: HyperInbound; hyper: HyperInbound;
tor: TorInbound; tor: TorInbound;
i2p: I2PInbound; i2p: I2PInbound;
running = false; running = false;
get addresses() { get addresses() {
const ip = getIPAddresses(); const ip = getIPAddresses();
const hyper = this.hyper.address; const hyper = this.hyper.address;
const tor = this.tor.address; const tor = this.tor.address;
return [...(ip ?? []), ...(tor ?? []), ...(hyper ?? [])]; return [...(ip ?? []), ...(tor ?? []), ...(hyper ?? [])];
} }
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
this.hyper = new HyperInbound(app); this.hyper = new HyperInbound(app);
this.tor = new TorInbound(app); this.tor = new TorInbound(app);
this.i2p = new I2PInbound(app); this.i2p = new I2PInbound(app);
this.listenToAppConfig(app.config); this.listenToAppConfig(app.config);
} }
private getAddress() { private getAddress() {
const address = this.app.server.address(); const address = this.app.server.address();
if (typeof address === 'string' || address === null) if (typeof address === "string" || address === null)
throw new Error('External servers started when server does not have an address'); throw new Error("External servers started when server does not have an address");
return address; return address;
} }
private update(config = this.app.config.data) { private update(config = this.app.config.data) {
if (!this.running) return; if (!this.running) return;
const address = this.getAddress(); const address = this.getAddress();
if (this.hyper.available && config.hyperEnabled !== this.hyper.running) { if (this.hyper.available && config.hyperEnabled !== this.hyper.running) {
if (config.hyperEnabled) this.hyper.start(address); if (config.hyperEnabled) this.hyper.start(address);
else this.hyper.stop(); else this.hyper.stop();
} }
if (this.tor.available) { if (this.tor.available) {
if (!this.tor.running) this.tor.start(address); if (!this.tor.running) this.tor.start(address);
} }
if (this.i2p.available) { if (this.i2p.available) {
if (!this.i2p.running) this.i2p.start(address); if (!this.i2p.running) this.i2p.start(address);
} }
} }
/** A helper method to make the manager run off of the app config */ /** A helper method to make the manager run off of the app config */
listenToAppConfig(config: ConfigManager) { listenToAppConfig(config: ConfigManager) {
config.on('updated', this.update.bind(this)); config.on("updated", this.update.bind(this));
} }
start() { start() {
this.running = true; this.running = true;
this.update(); this.update();
} }
async stop() { async stop() {
this.running = false; this.running = false;
await this.hyper.stop(); await this.hyper.stop();
await this.tor.stop(); await this.tor.stop();
await this.i2p.stop(); await this.i2p.stop();
} }
} }

View File

@@ -1,29 +1,29 @@
import { AddressInfo } from 'net'; import { AddressInfo } from "net";
import App from '../../../app/index.js'; import App from "../../../app/index.js";
import { TOR_ADDRESS } from '../../../env.js'; import { TOR_ADDRESS } from "../../../env.js";
import { logger } from '../../../logger.js'; import { logger } from "../../../logger.js";
import { InboundInterface } from '../interfaces.js'; import { InboundInterface } from "../interfaces.js";
export default class TorInbound implements InboundInterface { export default class TorInbound implements InboundInterface {
app: App; app: App;
log = logger.extend('Network:Inbound:Tor'); log = logger.extend("Network:Inbound:Tor");
readonly available = !!TOR_ADDRESS; readonly available = !!TOR_ADDRESS;
readonly running = !!TOR_ADDRESS; readonly running = !!TOR_ADDRESS;
readonly address = TOR_ADDRESS; readonly address = TOR_ADDRESS;
error?: Error; error?: Error;
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
async start(address: AddressInfo) { async start(address: AddressInfo) {
// not implemented yet // not implemented yet
if (TOR_ADDRESS) this.log(`Listening on ${TOR_ADDRESS}`); if (TOR_ADDRESS) this.log(`Listening on ${TOR_ADDRESS}`);
} }
async stop() { async stop() {
// not implemented yet // not implemented yet
} }
} }

View File

@@ -1,22 +1,22 @@
import { AddressInfo } from 'net'; import { AddressInfo } from "net";
export interface InboundInterface { export interface InboundInterface {
available: boolean; available: boolean;
running: boolean; running: boolean;
error?: Error; error?: Error;
address?: string; address?: string;
start(address: AddressInfo): Promise<void>; start(address: AddressInfo): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
} }
export interface OutboundInterface { export interface OutboundInterface {
available: boolean; available: boolean;
running: boolean; running: boolean;
error?: Error; error?: Error;
type: 'SOCKS5' | 'HTTP'; type: "SOCKS5" | "HTTP";
address?: string; address?: string;
start(): Promise<void>; start(): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
} }

View File

@@ -1,57 +1,57 @@
import type { createProxy } from 'hyper-socks5-proxy'; import type { createProxy } from "hyper-socks5-proxy";
import getPort from 'get-port'; import getPort from "get-port";
import EventEmitter from 'events'; import EventEmitter from "events";
import { logger } from '../../../logger.js'; import { logger } from "../../../logger.js";
import { OutboundInterface } from '../interfaces.js'; import { OutboundInterface } from "../interfaces.js";
type EventMap = { type EventMap = {
started: []; started: [];
stopped: []; stopped: [];
}; };
export default class HyperOutbound extends EventEmitter<EventMap> implements OutboundInterface { export default class HyperOutbound extends EventEmitter<EventMap> implements OutboundInterface {
log = logger.extend('Network:Outbound:Hyper'); log = logger.extend("Network:Outbound:Hyper");
private port?: number; private port?: number;
private proxy?: ReturnType<typeof createProxy>; private proxy?: ReturnType<typeof createProxy>;
running = false; running = false;
error?: Error; error?: Error;
readonly type = 'SOCKS5'; readonly type = "SOCKS5";
address?: string; address?: string;
get available() { get available() {
return true; return true;
} }
async start() { async start() {
if (this.running) return; if (this.running) return;
this.running = true; this.running = true;
try { try {
const { createProxy } = await import('hyper-socks5-proxy'); const { createProxy } = await import("hyper-socks5-proxy");
const { getOrCreateNode } = await import('../../../sidecars/hyperdht.js'); const { getOrCreateNode } = await import("../../../sidecars/hyperdht.js");
this.port = await getPort({ port: 1080 }); this.port = await getPort({ port: 1080 });
this.proxy = createProxy({ node: await getOrCreateNode() }); this.proxy = createProxy({ node: await getOrCreateNode() });
this.log('Starting SOCKS5 proxy'); this.log("Starting SOCKS5 proxy");
this.address = `127.0.0.1:${this.port}`; this.address = `127.0.0.1:${this.port}`;
this.proxy.listen(this.port, '127.0.0.1'); this.proxy.listen(this.port, "127.0.0.1");
this.log(`Proxy listening on ${this.address}`); this.log(`Proxy listening on ${this.address}`);
this.emit('started'); this.emit("started");
} catch (error) { } catch (error) {
this.running = false; this.running = false;
if (error instanceof Error) this.error = error; if (error instanceof Error) this.error = error;
} }
} }
async stop() { async stop() {
if (!this.running) return; if (!this.running) return;
this.running = false; this.running = false;
this.log('Stopping'); this.log("Stopping");
await new Promise<void>((res) => this.proxy?.close(() => res())); await new Promise<void>((res) => this.proxy?.close(() => res()));
this.proxy = undefined; this.proxy = undefined;
this.emit('stopped'); this.emit("stopped");
} }
} }

View File

@@ -1,31 +1,31 @@
import { logger } from '../../../logger.js'; import { logger } from "../../../logger.js";
import { OutboundInterface } from '../interfaces.js'; import { OutboundInterface } from "../interfaces.js";
import { I2P_PROXY, I2P_PROXY_TYPE } from '../../../env.js'; import { I2P_PROXY, I2P_PROXY_TYPE } from "../../../env.js";
import { testTCPConnection } from '../../../helpers/network.js'; import { testTCPConnection } from "../../../helpers/network.js";
export default class I2POutbound implements OutboundInterface { export default class I2POutbound implements OutboundInterface {
log = logger.extend('Network:Outbound:I2P'); log = logger.extend("Network:Outbound:I2P");
running = false; running = false;
error?: Error; error?: Error;
readonly type = I2P_PROXY_TYPE; readonly type = I2P_PROXY_TYPE;
readonly address = I2P_PROXY; readonly address = I2P_PROXY;
readonly available = !!I2P_PROXY; readonly available = !!I2P_PROXY;
async start() { async start() {
try { try {
if (this.running) return; if (this.running) return;
this.running = true; this.running = true;
this.log(`Connecting to ${I2P_PROXY}`); this.log(`Connecting to ${I2P_PROXY}`);
const [host, port] = this.address?.split(':') ?? []; const [host, port] = this.address?.split(":") ?? [];
if (!host || !port) throw new Error('Malformed proxy address'); if (!host || !port) throw new Error("Malformed proxy address");
await testTCPConnection(host, parseInt(port), 3000); await testTCPConnection(host, parseInt(port), 3000);
} catch (error) { } catch (error) {
this.running = false; this.running = false;
if (error instanceof Error) this.error = error; if (error instanceof Error) this.error = error;
} }
} }
async stop() {} async stop() {}
} }

View File

@@ -1,132 +1,132 @@
import { PacProxyAgent } from 'pac-proxy-agent'; import { PacProxyAgent } from "pac-proxy-agent";
import _throttle from 'lodash.throttle'; import _throttle from "lodash.throttle";
import { logger } from '../../../logger.js'; import { logger } from "../../../logger.js";
import ConfigManager from '../../config-manager.js'; import ConfigManager from "../../config-manager.js";
import HyperOutbound from './hyper.js'; import HyperOutbound from "./hyper.js";
import TorOutbound from './tor.js'; import TorOutbound from "./tor.js";
import I2POutbound from './i2p.js'; import I2POutbound from "./i2p.js";
export class OutboundNetworkManager { export class OutboundNetworkManager {
log = logger.extend('Network:Outbound'); log = logger.extend("Network:Outbound");
hyper: HyperOutbound; hyper: HyperOutbound;
tor: TorOutbound; tor: TorOutbound;
i2p: I2POutbound; i2p: I2POutbound;
running = false; running = false;
agent: PacProxyAgent<string>; agent: PacProxyAgent<string>;
enableHyperConnections = false; enableHyperConnections = false;
enableTorConnections = false; enableTorConnections = false;
enableI2PConnections = false; enableI2PConnections = false;
routeAllTrafficThroughTor = false; routeAllTrafficThroughTor = false;
constructor() { constructor() {
this.hyper = new HyperOutbound(); this.hyper = new HyperOutbound();
this.tor = new TorOutbound(); this.tor = new TorOutbound();
this.i2p = new I2POutbound(); this.i2p = new I2POutbound();
this.agent = new PacProxyAgent(this.buildPacURI(), { fallbackToDirect: true }); this.agent = new PacProxyAgent(this.buildPacURI(), { fallbackToDirect: true });
} }
private buildPacURI() { private buildPacURI() {
const statements: string[] = []; const statements: string[] = [];
if (this.i2p.available && this.enableI2PConnections) { if (this.i2p.available && this.enableI2PConnections) {
statements.push( statements.push(
` `
if (shExpMatch(host, "*.i2p")) if (shExpMatch(host, "*.i2p"))
{ {
return "${this.i2p.type} ${this.i2p.address}"; return "${this.i2p.type} ${this.i2p.address}";
} }
`.trim(), `.trim(),
); );
} }
if (this.tor.available && this.enableTorConnections) { if (this.tor.available && this.enableTorConnections) {
statements.push( statements.push(
` `
if (shExpMatch(host, "*.onion")) if (shExpMatch(host, "*.onion"))
{ {
return "${this.tor.type} ${this.tor.address}"; return "${this.tor.type} ${this.tor.address}";
} }
`.trim(), `.trim(),
); );
} }
if (this.hyper.available && this.enableHyperConnections) { if (this.hyper.available && this.enableHyperConnections) {
statements.push( statements.push(
` `
if (shExpMatch(host, "*.hyper")) if (shExpMatch(host, "*.hyper"))
{ {
return "${this.hyper.type} ${this.hyper.address}"; return "${this.hyper.type} ${this.hyper.address}";
} }
`.trim(), `.trim(),
); );
} }
if (this.routeAllTrafficThroughTor && this.tor.available) { if (this.routeAllTrafficThroughTor && this.tor.available) {
// if tor is available, route all traffic through it // if tor is available, route all traffic through it
statements.push(`${this.tor.type} ${this.tor.address}`); statements.push(`${this.tor.type} ${this.tor.address}`);
this.log('Routing all traffic through tor proxy'); this.log("Routing all traffic through tor proxy");
} else { } else {
statements.push('return "DIRECT";'); statements.push('return "DIRECT";');
} }
const PACFile = ` const PACFile = `
// SPDX-License-Identifier: CC0-1.0 // SPDX-License-Identifier: CC0-1.0
function FindProxyForURL(url, host) function FindProxyForURL(url, host)
{ {
${statements.join('\n')} ${statements.join("\n")}
} }
`.trim(); `.trim();
return 'pac+data:application/x-ns-proxy-autoconfig;base64,' + btoa(PACFile); return "pac+data:application/x-ns-proxy-autoconfig;base64," + btoa(PACFile);
} }
updateAgent(uri = this.buildPacURI()) { updateAgent(uri = this.buildPacURI()) {
this.log('Updating PAC proxy agent'); this.log("Updating PAC proxy agent");
// copied from https://github.com/TooTallNate/proxy-agents/blob/main/packages/pac-proxy-agent/src/index.ts#L79C22-L79C51 // copied from https://github.com/TooTallNate/proxy-agents/blob/main/packages/pac-proxy-agent/src/index.ts#L79C22-L79C51
this.agent.uri = new URL(uri.replace(/^pac\+/i, '')); this.agent.uri = new URL(uri.replace(/^pac\+/i, ""));
// forces the agent to refetch the resolver and pac file // forces the agent to refetch the resolver and pac file
this.agent.resolverPromise = undefined; this.agent.resolverPromise = undefined;
} }
updateAgentThrottle: () => void = _throttle(this.updateAgent.bind(this), 100); updateAgentThrottle: () => void = _throttle(this.updateAgent.bind(this), 100);
/** A helper method to make the manager run off of the app config */ /** A helper method to make the manager run off of the app config */
listenToAppConfig(config: ConfigManager) { listenToAppConfig(config: ConfigManager) {
config.on('updated', (c) => { config.on("updated", (c) => {
this.enableHyperConnections = c.hyperEnabled && c.enableHyperConnections; this.enableHyperConnections = c.hyperEnabled && c.enableHyperConnections;
this.enableTorConnections = c.enableTorConnections; this.enableTorConnections = c.enableTorConnections;
this.enableI2PConnections = c.enableI2PConnections; this.enableI2PConnections = c.enableI2PConnections;
this.routeAllTrafficThroughTor = c.routeAllTrafficThroughTor; this.routeAllTrafficThroughTor = c.routeAllTrafficThroughTor;
if (this.hyper.available && this.enableHyperConnections !== this.hyper.running) { if (this.hyper.available && this.enableHyperConnections !== this.hyper.running) {
if (this.enableHyperConnections) this.hyper.start(); if (this.enableHyperConnections) this.hyper.start();
else this.hyper.stop(); else this.hyper.stop();
} }
if (this.tor.available && this.enableTorConnections !== this.tor.running) { if (this.tor.available && this.enableTorConnections !== this.tor.running) {
if (this.enableTorConnections) this.tor.start(); if (this.enableTorConnections) this.tor.start();
else this.tor.stop(); else this.tor.stop();
} }
if (this.i2p.available && this.enableI2PConnections !== this.i2p.running) { if (this.i2p.available && this.enableI2PConnections !== this.i2p.running) {
if (this.enableI2PConnections) this.i2p.start(); if (this.enableI2PConnections) this.i2p.start();
else this.i2p.stop(); else this.i2p.stop();
} }
this.updateAgentThrottle(); this.updateAgentThrottle();
}); });
} }
async stop() { async stop() {
await this.hyper.stop(); await this.hyper.stop();
await this.tor.stop(); await this.tor.stop();
} }
} }
const outboundNetwork = new OutboundNetworkManager(); const outboundNetwork = new OutboundNetworkManager();

View File

@@ -1,31 +1,31 @@
import { logger } from '../../../logger.js'; import { logger } from "../../../logger.js";
import { OutboundInterface } from '../interfaces.js'; import { OutboundInterface } from "../interfaces.js";
import { TOR_PROXY, TOR_PROXY_TYPE } from '../../../env.js'; import { TOR_PROXY, TOR_PROXY_TYPE } from "../../../env.js";
import { testTCPConnection } from '../../../helpers/network.js'; import { testTCPConnection } from "../../../helpers/network.js";
export default class TorOutbound implements OutboundInterface { export default class TorOutbound implements OutboundInterface {
log = logger.extend('Network:Outbound:Tor'); log = logger.extend("Network:Outbound:Tor");
running = false; running = false;
error?: Error; error?: Error;
readonly type = TOR_PROXY_TYPE; readonly type = TOR_PROXY_TYPE;
readonly address = TOR_PROXY; readonly address = TOR_PROXY;
readonly available = !!TOR_PROXY; readonly available = !!TOR_PROXY;
async start() { async start() {
try { try {
if (this.running) return; if (this.running) return;
this.running = true; this.running = true;
this.log(`Connecting to ${TOR_PROXY}`); this.log(`Connecting to ${TOR_PROXY}`);
const [host, port] = this.address?.split(':') ?? []; const [host, port] = this.address?.split(":") ?? [];
if (!host || !port) throw new Error('Malformed proxy address'); if (!host || !port) throw new Error("Malformed proxy address");
await testTCPConnection(host, parseInt(port), 3000); await testTCPConnection(host, parseInt(port), 3000);
} catch (error) { } catch (error) {
this.running = false; this.running = false;
if (error instanceof Error) this.error = error; if (error instanceof Error) this.error = error;
} }
} }
async stop() {} async stop() {}
} }

View File

@@ -1,11 +1,11 @@
import { ClientRequestArgs } from 'http'; import { ClientRequestArgs } from "http";
import { ClientOptions, WebSocket } from 'ws'; import { ClientOptions, WebSocket } from "ws";
import outboundNetwork from './index.js'; import outboundNetwork from "./index.js";
/** extends the WebSocket class from ws to always use the custom http agent */ /** extends the WebSocket class from ws to always use the custom http agent */
export default class OutboundProxyWebSocket extends WebSocket { export default class OutboundProxyWebSocket extends WebSocket {
constructor(address: string | URL, options?: ClientOptions | ClientRequestArgs) { constructor(address: string | URL, options?: ClientOptions | ClientRequestArgs) {
super(address, { agent: outboundNetwork.agent, ...options }); super(address, { agent: outboundNetwork.agent, ...options });
} }
} }

View File

@@ -1,155 +1,155 @@
import { NotificationChannel, WebPushNotification } from '@satellite-earth/core/types/control-api/notifications.js'; import { NotificationChannel, WebPushNotification } from "@satellite-earth/core/types/control-api/notifications.js";
import { getDMRecipient, getDMSender, getUserDisplayName, parseKind0Event } from '@satellite-earth/core/helpers/nostr'; import { getDMRecipient, getDMSender, getUserDisplayName, parseKind0Event } from "@satellite-earth/core/helpers/nostr";
import { NostrEvent, kinds } from 'nostr-tools'; import { NostrEvent, kinds } from "nostr-tools";
import { npubEncode } from 'nostr-tools/nip19'; import { npubEncode } from "nostr-tools/nip19";
import EventEmitter from 'events'; import EventEmitter from "events";
import webPush from 'web-push'; import webPush from "web-push";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import { logger } from '../../logger.js'; import { logger } from "../../logger.js";
import App from '../../app/index.js'; import App from "../../app/index.js";
export type NotificationsManagerState = { export type NotificationsManagerState = {
channels: NotificationChannel[]; channels: NotificationChannel[];
}; };
type EventMap = { type EventMap = {
addChannel: [NotificationChannel]; addChannel: [NotificationChannel];
updateChannel: [NotificationChannel]; updateChannel: [NotificationChannel];
removeChannel: [NotificationChannel]; removeChannel: [NotificationChannel];
}; };
export default class NotificationsManager extends EventEmitter<EventMap> { export default class NotificationsManager extends EventEmitter<EventMap> {
log = logger.extend('Notifications'); log = logger.extend("Notifications");
app: App; app: App;
lastRead: number = dayjs().unix(); lastRead: number = dayjs().unix();
webPushKeys: webPush.VapidKeys = webPush.generateVAPIDKeys(); webPushKeys: webPush.VapidKeys = webPush.generateVAPIDKeys();
state: NotificationsManagerState = { channels: [] }; state: NotificationsManagerState = { channels: [] };
get channels() { get channels() {
return this.state.channels; return this.state.channels;
} }
constructor(app: App) { constructor(app: App) {
super(); super();
this.app = app; this.app = app;
} }
async setup() { async setup() {
this.state = ( this.state = (
await this.app.state.getMutableState<NotificationsManagerState>('notification-manager', { channels: [] }) await this.app.state.getMutableState<NotificationsManagerState>("notification-manager", { channels: [] })
).proxy; ).proxy;
} }
addOrUpdateChannel(channel: NotificationChannel) { addOrUpdateChannel(channel: NotificationChannel) {
if (this.state.channels.some((c) => c.id === channel.id)) { if (this.state.channels.some((c) => c.id === channel.id)) {
// update channel // update channel
this.log(`Updating channel ${channel.id} (${channel.type})`); this.log(`Updating channel ${channel.id} (${channel.type})`);
this.state.channels = this.state.channels.map((c) => { this.state.channels = this.state.channels.map((c) => {
if (c.id === channel.id) return channel; if (c.id === channel.id) return channel;
else return c; else return c;
}); });
this.emit('updateChannel', channel); this.emit("updateChannel", channel);
} else { } else {
// add channel // add channel
this.log(`Added new channel ${channel.id} (${channel.type})`); this.log(`Added new channel ${channel.id} (${channel.type})`);
this.state.channels = [...this.state.channels, channel]; this.state.channels = [...this.state.channels, channel];
this.emit('addChannel', channel); this.emit("addChannel", channel);
} }
} }
removeChannel(id: string) { removeChannel(id: string) {
const channel = this.state.channels.find((s) => s.id === id); const channel = this.state.channels.find((s) => s.id === id);
if (channel) { if (channel) {
this.log(`Removed channel ${id}`); this.log(`Removed channel ${id}`);
this.state.channels = this.state.channels.filter((s) => s.id !== id); this.state.channels = this.state.channels.filter((s) => s.id !== id);
this.emit('removeChannel', channel); this.emit("removeChannel", channel);
} }
} }
/** Whether a notification should be sent */ /** Whether a notification should be sent */
shouldNotify(event: NostrEvent) { shouldNotify(event: NostrEvent) {
if (event.kind !== kinds.EncryptedDirectMessage) return; if (event.kind !== kinds.EncryptedDirectMessage) return;
if (getDMRecipient(event) !== this.app.config.data.owner) return; if (getDMRecipient(event) !== this.app.config.data.owner) return;
if (event.created_at > this.lastRead) return true; if (event.created_at > this.lastRead) return true;
} }
/** builds a notification based on a nostr event */ /** builds a notification based on a nostr event */
async buildNotification(event: NostrEvent) { async buildNotification(event: NostrEvent) {
// TODO in the future we might need to build special notifications for channel type // TODO in the future we might need to build special notifications for channel type
switch (event.kind) { switch (event.kind) {
case kinds.EncryptedDirectMessage: case kinds.EncryptedDirectMessage:
const sender = getDMSender(event); const sender = getDMSender(event);
const senderProfileEvent = await this.app.profileBook.loadProfile(sender); const senderProfileEvent = await this.app.profileBook.loadProfile(sender);
const senderProfile = senderProfileEvent ? parseKind0Event(senderProfileEvent) : undefined; const senderProfile = senderProfileEvent ? parseKind0Event(senderProfileEvent) : undefined;
const senderName = getUserDisplayName(senderProfile, sender); const senderName = getUserDisplayName(senderProfile, sender);
return { return {
kind: event.kind, kind: event.kind,
event, event,
senderName, senderName,
senderProfile, senderProfile,
title: `Message from ${senderName}`, title: `Message from ${senderName}`,
body: 'Tap on notification to read', body: "Tap on notification to read",
icon: 'https://app.satellite.earth/logo-64x64.png', icon: "https://app.satellite.earth/logo-64x64.png",
// TODO: switch this to a satellite:// link once the native app supports it // TODO: switch this to a satellite:// link once the native app supports it
url: `https://app.satellite.earth/messages/p/${npubEncode(sender)}`, url: `https://app.satellite.earth/messages/p/${npubEncode(sender)}`,
}; };
} }
} }
async notify(event: NostrEvent) { async notify(event: NostrEvent) {
const notification = await this.buildNotification(event); const notification = await this.buildNotification(event);
if (!notification) return; if (!notification) return;
this.log(`Sending notification for ${event.id} to ${this.state.channels.length} channels`); this.log(`Sending notification for ${event.id} to ${this.state.channels.length} channels`);
for (const channel of this.state.channels) { for (const channel of this.state.channels) {
this.log(`Sending notification "${notification.title}" to ${channel.id} (${channel.type})`); this.log(`Sending notification "${notification.title}" to ${channel.id} (${channel.type})`);
try { try {
switch (channel.type) { switch (channel.type) {
case 'web': case "web":
const pushNotification: WebPushNotification = { const pushNotification: WebPushNotification = {
title: notification.title, title: notification.title,
body: notification.body, body: notification.body,
icon: notification.icon, icon: notification.icon,
url: notification.url, url: notification.url,
event: notification.event, event: notification.event,
}; };
await webPush.sendNotification(channel, JSON.stringify(pushNotification), { await webPush.sendNotification(channel, JSON.stringify(pushNotification), {
vapidDetails: { vapidDetails: {
subject: 'mailto:admin@example.com', subject: "mailto:admin@example.com",
publicKey: this.webPushKeys.publicKey, publicKey: this.webPushKeys.publicKey,
privateKey: this.webPushKeys.privateKey, privateKey: this.webPushKeys.privateKey,
}, },
}); });
break; break;
case 'ntfy': case "ntfy":
const headers: HeadersInit = { const headers: HeadersInit = {
Title: notification.title, Title: notification.title,
Icon: notification.icon, Icon: notification.icon,
Click: notification.url, Click: notification.url,
}; };
await fetch(new URL(channel.topic, channel.server), { await fetch(new URL(channel.topic, channel.server), {
method: 'POST', method: "POST",
body: notification.body, body: notification.body,
headers, headers,
}).then((res) => res.text()); }).then((res) => res.text());
break; break;
default: default:
// @ts-expect-error // @ts-expect-error
throw new Error(`Unknown channel type ${channel.type}`); throw new Error(`Unknown channel type ${channel.type}`);
} }
} catch (error) { } catch (error) {
this.log(`Failed to notification ${channel.id} (${channel.type})`); this.log(`Failed to notification ${channel.id} (${channel.type})`);
this.log(error); this.log(error);
} }
} }
} }
} }

View File

@@ -1,40 +1,40 @@
import { NostrEvent, kinds } from 'nostr-tools'; import { NostrEvent, kinds } from "nostr-tools";
import _throttle from 'lodash.throttle'; import _throttle from "lodash.throttle";
import { COMMON_CONTACT_RELAYS } from '../env.js'; import { COMMON_CONTACT_RELAYS } from "../env.js";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
import App from '../app/index.js'; import App from "../app/index.js";
import PubkeyBatchLoader from './pubkey-batch-loader.js'; import PubkeyBatchLoader from "./pubkey-batch-loader.js";
/** loads kind 0 metadata for pubkeys */ /** loads kind 0 metadata for pubkeys */
export default class ProfileBook { export default class ProfileBook {
log = logger.extend('ProfileBook'); log = logger.extend("ProfileBook");
app: App; app: App;
loader: PubkeyBatchLoader; loader: PubkeyBatchLoader;
extraRelays = COMMON_CONTACT_RELAYS; extraRelays = COMMON_CONTACT_RELAYS;
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
this.loader = new PubkeyBatchLoader(kinds.Metadata, this.app.pool, (pubkey) => { this.loader = new PubkeyBatchLoader(kinds.Metadata, this.app.pool, (pubkey) => {
return this.app.eventStore.getEventsForFilters([{ kinds: [kinds.Metadata], authors: [pubkey] }])?.[0]; return this.app.eventStore.getEventsForFilters([{ kinds: [kinds.Metadata], authors: [pubkey] }])?.[0];
}); });
this.loader.on('event', (event) => this.app.eventStore.addEvent(event)); this.loader.on("event", (event) => this.app.eventStore.addEvent(event));
this.loader.on('batch', (found, failed) => { this.loader.on("batch", (found, failed) => {
this.log(`Found ${found}, failed ${failed}, pending ${this.loader.queue}`); this.log(`Found ${found}, failed ${failed}, pending ${this.loader.queue}`);
}); });
} }
getProfile(pubkey: string) { getProfile(pubkey: string) {
return this.loader.getEvent(pubkey); return this.loader.getEvent(pubkey);
} }
handleEvent(event: NostrEvent) { handleEvent(event: NostrEvent) {
this.loader.handleEvent(event); this.loader.handleEvent(event);
} }
async loadProfile(pubkey: string, relays: string[] = []) { async loadProfile(pubkey: string, relays: string[] = []) {
return this.loader.getOrLoadEvent(pubkey, relays); return this.loader.getOrLoadEvent(pubkey, relays);
} }
} }

View File

@@ -1,259 +1,259 @@
import EventEmitter from 'events'; import EventEmitter from "events";
import { NostrEvent, SimplePool, Filter } from 'nostr-tools'; import { NostrEvent, SimplePool, Filter } from "nostr-tools";
import SuperMap from '@satellite-earth/core/helpers/super-map.js'; import SuperMap from "@satellite-earth/core/helpers/super-map.js";
import { AbstractRelay, Subscription, SubscriptionParams } from 'nostr-tools/abstract-relay'; import { AbstractRelay, Subscription, SubscriptionParams } from "nostr-tools/abstract-relay";
import { getPubkeysFromList } from '@satellite-earth/core/helpers/nostr/lists.js'; import { getPubkeysFromList } from "@satellite-earth/core/helpers/nostr/lists.js";
import { getInboxes, getOutboxes } from '@satellite-earth/core/helpers/nostr/mailboxes.js'; import { getInboxes, getOutboxes } from "@satellite-earth/core/helpers/nostr/mailboxes.js";
import { getRelaysFromContactList } from '@satellite-earth/core/helpers/nostr/contacts.js'; import { getRelaysFromContactList } from "@satellite-earth/core/helpers/nostr/contacts.js";
import { BOOTSTRAP_RELAYS } from '../../env.js'; import { BOOTSTRAP_RELAYS } from "../../env.js";
import { logger } from '../../logger.js'; import { logger } from "../../logger.js";
import App from '../../app/index.js'; import App from "../../app/index.js";
/** creates a new subscription and waits for it to get an event or close */ /** creates a new subscription and waits for it to get an event or close */
function asyncSubscription(relay: AbstractRelay, filters: Filter[], opts: SubscriptionParams) { function asyncSubscription(relay: AbstractRelay, filters: Filter[], opts: SubscriptionParams) {
let resolved = false; let resolved = false;
return new Promise<Subscription>((res, rej) => { return new Promise<Subscription>((res, rej) => {
const sub = relay.subscribe(filters, { const sub = relay.subscribe(filters, {
onevent: (event) => { onevent: (event) => {
if (!resolved) res(sub); if (!resolved) res(sub);
opts.onevent?.(event); opts.onevent?.(event);
}, },
oneose: () => { oneose: () => {
if (!resolved) res(sub); if (!resolved) res(sub);
opts.oneose?.(); opts.oneose?.();
}, },
onclose: (reason) => { onclose: (reason) => {
if (!resolved) rej(new Error(reason)); if (!resolved) rej(new Error(reason));
opts.onclose?.(reason); opts.onclose?.(reason);
}, },
}); });
}); });
} }
type EventMap = { type EventMap = {
started: [Receiver]; started: [Receiver];
stopped: [Receiver]; stopped: [Receiver];
status: [string]; status: [string];
rebuild: []; rebuild: [];
subscribed: [string, string[]]; subscribed: [string, string[]];
closed: [string, string[]]; closed: [string, string[]];
error: [Error]; error: [Error];
event: [NostrEvent]; event: [NostrEvent];
}; };
type ReceiverStatus = 'running' | 'starting' | 'errored' | 'stopped'; type ReceiverStatus = "running" | "starting" | "errored" | "stopped";
export default class Receiver extends EventEmitter<EventMap> { export default class Receiver extends EventEmitter<EventMap> {
log = logger.extend('Receiver'); log = logger.extend("Receiver");
_status: ReceiverStatus = 'stopped'; _status: ReceiverStatus = "stopped";
get status() { get status() {
return this._status; return this._status;
} }
set status(v: ReceiverStatus) { set status(v: ReceiverStatus) {
this._status = v; this._status = v;
this.emit('status', v); this.emit("status", v);
} }
starting = true; starting = true;
startupError?: Error; startupError?: Error;
app: App; app: App;
pool: SimplePool; pool: SimplePool;
subscriptions = new Map<string, Subscription>(); subscriptions = new Map<string, Subscription>();
constructor(app: App, pool?: SimplePool) { constructor(app: App, pool?: SimplePool) {
super(); super();
this.app = app; this.app = app;
this.pool = pool || app.pool; this.pool = pool || app.pool;
} }
// pubkey -> relays // pubkey -> relays
private pubkeyRelays = new Map<string, Set<string>>(); private pubkeyRelays = new Map<string, Set<string>>();
// relay url -> pubkeys // relay url -> pubkeys
private relayPubkeys = new SuperMap<string, Set<string>>(() => new Set()); private relayPubkeys = new SuperMap<string, Set<string>>(() => new Set());
// the current request map in the format of relay -> pubkeys // the current request map in the format of relay -> pubkeys
map = new SuperMap<string, Set<string>>(() => new Set()); map = new SuperMap<string, Set<string>>(() => new Set());
async fetchData() { async fetchData() {
const owner = this.app.config.data.owner; const owner = this.app.config.data.owner;
if (!owner) throw new Error('Missing owner'); if (!owner) throw new Error("Missing owner");
const ownerMailboxes = await this.app.addressBook.loadMailboxes(owner); const ownerMailboxes = await this.app.addressBook.loadMailboxes(owner);
const ownerInboxes = getInboxes(ownerMailboxes); const ownerInboxes = getInboxes(ownerMailboxes);
const ownerOutboxes = getOutboxes(ownerMailboxes); const ownerOutboxes = getOutboxes(ownerMailboxes);
this.log('Searching for owner kind:3 contacts'); this.log("Searching for owner kind:3 contacts");
const contacts = await this.app.contactBook.loadContacts(owner); const contacts = await this.app.contactBook.loadContacts(owner);
if (!contacts) throw new Error('Cant find contacts'); if (!contacts) throw new Error("Cant find contacts");
this.pubkeyRelays.clear(); this.pubkeyRelays.clear();
this.relayPubkeys.clear(); this.relayPubkeys.clear();
// add the owners details // add the owners details
this.pubkeyRelays.set(owner, new Set(ownerOutboxes)); this.pubkeyRelays.set(owner, new Set(ownerOutboxes));
for (const url of ownerOutboxes) this.relayPubkeys.get(url).add(owner); for (const url of ownerOutboxes) this.relayPubkeys.get(url).add(owner);
const people = getPubkeysFromList(contacts); const people = getPubkeysFromList(contacts);
this.log(`Found ${people.length} contacts`); this.log(`Found ${people.length} contacts`);
let usersWithMailboxes = 0; let usersWithMailboxes = 0;
let usersWithContactRelays = 0; let usersWithContactRelays = 0;
let usersWithFallbackRelays = 0; let usersWithFallbackRelays = 0;
// fetch all addresses in parallel // fetch all addresses in parallel
await Promise.all( await Promise.all(
people.map(async (person) => { people.map(async (person) => {
const mailboxes = await this.app.addressBook.loadMailboxes(person.pubkey, ownerInboxes ?? BOOTSTRAP_RELAYS); const mailboxes = await this.app.addressBook.loadMailboxes(person.pubkey, ownerInboxes ?? BOOTSTRAP_RELAYS);
let relays = getOutboxes(mailboxes); let relays = getOutboxes(mailboxes);
// if the user does not have any mailboxes try to get the relays stored in the contact list // if the user does not have any mailboxes try to get the relays stored in the contact list
if (relays.length === 0) { if (relays.length === 0) {
this.log(`Failed to find mailboxes for ${person.pubkey}`); this.log(`Failed to find mailboxes for ${person.pubkey}`);
const contacts = await this.app.contactBook.loadContacts(person.pubkey, ownerInboxes ?? BOOTSTRAP_RELAYS); const contacts = await this.app.contactBook.loadContacts(person.pubkey, ownerInboxes ?? BOOTSTRAP_RELAYS);
if (contacts && contacts.content.startsWith('{')) { if (contacts && contacts.content.startsWith("{")) {
const parsed = getRelaysFromContactList(contacts); const parsed = getRelaysFromContactList(contacts);
if (parsed) { if (parsed) {
relays = parsed.filter((r) => r.write).map((r) => r.url); relays = parsed.filter((r) => r.write).map((r) => r.url);
usersWithContactRelays++; usersWithContactRelays++;
} else { } else {
relays = BOOTSTRAP_RELAYS; relays = BOOTSTRAP_RELAYS;
usersWithFallbackRelays++; usersWithFallbackRelays++;
} }
} else { } else {
relays = BOOTSTRAP_RELAYS; relays = BOOTSTRAP_RELAYS;
usersWithFallbackRelays++; usersWithFallbackRelays++;
} }
} else usersWithMailboxes++; } else usersWithMailboxes++;
// add pubkey details // add pubkey details
this.pubkeyRelays.set(person.pubkey, new Set(relays)); this.pubkeyRelays.set(person.pubkey, new Set(relays));
for (const url of relays) this.relayPubkeys.get(url).add(person.pubkey); for (const url of relays) this.relayPubkeys.get(url).add(person.pubkey);
}), }),
); );
this.log( this.log(
`Found ${usersWithMailboxes} users with mailboxes, ${usersWithContactRelays} user with relays in contact list, and ${usersWithFallbackRelays} using fallback relays`, `Found ${usersWithMailboxes} users with mailboxes, ${usersWithContactRelays} user with relays in contact list, and ${usersWithFallbackRelays} using fallback relays`,
); );
} }
buildMap() { buildMap() {
this.map.clear(); this.map.clear();
// sort pubkey relays by popularity // sort pubkey relays by popularity
for (const [pubkey, relays] of this.pubkeyRelays) { for (const [pubkey, relays] of this.pubkeyRelays) {
const sorted = Array.from(relays).sort((a, b) => this.relayPubkeys.get(b).size - this.relayPubkeys.get(a).size); const sorted = Array.from(relays).sort((a, b) => this.relayPubkeys.get(b).size - this.relayPubkeys.get(a).size);
// add the pubkey to their top two relays // add the pubkey to their top two relays
for (const url of sorted.slice(0, 2)) this.map.get(url).add(pubkey); for (const url of sorted.slice(0, 2)) this.map.get(url).add(pubkey);
} }
this.emit('rebuild'); this.emit("rebuild");
return this.map; return this.map;
} }
private handleEvent(event: NostrEvent) { private handleEvent(event: NostrEvent) {
this.emit('event', event); this.emit("event", event);
} }
async updateRelaySubscription(url: string) { async updateRelaySubscription(url: string) {
const pubkeys = this.map.get(url); const pubkeys = this.map.get(url);
if (pubkeys.size === 0) return; if (pubkeys.size === 0) return;
const subscription = this.subscriptions.get(url); const subscription = this.subscriptions.get(url);
if (!subscription || subscription.closed) { if (!subscription || subscription.closed) {
const relay = await this.app.pool.ensureRelay(url); const relay = await this.app.pool.ensureRelay(url);
const sub = relay.subscribe([{ authors: Array.from(pubkeys) }], { const sub = relay.subscribe([{ authors: Array.from(pubkeys) }], {
onevent: this.handleEvent.bind(this), onevent: this.handleEvent.bind(this),
onclose: () => { onclose: () => {
this.emit('closed', url, Array.from(pubkeys)); this.emit("closed", url, Array.from(pubkeys));
// wait 30 seconds then try to reconnect // wait 30 seconds then try to reconnect
setTimeout(() => { setTimeout(() => {
this.updateRelaySubscription(url); this.updateRelaySubscription(url);
}, 30_000); }, 30_000);
}, },
}); });
this.emit('subscribed', url, Array.from(pubkeys)); this.emit("subscribed", url, Array.from(pubkeys));
this.subscriptions.set(url, sub); this.subscriptions.set(url, sub);
this.log(`Subscribed to ${url} for ${pubkeys.size} pubkeys`); this.log(`Subscribed to ${url} for ${pubkeys.size} pubkeys`);
} else { } else {
const hasOld = subscription.filters[0].authors?.some((p) => !pubkeys.has(p)); const hasOld = subscription.filters[0].authors?.some((p) => !pubkeys.has(p));
const hasNew = Array.from(pubkeys).some((p) => !subscription.filters[0].authors?.includes(p)); const hasNew = Array.from(pubkeys).some((p) => !subscription.filters[0].authors?.includes(p));
if (hasNew || hasOld) { if (hasNew || hasOld) {
// reset the subscription // reset the subscription
subscription.eosed = false; subscription.eosed = false;
subscription.filters = [{ authors: Array.from(pubkeys) }]; subscription.filters = [{ authors: Array.from(pubkeys) }];
subscription.fire(); subscription.fire();
this.log(`Subscribed to ${url} with ${pubkeys.size} pubkeys`); this.log(`Subscribed to ${url} with ${pubkeys.size} pubkeys`);
} }
} }
} }
ensureSubscriptions() { ensureSubscriptions() {
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
for (const [url, pubkeys] of this.map) { for (const [url, pubkeys] of this.map) {
const p = this.updateRelaySubscription(url).catch((error) => { const p = this.updateRelaySubscription(url).catch((error) => {
// failed to connect to relay // failed to connect to relay
// this needs to be remembered and the subscription map should be rebuilt accordingly // this needs to be remembered and the subscription map should be rebuilt accordingly
}); });
promises.push(p); promises.push(p);
} }
return Promise.all(promises); return Promise.all(promises);
} }
async start() { async start() {
if (this.status === 'running' || this.status === 'starting') return; if (this.status === "running" || this.status === "starting") return;
try { try {
this.log('Starting'); this.log("Starting");
this.startupError = undefined; this.startupError = undefined;
this.status = 'starting'; this.status = "starting";
await this.fetchData(); await this.fetchData();
this.buildMap(); this.buildMap();
await this.ensureSubscriptions(); await this.ensureSubscriptions();
this.status = 'running'; this.status = "running";
this.emit('started', this); this.emit("started", this);
} catch (error) { } catch (error) {
this.status = 'errored'; this.status = "errored";
if (error instanceof Error) { if (error instanceof Error) {
this.startupError = error; this.startupError = error;
this.log(`Failed to start receiver`, error.message); this.log(`Failed to start receiver`, error.message);
this.emit('error', error); this.emit("error", error);
} }
} }
} }
/** stop receiving events and disconnect from all relays */ /** stop receiving events and disconnect from all relays */
stop() { stop() {
if (this.status === 'stopped') return; if (this.status === "stopped") return;
this.status = 'stopped'; this.status = "stopped";
for (const [relay, sub] of this.subscriptions) sub.close(); for (const [relay, sub] of this.subscriptions) sub.close();
this.subscriptions.clear(); this.subscriptions.clear();
this.log('Stopped'); this.log("Stopped");
this.emit('stopped', this); this.emit("stopped", this);
} }
destroy() { destroy() {
this.stop(); this.stop();
this.removeAllListeners(); this.removeAllListeners();
} }
} }

View File

@@ -1,115 +1,115 @@
import { ReportArguments, ReportResults } from '@satellite-earth/core/types'; import { ReportArguments, ReportResults } from "@satellite-earth/core/types";
import { NostrEvent } from 'nostr-tools'; import { NostrEvent } from "nostr-tools";
import { getTagValue } from '@satellite-earth/core/helpers/nostr'; import { getTagValue } from "@satellite-earth/core/helpers/nostr";
import SuperMap from '@satellite-earth/core/helpers/super-map.js'; import SuperMap from "@satellite-earth/core/helpers/super-map.js";
import Report from '../report.js'; import Report from "../report.js";
export default class ConversationsReport extends Report<'CONVERSATIONS'> { export default class ConversationsReport extends Report<"CONVERSATIONS"> {
readonly type = 'CONVERSATIONS'; readonly type = "CONVERSATIONS";
private async getConversationResult(self: string, other: string) { private async getConversationResult(self: string, other: string) {
const sent = this.app.database.db const sent = this.app.database.db
.prepare<[string, string], { pubkey: string; count: number; lastMessage: number }>( .prepare<[string, string], { pubkey: string; count: number; lastMessage: number }>(
` `
SELECT tags.v as pubkey, count(events.id) as count, max(events.created_at) as lastMessage FROM tags SELECT tags.v as pubkey, count(events.id) as count, max(events.created_at) as lastMessage FROM tags
INNER JOIN events ON events.id = tags.e INNER JOIN events ON events.id = tags.e
WHERE events.kind = 4 AND tags.t = 'p' AND events.pubkey = ? AND tags.v = ?`, WHERE events.kind = 4 AND tags.t = 'p' AND events.pubkey = ? AND tags.v = ?`,
) )
.get(self, other); .get(self, other);
const received = this.app.database.db const received = this.app.database.db
.prepare<[string, string], { pubkey: string; count: number; lastMessage: number }>( .prepare<[string, string], { pubkey: string; count: number; lastMessage: number }>(
` `
SELECT events.pubkey, count(events.id) as count, max(events.created_at) as lastMessage FROM events SELECT events.pubkey, count(events.id) as count, max(events.created_at) as lastMessage FROM events
INNER JOIN tags ON tags.e = events.id INNER JOIN tags ON tags.e = events.id
WHERE events.kind = 4 AND tags.t = 'p' AND tags.v = ? AND events.pubkey = ?`, WHERE events.kind = 4 AND tags.t = 'p' AND tags.v = ? AND events.pubkey = ?`,
) )
.get(self, other); .get(self, other);
const result: ReportResults['CONVERSATIONS'] = { const result: ReportResults["CONVERSATIONS"] = {
pubkey: other, pubkey: other,
count: (received?.count ?? 0) + (sent?.count ?? 0), count: (received?.count ?? 0) + (sent?.count ?? 0),
sent: 0, sent: 0,
received: 0, received: 0,
}; };
if (received) { if (received) {
result.received = received.count; result.received = received.count;
result.lastReceived = received.lastMessage; result.lastReceived = received.lastMessage;
} }
if (sent) { if (sent) {
result.sent = sent.count; result.sent = sent.count;
result.lastSent = sent.lastMessage; result.lastSent = sent.lastMessage;
} }
return result; return result;
} }
private async getAllConversationResults(self: string) { private async getAllConversationResults(self: string) {
const sent = this.app.database.db const sent = this.app.database.db
.prepare<[string], { pubkey: string; count: number; lastMessage: number }>( .prepare<[string], { pubkey: string; count: number; lastMessage: number }>(
` `
SELECT tags.v as pubkey, count(tags.v) as count, max(events.created_at) as lastMessage FROM tags SELECT tags.v as pubkey, count(tags.v) as count, max(events.created_at) as lastMessage FROM tags
INNER JOIN events ON events.id = tags.e INNER JOIN events ON events.id = tags.e
WHERE events.kind = 4 AND tags.t = 'p' AND events.pubkey = ? WHERE events.kind = 4 AND tags.t = 'p' AND events.pubkey = ?
GROUP BY tags.v`, GROUP BY tags.v`,
) )
.all(self); .all(self);
const received = this.app.database.db const received = this.app.database.db
.prepare<[string], { pubkey: string; count: number; lastMessage: number }>( .prepare<[string], { pubkey: string; count: number; lastMessage: number }>(
` `
SELECT events.pubkey, count(events.pubkey) as count, max(events.created_at) as lastMessage FROM events SELECT events.pubkey, count(events.pubkey) as count, max(events.created_at) as lastMessage FROM events
INNER JOIN tags ON tags.e = events.id INNER JOIN tags ON tags.e = events.id
WHERE events.kind = 4 AND tags.t = 'p' AND tags.v = ? WHERE events.kind = 4 AND tags.t = 'p' AND tags.v = ?
GROUP BY events.pubkey`, GROUP BY events.pubkey`,
) )
.all(self); .all(self);
const results = new SuperMap<string, ReportResults['CONVERSATIONS']>((pubkey) => ({ const results = new SuperMap<string, ReportResults["CONVERSATIONS"]>((pubkey) => ({
pubkey, pubkey,
count: sent.length + received.length, count: sent.length + received.length,
sent: 0, sent: 0,
received: 0, received: 0,
})); }));
for (const { pubkey, count, lastMessage } of received) { for (const { pubkey, count, lastMessage } of received) {
const result = results.get(pubkey); const result = results.get(pubkey);
result.received = count; result.received = count;
result.lastReceived = lastMessage; result.lastReceived = lastMessage;
} }
for (const { pubkey, count, lastMessage } of sent) { for (const { pubkey, count, lastMessage } of sent) {
const result = results.get(pubkey); const result = results.get(pubkey);
result.sent = count; result.sent = count;
result.lastSent = lastMessage; result.lastSent = lastMessage;
} }
return Array.from(results.values()).sort( return Array.from(results.values()).sort(
(a, b) => Math.max(b.lastReceived ?? 0, b.lastSent ?? 0) - Math.max(a.lastReceived ?? 0, a.lastSent ?? 0), (a, b) => Math.max(b.lastReceived ?? 0, b.lastSent ?? 0) - Math.max(a.lastReceived ?? 0, a.lastSent ?? 0),
); );
} }
async setup(args: ReportArguments['CONVERSATIONS']) { async setup(args: ReportArguments["CONVERSATIONS"]) {
const listener = (event: NostrEvent) => { const listener = (event: NostrEvent) => {
const from = event.pubkey; const from = event.pubkey;
const to = getTagValue(event, 'p'); const to = getTagValue(event, "p");
if (!to) return; if (!to) return;
const self = args.pubkey; const self = args.pubkey;
// get the latest stats from the database // get the latest stats from the database
this.getConversationResult(self, self === from ? to : from).then((result) => this.send(result)); this.getConversationResult(self, self === from ? to : from).then((result) => this.send(result));
}; };
this.app.directMessageManager.on('message', listener); this.app.directMessageManager.on("message", listener);
return () => this.app.directMessageManager.off('message', listener); return () => this.app.directMessageManager.off("message", listener);
} }
async execute(args: ReportArguments['CONVERSATIONS']) { async execute(args: ReportArguments["CONVERSATIONS"]) {
const results = await this.getAllConversationResults(args.pubkey); const results = await this.getAllConversationResults(args.pubkey);
for (const result of results) { for (const result of results) {
this.send(result); this.send(result);
} }
} }
} }

View File

@@ -1,11 +1,11 @@
import { ReportArguments } from '@satellite-earth/core/types'; import { ReportArguments } from "@satellite-earth/core/types";
import Report from '../report.js'; import Report from "../report.js";
export default class DMSearchReport extends Report<'DM_SEARCH'> { export default class DMSearchReport extends Report<"DM_SEARCH"> {
readonly type = 'DM_SEARCH'; readonly type = "DM_SEARCH";
async execute(args: ReportArguments['DM_SEARCH']) { async execute(args: ReportArguments["DM_SEARCH"]) {
const results = await this.app.decryptionCache.search(args.query, args); const results = await this.app.decryptionCache.search(args.query, args);
for (const result of results) this.send(result); for (const result of results) this.send(result);
} }
} }

View File

@@ -1,12 +1,12 @@
import { ReportArguments } from '@satellite-earth/core/types'; import { ReportArguments } from "@satellite-earth/core/types";
import { EventRow, parseEventRow } from '@satellite-earth/core'; import { EventRow, parseEventRow } from "@satellite-earth/core";
import Report from '../report.js'; import Report from "../report.js";
export default class EventsSummaryReport extends Report<'EVENTS_SUMMARY'> { export default class EventsSummaryReport extends Report<"EVENTS_SUMMARY"> {
readonly type = 'EVENTS_SUMMARY'; readonly type = "EVENTS_SUMMARY";
async execute(args: ReportArguments['EVENTS_SUMMARY']): Promise<void> { async execute(args: ReportArguments["EVENTS_SUMMARY"]): Promise<void> {
let sql = ` let sql = `
SELECT SELECT
events.*, events.*,
COUNT(l.id) AS reactions, COUNT(l.id) AS reactions,
@@ -20,50 +20,50 @@ export default class EventsSummaryReport extends Report<'EVENTS_SUMMARY'> {
LEFT JOIN events AS r ON r.id = tags.e AND r.kind = 1 LEFT JOIN events AS r ON r.id = tags.e AND r.kind = 1
`; `;
const params: any[] = []; const params: any[] = [];
const conditions: string[] = []; const conditions: string[] = [];
if (args.kind !== undefined) { if (args.kind !== undefined) {
conditions.push(`events.kind = ?`); conditions.push(`events.kind = ?`);
params.push(args.kind); params.push(args.kind);
} }
if (args.pubkey !== undefined) { if (args.pubkey !== undefined) {
conditions.push(`events.pubkey = ?`); conditions.push(`events.pubkey = ?`);
params.push(args.pubkey); params.push(args.pubkey);
} }
if (conditions.length > 0) { if (conditions.length > 0) {
sql += ` WHERE ${conditions.join(' AND ')}\n`; sql += ` WHERE ${conditions.join(" AND ")}\n`;
} }
sql += ' GROUP BY events.id\n'; sql += " GROUP BY events.id\n";
switch (args.order) { switch (args.order) {
case 'created_at': case "created_at":
sql += ` ORDER BY events.created_at DESC\n`; sql += ` ORDER BY events.created_at DESC\n`;
break; break;
default: default:
case 'interactions': case "interactions":
sql += ` ORDER BY reactions + shares + replies DESC\n`; sql += ` ORDER BY reactions + shares + replies DESC\n`;
break; break;
} }
let limit = args.limit || 100; let limit = args.limit || 100;
sql += ` LIMIT ?`; sql += ` LIMIT ?`;
params.push(limit); params.push(limit);
const rows = await this.app.database.db const rows = await this.app.database.db
.prepare<any[], EventRow & { reactions: number; shares: number; replies: number }>(sql) .prepare<any[], EventRow & { reactions: number; shares: number; replies: number }>(sql)
.all(...params); .all(...params);
const results = rows.map((row) => { const results = rows.map((row) => {
const event = parseEventRow(row); const event = parseEventRow(row);
return { event, reactions: row.reactions, shares: row.shares, replies: row.replies }; return { event, reactions: row.reactions, shares: row.shares, replies: row.replies };
}); });
for (const result of results) { for (const result of results) {
this.send(result); this.send(result);
} }
} }
} }

View File

@@ -1,30 +1,30 @@
import { ReportArguments } from '@satellite-earth/core/types'; import { ReportArguments } from "@satellite-earth/core/types";
import Report from '../report.js'; import Report from "../report.js";
import OverviewReport from './overview.js'; import OverviewReport from "./overview.js";
import ConversationsReport from './conversations.js'; import ConversationsReport from "./conversations.js";
import LogsReport from './logs.js'; import LogsReport from "./logs.js";
import ServicesReport from './services.js'; import ServicesReport from "./services.js";
import DMSearchReport from './dm-search.js'; import DMSearchReport from "./dm-search.js";
import ScrapperStatusReport from './scrapper-status.js'; import ScrapperStatusReport from "./scrapper-status.js";
import ReceiverStatusReport from './receiver-status.js'; import ReceiverStatusReport from "./receiver-status.js";
import NetworkStatusReport from './network-status.js'; import NetworkStatusReport from "./network-status.js";
import NotificationChannelsReport from './notification-channels.js'; import NotificationChannelsReport from "./notification-channels.js";
import EventsSummaryReport from './events-summary.js'; import EventsSummaryReport from "./events-summary.js";
const REPORT_CLASSES: { const REPORT_CLASSES: {
[k in keyof ReportArguments]?: typeof Report<k>; [k in keyof ReportArguments]?: typeof Report<k>;
} = { } = {
OVERVIEW: OverviewReport, OVERVIEW: OverviewReport,
CONVERSATIONS: ConversationsReport, CONVERSATIONS: ConversationsReport,
LOGS: LogsReport, LOGS: LogsReport,
SERVICES: ServicesReport, SERVICES: ServicesReport,
DM_SEARCH: DMSearchReport, DM_SEARCH: DMSearchReport,
SCRAPPER_STATUS: ScrapperStatusReport, SCRAPPER_STATUS: ScrapperStatusReport,
RECEIVER_STATUS: ReceiverStatusReport, RECEIVER_STATUS: ReceiverStatusReport,
NETWORK_STATUS: NetworkStatusReport, NETWORK_STATUS: NetworkStatusReport,
NOTIFICATION_CHANNELS: NotificationChannelsReport, NOTIFICATION_CHANNELS: NotificationChannelsReport,
EVENTS_SUMMARY: EventsSummaryReport, EVENTS_SUMMARY: EventsSummaryReport,
}; };
export default REPORT_CLASSES; export default REPORT_CLASSES;

View File

@@ -1,23 +1,23 @@
import { ReportArguments } from '@satellite-earth/core/types'; import { ReportArguments } from "@satellite-earth/core/types";
import { LogEntry } from '../../log-store/log-store.js'; import { LogEntry } from "../../log-store/log-store.js";
import Report from '../report.js'; import Report from "../report.js";
/** WARNING: be careful of calling this.log in this class. it could trigger an infinite loop of logging */ /** WARNING: be careful of calling this.log in this class. it could trigger an infinite loop of logging */
export default class LogsReport extends Report<'LOGS'> { export default class LogsReport extends Report<"LOGS"> {
readonly type = 'LOGS'; readonly type = "LOGS";
async setup() { async setup() {
const listener = (entry: LogEntry) => { const listener = (entry: LogEntry) => {
if (!this.args?.service || entry.service === this.args.service) this.send(entry); if (!this.args?.service || entry.service === this.args.service) this.send(entry);
}; };
this.app.logStore.on('log', listener); this.app.logStore.on("log", listener);
return () => this.app.logStore.off('log', listener); return () => this.app.logStore.off("log", listener);
} }
async execute(args: ReportArguments['LOGS']) { async execute(args: ReportArguments["LOGS"]) {
const logs = this.app.logStore.getLogs({ service: args.service, limit: 500 }); const logs = this.app.logStore.getLogs({ service: args.service, limit: 500 });
for (const entry of logs) this.send(entry); for (const entry of logs) this.send(entry);
} }
} }

View File

@@ -1,69 +1,69 @@
import Report from '../report.js'; import Report from "../report.js";
export default class NetworkStatusReport extends Report<'NETWORK_STATUS'> { export default class NetworkStatusReport extends Report<"NETWORK_STATUS"> {
readonly type = 'NETWORK_STATUS'; readonly type = "NETWORK_STATUS";
update() { update() {
const torIn = this.app.inboundNetwork.tor; const torIn = this.app.inboundNetwork.tor;
const torOut = this.app.outboundNetwork.tor; const torOut = this.app.outboundNetwork.tor;
const hyperIn = this.app.inboundNetwork.hyper; const hyperIn = this.app.inboundNetwork.hyper;
const hyperOut = this.app.outboundNetwork.hyper; const hyperOut = this.app.outboundNetwork.hyper;
const i2pIn = this.app.inboundNetwork.i2p; const i2pIn = this.app.inboundNetwork.i2p;
const i2pOut = this.app.outboundNetwork.i2p; const i2pOut = this.app.outboundNetwork.i2p;
this.send({ this.send({
tor: { tor: {
inbound: { inbound: {
available: torIn.available, available: torIn.available,
running: torIn.running, running: torIn.running,
error: torIn.error?.message, error: torIn.error?.message,
address: torIn.address, address: torIn.address,
}, },
outbound: { outbound: {
available: torOut.available, available: torOut.available,
running: torOut.running, running: torOut.running,
error: torOut.error?.message, error: torOut.error?.message,
}, },
}, },
hyper: { hyper: {
inbound: { inbound: {
available: hyperIn.available, available: hyperIn.available,
running: hyperIn.running, running: hyperIn.running,
error: hyperIn.error?.message, error: hyperIn.error?.message,
address: hyperIn.address, address: hyperIn.address,
}, },
outbound: { outbound: {
available: hyperOut.available, available: hyperOut.available,
running: hyperOut.running, running: hyperOut.running,
error: hyperOut.error?.message, error: hyperOut.error?.message,
}, },
}, },
i2p: { i2p: {
inbound: { inbound: {
available: i2pIn.available, available: i2pIn.available,
running: i2pIn.running, running: i2pIn.running,
error: i2pIn.error?.message, error: i2pIn.error?.message,
address: i2pIn.address, address: i2pIn.address,
}, },
outbound: { outbound: {
available: i2pOut.available, available: i2pOut.available,
running: i2pOut.running, running: i2pOut.running,
error: i2pOut.error?.message, error: i2pOut.error?.message,
}, },
}, },
}); });
} }
async setup() { async setup() {
const listener = this.update.bind(this); const listener = this.update.bind(this);
// NOTE: set and interval since there are not events to listen to yet // NOTE: set and interval since there are not events to listen to yet
const i = setInterval(listener, 1000); const i = setInterval(listener, 1000);
return () => clearInterval(i); return () => clearInterval(i);
} }
async execute(args: {}): Promise<void> { async execute(args: {}): Promise<void> {
this.update(); this.update();
} }
} }

View File

@@ -1,29 +1,29 @@
import { NotificationChannel } from '@satellite-earth/core/types/control-api/notifications.js'; import { NotificationChannel } from "@satellite-earth/core/types/control-api/notifications.js";
import Report from '../report.js'; import Report from "../report.js";
export default class NotificationChannelsReport extends Report<'NOTIFICATION_CHANNELS'> { export default class NotificationChannelsReport extends Report<"NOTIFICATION_CHANNELS"> {
readonly type = 'NOTIFICATION_CHANNELS'; readonly type = "NOTIFICATION_CHANNELS";
async setup() { async setup() {
const listener = this.send.bind(this); const listener = this.send.bind(this);
const removeListener = (channel: NotificationChannel) => { const removeListener = (channel: NotificationChannel) => {
this.send(['removed', channel.id]); this.send(["removed", channel.id]);
}; };
this.app.notifications.on('addChannel', listener); this.app.notifications.on("addChannel", listener);
this.app.notifications.on('updateChannel', listener); this.app.notifications.on("updateChannel", listener);
this.app.notifications.on('removeChannel', removeListener); this.app.notifications.on("removeChannel", removeListener);
return () => { return () => {
this.app.notifications.off('addChannel', listener); this.app.notifications.off("addChannel", listener);
this.app.notifications.off('updateChannel', listener); this.app.notifications.off("updateChannel", listener);
this.app.notifications.off('removeChannel', removeListener); this.app.notifications.off("removeChannel", removeListener);
}; };
} }
async execute(args: {}): Promise<void> { async execute(args: {}): Promise<void> {
for (const channel of this.app.notifications.channels) { for (const channel of this.app.notifications.channels) {
this.send(channel); this.send(channel);
} }
} }
} }

View File

@@ -1,40 +1,40 @@
import { NostrEvent } from 'nostr-tools'; import { NostrEvent } from "nostr-tools";
import { ReportArguments } from '@satellite-earth/core/types'; import { ReportArguments } from "@satellite-earth/core/types";
import Report from '../report.js'; import Report from "../report.js";
export default class OverviewReport extends Report<'OVERVIEW'> { export default class OverviewReport extends Report<"OVERVIEW"> {
readonly type = 'OVERVIEW'; readonly type = "OVERVIEW";
async setup() { async setup() {
const listener = (event: NostrEvent) => { const listener = (event: NostrEvent) => {
// update summary for pubkey // update summary for pubkey
const result = this.app.database.db const result = this.app.database.db
.prepare< .prepare<
[string], [string],
{ pubkey: string; events: number; active: number } { pubkey: string; events: number; active: number }
>(`SELECT pubkey, COUNT(events.id) as \`events\`, MAX(created_at) as \`active\` FROM events WHERE pubkey=?`) >(`SELECT pubkey, COUNT(events.id) as \`events\`, MAX(created_at) as \`active\` FROM events WHERE pubkey=?`)
.get(event.pubkey); .get(event.pubkey);
if (result) this.send(result); if (result) this.send(result);
}; };
this.app.eventStore.on('event:inserted', listener); this.app.eventStore.on("event:inserted", listener);
return () => { return () => {
this.app.eventStore.off('event:inserted', listener); this.app.eventStore.off("event:inserted", listener);
}; };
} }
async execute(args: ReportArguments['OVERVIEW']) { async execute(args: ReportArguments["OVERVIEW"]) {
const results = await this.app.database.db const results = await this.app.database.db
.prepare< .prepare<
[], [],
{ pubkey: string; events: number; active: number } { pubkey: string; events: number; active: number }
>(`SELECT pubkey, COUNT(events.id) as \`events\`, MAX(created_at) as \`active\` FROM events GROUP BY pubkey ORDER BY \`events\` DESC`) >(`SELECT pubkey, COUNT(events.id) as \`events\`, MAX(created_at) as \`active\` FROM events GROUP BY pubkey ORDER BY \`events\` DESC`)
.all(); .all();
for (const result of results) { for (const result of results) {
this.send(result); this.send(result);
} }
} }
} }

View File

@@ -1,38 +1,38 @@
import Report from '../report.js'; import Report from "../report.js";
export default class ReceiverStatusReport extends Report<'RECEIVER_STATUS'> { export default class ReceiverStatusReport extends Report<"RECEIVER_STATUS"> {
readonly type = 'RECEIVER_STATUS'; readonly type = "RECEIVER_STATUS";
update() { update() {
this.send({ this.send({
status: this.app.receiver.status, status: this.app.receiver.status,
startError: this.app.receiver.startupError?.message, startError: this.app.receiver.startupError?.message,
subscriptions: Array.from(this.app.receiver.map).map(([relay, pubkeys]) => ({ subscriptions: Array.from(this.app.receiver.map).map(([relay, pubkeys]) => ({
relay, relay,
pubkeys: Array.from(pubkeys), pubkeys: Array.from(pubkeys),
active: !!this.app.receiver.subscriptions.get(relay), active: !!this.app.receiver.subscriptions.get(relay),
closed: !!this.app.receiver.subscriptions.get(relay)?.closed, closed: !!this.app.receiver.subscriptions.get(relay)?.closed,
})), })),
}); });
} }
async setup() { async setup() {
const listener = this.update.bind(this); const listener = this.update.bind(this);
this.app.receiver.on('status', listener); this.app.receiver.on("status", listener);
this.app.receiver.on('subscribed', listener); this.app.receiver.on("subscribed", listener);
this.app.receiver.on('closed', listener); this.app.receiver.on("closed", listener);
this.app.receiver.on('error', listener); this.app.receiver.on("error", listener);
return () => { return () => {
this.app.receiver.off('status', listener); this.app.receiver.off("status", listener);
this.app.receiver.off('subscribed', listener); this.app.receiver.off("subscribed", listener);
this.app.receiver.off('closed', listener); this.app.receiver.off("closed", listener);
this.app.receiver.off('error', listener); this.app.receiver.off("error", listener);
}; };
} }
async execute(args: {}): Promise<void> { async execute(args: {}): Promise<void> {
this.update(); this.update();
} }
} }

View File

@@ -1,55 +1,55 @@
import { NostrEvent } from 'nostr-tools'; import { NostrEvent } from "nostr-tools";
import _throttle from 'lodash.throttle'; import _throttle from "lodash.throttle";
import Report from '../report.js'; import Report from "../report.js";
export default class ScrapperStatusReport extends Report<'SCRAPPER_STATUS'> { export default class ScrapperStatusReport extends Report<"SCRAPPER_STATUS"> {
readonly type = 'SCRAPPER_STATUS'; readonly type = "SCRAPPER_STATUS";
eventsPerSecond: number[] = [0]; eventsPerSecond: number[] = [0];
update() { update() {
const averageEventsPerSecond = this.eventsPerSecond.reduce((m, v) => m + v, 0) / this.eventsPerSecond.length; const averageEventsPerSecond = this.eventsPerSecond.reduce((m, v) => m + v, 0) / this.eventsPerSecond.length;
const pubkeys = this.app.scrapper.state.pubkeys; const pubkeys = this.app.scrapper.state.pubkeys;
let activeSubscriptions = 0; let activeSubscriptions = 0;
for (const [pubkey, scrapper] of this.app.scrapper.scrappers) { for (const [pubkey, scrapper] of this.app.scrapper.scrappers) {
for (const [relay, relayScrapper] of scrapper.relayScrappers) { for (const [relay, relayScrapper] of scrapper.relayScrappers) {
if (relayScrapper.running) activeSubscriptions++; if (relayScrapper.running) activeSubscriptions++;
} }
} }
this.send({ this.send({
running: this.app.scrapper.running, running: this.app.scrapper.running,
eventsPerSecond: averageEventsPerSecond, eventsPerSecond: averageEventsPerSecond,
activeSubscriptions, activeSubscriptions,
pubkeys, pubkeys,
}); });
} }
async setup() { async setup() {
const onEvent = (event: NostrEvent) => { const onEvent = (event: NostrEvent) => {
this.eventsPerSecond[0]++; this.eventsPerSecond[0]++;
}; };
this.app.scrapper.on('event', onEvent); this.app.scrapper.on("event", onEvent);
const tick = setInterval(() => { const tick = setInterval(() => {
// start a new second // start a new second
this.eventsPerSecond.unshift(0); this.eventsPerSecond.unshift(0);
// limit to 60 seconds // limit to 60 seconds
while (this.eventsPerSecond.length > 60) this.eventsPerSecond.pop(); while (this.eventsPerSecond.length > 60) this.eventsPerSecond.pop();
this.update(); this.update();
}, 1000); }, 1000);
return () => { return () => {
this.app.scrapper.off('event', onEvent); this.app.scrapper.off("event", onEvent);
clearInterval(tick); clearInterval(tick);
}; };
} }
async execute(args: {}): Promise<void> { async execute(args: {}): Promise<void> {
this.update(); this.update();
} }
} }

View File

@@ -1,12 +1,12 @@
import Report from '../report.js'; import Report from "../report.js";
export default class ServicesReport extends Report<'SERVICES'> { export default class ServicesReport extends Report<"SERVICES"> {
readonly type = 'SERVICES'; readonly type = "SERVICES";
async execute() { async execute() {
const services = this.app.database.db const services = this.app.database.db
.prepare<[], { id: string }>(`SELECT service as id FROM logs GROUP BY service`) .prepare<[], { id: string }>(`SELECT service as id FROM logs GROUP BY service`)
.all(); .all();
for (const service of services) this.send(service); for (const service of services) this.send(service);
} }
} }

View File

@@ -1,115 +1,115 @@
import dayjs from 'dayjs'; import dayjs from "dayjs";
import { EventEmitter } from 'events'; import { EventEmitter } from "events";
import { NostrEvent } from 'nostr-tools'; import { NostrEvent } from "nostr-tools";
import { Debugger } from 'debug'; import { Debugger } from "debug";
import { AbstractRelay, Subscription } from 'nostr-tools/abstract-relay'; import { AbstractRelay, Subscription } from "nostr-tools/abstract-relay";
import { logger } from '../../logger.js'; import { logger } from "../../logger.js";
function stripProtocol(url: string) { function stripProtocol(url: string) {
return url.replace(/^\w+\:\/\//, ''); return url.replace(/^\w+\:\/\//, "");
} }
const DEFAULT_LIMIT = 1000; const DEFAULT_LIMIT = 1000;
export type PubkeyRelayScrapperState = { export type PubkeyRelayScrapperState = {
cursor?: number; cursor?: number;
complete?: boolean; complete?: boolean;
}; };
type EventMap = { type EventMap = {
event: [NostrEvent]; event: [NostrEvent];
chunk: [{ count: number; cursor: number }]; chunk: [{ count: number; cursor: number }];
}; };
export default class PubkeyRelayScrapper extends EventEmitter<EventMap> { export default class PubkeyRelayScrapper extends EventEmitter<EventMap> {
pubkey: string; pubkey: string;
relay: AbstractRelay; relay: AbstractRelay;
log: Debugger; log: Debugger;
running = false; running = false;
error?: Error; error?: Error;
state: PubkeyRelayScrapperState = {}; state: PubkeyRelayScrapperState = {};
get cursor() { get cursor() {
return this.state.cursor || dayjs().unix(); return this.state.cursor || dayjs().unix();
} }
set cursor(v: number) { set cursor(v: number) {
this.state.cursor = v; this.state.cursor = v;
} }
get complete() { get complete() {
return this.state.complete || false; return this.state.complete || false;
} }
set complete(v: boolean) { set complete(v: boolean) {
this.state.complete = v; this.state.complete = v;
} }
private subscription?: Subscription; private subscription?: Subscription;
constructor(pubkey: string, relay: AbstractRelay, state?: PubkeyRelayScrapperState) { constructor(pubkey: string, relay: AbstractRelay, state?: PubkeyRelayScrapperState) {
super(); super();
this.pubkey = pubkey; this.pubkey = pubkey;
this.relay = relay; this.relay = relay;
if (state) this.state = state; if (state) this.state = state;
this.log = logger.extend('scrapper:' + pubkey + ':' + stripProtocol(relay.url)); this.log = logger.extend("scrapper:" + pubkey + ":" + stripProtocol(relay.url));
} }
async loadNext() { async loadNext() {
// don't run if its already running, complete, or has an error // don't run if its already running, complete, or has an error
if (this.running || this.complete || this.error) return; if (this.running || this.complete || this.error) return;
this.running = true; this.running = true;
// wait for relay connection // wait for relay connection
await this.relay.connect(); await this.relay.connect();
const cursor = this.state.cursor || dayjs().unix(); const cursor = this.state.cursor || dayjs().unix();
this.log(`Requesting from ${dayjs.unix(cursor).format('lll')} (${cursor})`); this.log(`Requesting from ${dayjs.unix(cursor).format("lll")} (${cursor})`);
// return a promise to wait for the subscription to end // return a promise to wait for the subscription to end
return new Promise<void>((res, rej) => { return new Promise<void>((res, rej) => {
let count = 0; let count = 0;
let newCursor = cursor; let newCursor = cursor;
this.subscription = this.relay.subscribe([{ authors: [this.pubkey], until: cursor, limit: DEFAULT_LIMIT }], { this.subscription = this.relay.subscribe([{ authors: [this.pubkey], until: cursor, limit: DEFAULT_LIMIT }], {
onevent: (event) => { onevent: (event) => {
this.emit('event', event); this.emit("event", event);
count++; count++;
newCursor = Math.min(newCursor, event.created_at); newCursor = Math.min(newCursor, event.created_at);
}, },
oneose: () => { oneose: () => {
this.running = false; this.running = false;
// if no events where returned, mark complete // if no events where returned, mark complete
if (count === 0) { if (count === 0) {
// connection closed before events could be returned, ignore complete // connection closed before events could be returned, ignore complete
if (this.subscription?.closed === true) return; if (this.subscription?.closed === true) return;
this.complete = true; this.complete = true;
this.log('Got 0 events, complete'); this.log("Got 0 events, complete");
} else { } else {
this.log(`Got ${count} events and moved cursor to ${dayjs.unix(newCursor).format('lll')} (${newCursor})`); this.log(`Got ${count} events and moved cursor to ${dayjs.unix(newCursor).format("lll")} (${newCursor})`);
} }
this.state.cursor = newCursor - 1; this.state.cursor = newCursor - 1;
this.emit('chunk', { count, cursor: this.cursor }); this.emit("chunk", { count, cursor: this.cursor });
this.subscription?.close(); this.subscription?.close();
res(); res();
}, },
onclose: (reason) => { onclose: (reason) => {
if (reason !== 'closed by caller') { if (reason !== "closed by caller") {
// unexpected close // unexpected close
this.log(`Error: ${reason}`); this.log(`Error: ${reason}`);
this.error = new Error(reason); this.error = new Error(reason);
rej(this.error); rej(this.error);
} }
res(); res();
}, },
}); });
}); });
} }
} }

View File

@@ -1,83 +1,83 @@
import App from '../../app/index.js'; import App from "../../app/index.js";
import { NostrEvent } from 'nostr-tools'; import { NostrEvent } from "nostr-tools";
import { EventEmitter } from 'events'; import { EventEmitter } from "events";
import { Debugger } from 'debug'; import { Debugger } from "debug";
import { getOutboxes } from '@satellite-earth/core/helpers/nostr/mailboxes.js'; import { getOutboxes } from "@satellite-earth/core/helpers/nostr/mailboxes.js";
import PubkeyRelayScrapper, { PubkeyRelayScrapperState } from './pubkey-relay-scrapper.js'; import PubkeyRelayScrapper, { PubkeyRelayScrapperState } from "./pubkey-relay-scrapper.js";
import { logger } from '../../logger.js'; import { logger } from "../../logger.js";
type EventMap = { type EventMap = {
event: [NostrEvent]; event: [NostrEvent];
}; };
export default class PubkeyScrapper extends EventEmitter<EventMap> { export default class PubkeyScrapper extends EventEmitter<EventMap> {
app: App; app: App;
pubkey: string; pubkey: string;
additionalRelays: string[] = []; additionalRelays: string[] = [];
log: Debugger; log: Debugger;
private failed = new Set<string>(); private failed = new Set<string>();
relayScrappers = new Map<string, PubkeyRelayScrapper>(); relayScrappers = new Map<string, PubkeyRelayScrapper>();
constructor(app: App, pubkey: string) { constructor(app: App, pubkey: string) {
super(); super();
this.app = app; this.app = app;
this.pubkey = pubkey; this.pubkey = pubkey;
this.log = logger.extend('scrapper:' + this.pubkey); this.log = logger.extend("scrapper:" + this.pubkey);
} }
async ensureData() { async ensureData() {
// get mailboxes // get mailboxes
this.app.profileBook.loadProfile(this.pubkey); this.app.profileBook.loadProfile(this.pubkey);
const mailboxes = await this.app.addressBook.loadMailboxes(this.pubkey); const mailboxes = await this.app.addressBook.loadMailboxes(this.pubkey);
return { mailboxes }; return { mailboxes };
} }
async loadNext() { async loadNext() {
const { mailboxes } = await this.ensureData(); const { mailboxes } = await this.ensureData();
const outboxes = getOutboxes(mailboxes); const outboxes = getOutboxes(mailboxes);
const relays = [...outboxes, ...this.additionalRelays]; const relays = [...outboxes, ...this.additionalRelays];
const scrappers: PubkeyRelayScrapper[] = []; const scrappers: PubkeyRelayScrapper[] = [];
for (const url of relays) { for (const url of relays) {
if (this.failed.has(url)) continue; if (this.failed.has(url)) continue;
try { try {
let scrapper = this.relayScrappers.get(url); let scrapper = this.relayScrappers.get(url);
if (!scrapper) { if (!scrapper) {
const relay = await this.app.pool.ensureRelay(url); const relay = await this.app.pool.ensureRelay(url);
scrapper = new PubkeyRelayScrapper(this.pubkey, relay); scrapper = new PubkeyRelayScrapper(this.pubkey, relay);
scrapper.on('event', (event) => this.emit('event', event)); scrapper.on("event", (event) => this.emit("event", event));
// load the state from the database // load the state from the database
const state = await this.app.state.getMutableState<PubkeyRelayScrapperState>( const state = await this.app.state.getMutableState<PubkeyRelayScrapperState>(
`${this.pubkey}|${relay.url}`, `${this.pubkey}|${relay.url}`,
{}, {},
); );
if (state) scrapper.state = state.proxy; if (state) scrapper.state = state.proxy;
this.relayScrappers.set(url, scrapper); this.relayScrappers.set(url, scrapper);
} }
scrappers.push(scrapper); scrappers.push(scrapper);
} catch (error) { } catch (error) {
this.failed.add(url); this.failed.add(url);
if (error instanceof Error) this.log(`Failed to create relay scrapper for ${url}`, error.message); if (error instanceof Error) this.log(`Failed to create relay scrapper for ${url}`, error.message);
} }
} }
// call loadNext on the one with the latest cursor // call loadNext on the one with the latest cursor
const incomplete = scrappers const incomplete = scrappers
.filter((s) => !s.complete && !s.running && !s.error) .filter((s) => !s.complete && !s.running && !s.error)
.sort((a, b) => b.cursor - a.cursor); .sort((a, b) => b.cursor - a.cursor);
const next = incomplete[0]; const next = incomplete[0];
if (next) { if (next) {
await next.loadNext(); await next.loadNext();
} }
} }
} }

View File

@@ -1,125 +1,125 @@
import _throttle from 'lodash.throttle'; import _throttle from "lodash.throttle";
import { generateSecretKey } from 'nostr-tools'; import { generateSecretKey } from "nostr-tools";
import EventEmitter from 'events'; import EventEmitter from "events";
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import webPush from 'web-push'; import webPush from "web-push";
import crypto from 'crypto'; import crypto from "crypto";
import fs from 'fs'; import fs from "fs";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
type Secrets = { type Secrets = {
nostrKey: Uint8Array; nostrKey: Uint8Array;
vapidPrivateKey: string; vapidPrivateKey: string;
vapidPublicKey: string; vapidPublicKey: string;
hyperKey: Buffer; hyperKey: Buffer;
i2pPrivateKey?: string; i2pPrivateKey?: string;
i2pPublicKey?: string; i2pPublicKey?: string;
}; };
type RawJson = Partial<{ type RawJson = Partial<{
nostrKey: string; nostrKey: string;
vapidPrivateKey: string; vapidPrivateKey: string;
vapidPublicKey: string; vapidPublicKey: string;
hyperKey: string; hyperKey: string;
i2pPrivateKey?: string; i2pPrivateKey?: string;
i2pPublicKey?: string; i2pPublicKey?: string;
}>; }>;
type EventMap = { type EventMap = {
/** fires when file is loaded */ /** fires when file is loaded */
loaded: []; loaded: [];
/** fires when a field is set */ /** fires when a field is set */
changed: [keyof Secrets, any]; changed: [keyof Secrets, any];
/** fires when file is loaded or changed */ /** fires when file is loaded or changed */
updated: []; updated: [];
saved: []; saved: [];
}; };
export default class SecretsManager extends EventEmitter<EventMap> { export default class SecretsManager extends EventEmitter<EventMap> {
log = logger.extend('SecretsManager'); log = logger.extend("SecretsManager");
protected secrets?: Secrets; protected secrets?: Secrets;
path: string; path: string;
constructor(path: string) { constructor(path: string) {
super(); super();
this.path = path; this.path = path;
} }
get<T extends keyof Secrets>(secret: T): Secrets[T] { get<T extends keyof Secrets>(secret: T): Secrets[T] {
if (!this.secrets) throw new Error('Secrets not loaded'); if (!this.secrets) throw new Error("Secrets not loaded");
return this.secrets[secret]; return this.secrets[secret];
} }
set<T extends keyof Secrets>(secret: T, value: Secrets[T]) { set<T extends keyof Secrets>(secret: T, value: Secrets[T]) {
if (!this.secrets) throw new Error('Secrets not loaded'); if (!this.secrets) throw new Error("Secrets not loaded");
this.secrets[secret] = value; this.secrets[secret] = value;
this.emit('changed', secret, value); this.emit("changed", secret, value);
this.emit('updated'); this.emit("updated");
this.write(); this.write();
} }
read() { read() {
this.log('Loading secrets'); this.log("Loading secrets");
let json: Record<string, any> = {}; let json: Record<string, any> = {};
try { try {
json = JSON.parse(fs.readFileSync(this.path, { encoding: 'utf-8' })); json = JSON.parse(fs.readFileSync(this.path, { encoding: "utf-8" }));
} catch (error) {} } catch (error) {}
let changed = false; let changed = false;
const secrets = {} as Secrets; const secrets = {} as Secrets;
if (!json.nostrKey) { if (!json.nostrKey) {
this.log('Generating new nostr key'); this.log("Generating new nostr key");
secrets.nostrKey = generateSecretKey(); secrets.nostrKey = generateSecretKey();
changed = true; changed = true;
} else secrets.nostrKey = hexToBytes(json.nostrKey); } else secrets.nostrKey = hexToBytes(json.nostrKey);
if (!json.vapidPrivateKey || !json.vapidPublicKey) { if (!json.vapidPrivateKey || !json.vapidPublicKey) {
this.log('Generating new vapid key'); this.log("Generating new vapid key");
const keys = webPush.generateVAPIDKeys(); const keys = webPush.generateVAPIDKeys();
secrets.vapidPrivateKey = keys.privateKey; secrets.vapidPrivateKey = keys.privateKey;
secrets.vapidPublicKey = keys.publicKey; secrets.vapidPublicKey = keys.publicKey;
changed = true; changed = true;
} else { } else {
secrets.vapidPrivateKey = json.vapidPrivateKey; secrets.vapidPrivateKey = json.vapidPrivateKey;
secrets.vapidPublicKey = json.vapidPublicKey; secrets.vapidPublicKey = json.vapidPublicKey;
} }
if (!json.hyperKey) { if (!json.hyperKey) {
this.log('Generating new hyper key'); this.log("Generating new hyper key");
secrets.hyperKey = crypto.randomBytes(32); secrets.hyperKey = crypto.randomBytes(32);
changed = true; changed = true;
} else secrets.hyperKey = Buffer.from(json.hyperKey, 'hex'); } else secrets.hyperKey = Buffer.from(json.hyperKey, "hex");
secrets.i2pPrivateKey = json.i2pPrivateKey; secrets.i2pPrivateKey = json.i2pPrivateKey;
secrets.i2pPublicKey = json.i2pPublicKey; secrets.i2pPublicKey = json.i2pPublicKey;
this.secrets = secrets; this.secrets = secrets;
this.emit('loaded'); this.emit("loaded");
this.emit('updated'); this.emit("updated");
if (changed) this.write(); if (changed) this.write();
} }
write() { write() {
if (!this.secrets) throw new Error('Secrets not loaded'); if (!this.secrets) throw new Error("Secrets not loaded");
this.log('Saving'); this.log("Saving");
const json: RawJson = { const json: RawJson = {
nostrKey: bytesToHex(this.secrets?.nostrKey), nostrKey: bytesToHex(this.secrets?.nostrKey),
vapidPrivateKey: this.secrets.vapidPrivateKey, vapidPrivateKey: this.secrets.vapidPrivateKey,
vapidPublicKey: this.secrets.vapidPublicKey, vapidPublicKey: this.secrets.vapidPublicKey,
hyperKey: this.secrets.hyperKey?.toString('hex'), hyperKey: this.secrets.hyperKey?.toString("hex"),
i2pPrivateKey: this.secrets.i2pPrivateKey, i2pPrivateKey: this.secrets.i2pPrivateKey,
i2pPublicKey: this.secrets.i2pPublicKey, i2pPublicKey: this.secrets.i2pPublicKey,
}; };
fs.writeFileSync(this.path, JSON.stringify(json, null, 2), { encoding: 'utf-8' }); fs.writeFileSync(this.path, JSON.stringify(json, null, 2), { encoding: "utf-8" });
this.emit('saved'); this.emit("saved");
} }
} }

View File

@@ -1,49 +1,49 @@
import { MigrationSet } from '@satellite-earth/core/sqlite'; import { MigrationSet } from "@satellite-earth/core/sqlite";
import { Database } from 'better-sqlite3'; import { Database } from "better-sqlite3";
import { MutableState } from './mutable-state.js'; import { MutableState } from "./mutable-state.js";
const migrations = new MigrationSet('application-state'); const migrations = new MigrationSet("application-state");
migrations.addScript(1, async (db, log) => { migrations.addScript(1, async (db, log) => {
db.prepare( db.prepare(
` `
CREATE TABLE "application_state" ( CREATE TABLE "application_state" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"state" TEXT, "state" TEXT,
PRIMARY KEY("id") PRIMARY KEY("id")
); );
`, `,
).run(); ).run();
log('Created application state table'); log("Created application state table");
}); });
export default class ApplicationStateManager { export default class ApplicationStateManager {
private mutableState = new Map<string, MutableState<any>>(); private mutableState = new Map<string, MutableState<any>>();
database: Database; database: Database;
constructor(database: Database) { constructor(database: Database) {
this.database = database; this.database = database;
} }
async setup() { async setup() {
await migrations.run(this.database); await migrations.run(this.database);
} }
async getMutableState<T extends object>(key: string, initialState: T) { async getMutableState<T extends object>(key: string, initialState: T) {
const cached = this.mutableState.get(key); const cached = this.mutableState.get(key);
if (cached) return cached as MutableState<T>; if (cached) return cached as MutableState<T>;
const state = new MutableState<T>(this.database, key, initialState); const state = new MutableState<T>(this.database, key, initialState);
await state.read(); await state.read();
this.mutableState.set(key, state); this.mutableState.set(key, state);
return state; return state;
} }
async saveAll() { async saveAll() {
for (const [key, state] of this.mutableState) { for (const [key, state] of this.mutableState) {
await state.save(); await state.save();
} }
} }
} }

View File

@@ -1,91 +1,91 @@
import { EventEmitter } from 'events'; import { EventEmitter } from "events";
import { Database } from 'better-sqlite3'; import { Database } from "better-sqlite3";
import _throttle from 'lodash.throttle'; import _throttle from "lodash.throttle";
import { Debugger } from 'debug'; import { Debugger } from "debug";
import { logger } from '../../logger.js'; import { logger } from "../../logger.js";
type EventMap<T> = { type EventMap<T> = {
/** fires when file is loaded */ /** fires when file is loaded */
loaded: [T]; loaded: [T];
/** fires when a field is set */ /** fires when a field is set */
changed: [T, string, any]; changed: [T, string, any];
/** fires when state is loaded or changed */ /** fires when state is loaded or changed */
updated: [T]; updated: [T];
saved: [T]; saved: [T];
}; };
export class MutableState<T extends object> extends EventEmitter<EventMap<T>> { export class MutableState<T extends object> extends EventEmitter<EventMap<T>> {
state?: T; state?: T;
log: Debugger; log: Debugger;
private _proxy?: T; private _proxy?: T;
/** A Proxy object that will automatically save when mutated */ /** A Proxy object that will automatically save when mutated */
get proxy() { get proxy() {
if (!this._proxy) throw new Error('Cant access state before initialized'); if (!this._proxy) throw new Error("Cant access state before initialized");
return this._proxy; return this._proxy;
} }
key: string; key: string;
database: Database; database: Database;
constructor(database: Database, key: string, initialState: T) { constructor(database: Database, key: string, initialState: T) {
super(); super();
this.state = initialState; this.state = initialState;
this.key = key; this.key = key;
this.database = database; this.database = database;
this.log = logger.extend(`State:` + key); this.log = logger.extend(`State:` + key);
this.createProxy(); this.createProxy();
} }
private createProxy() { private createProxy() {
if (!this.state) return; if (!this.state) return;
return (this._proxy = new Proxy(this.state, { return (this._proxy = new Proxy(this.state, {
get(target, prop, receiver) { get(target, prop, receiver) {
return Reflect.get(target, prop, receiver); return Reflect.get(target, prop, receiver);
}, },
set: (target, p, newValue, receiver) => { set: (target, p, newValue, receiver) => {
Reflect.set(target, p, newValue, receiver); Reflect.set(target, p, newValue, receiver);
this.emit('changed', target as T, String(p), newValue); this.emit("changed", target as T, String(p), newValue);
this.emit('updated', target as T); this.emit("updated", target as T);
this.throttleSave(); this.throttleSave();
return newValue; return newValue;
}, },
})); }));
} }
private throttleSave = _throttle(this.save.bind(this), 30_000); private throttleSave = _throttle(this.save.bind(this), 30_000);
async read() { async read() {
const row = await this.database const row = await this.database
.prepare<[string], { id: string; state: string }>(`SELECT id, state FROM application_state WHERE id=?`) .prepare<[string], { id: string; state: string }>(`SELECT id, state FROM application_state WHERE id=?`)
.get(this.key); .get(this.key);
const state: T | undefined = row ? (JSON.parse(row.state) as T) : undefined; const state: T | undefined = row ? (JSON.parse(row.state) as T) : undefined;
if (state && this.state) { if (state && this.state) {
Object.assign(this.state, state); Object.assign(this.state, state);
this.log('Loaded'); this.log("Loaded");
} }
if (!this.state) throw new Error(`Missing initial state for ${this.key}`); if (!this.state) throw new Error(`Missing initial state for ${this.key}`);
this.createProxy(); this.createProxy();
if (this.state) { if (this.state) {
this.emit('loaded', this.state); this.emit("loaded", this.state);
this.emit('updated', this.state); this.emit("updated", this.state);
} }
} }
async save() { async save() {
if (!this.state) return; if (!this.state) return;
await this.database await this.database
.prepare<[string, string]>(`INSERT OR REPLACE INTO application_state (id, state) VALUES (?, ?)`) .prepare<[string, string]>(`INSERT OR REPLACE INTO application_state (id, state) VALUES (?, ?)`)
.run(this.key, JSON.stringify(this.state)); .run(this.key, JSON.stringify(this.state));
this.log('Saved'); this.log("Saved");
this.emit('saved', this.state); this.emit("saved", this.state);
} }
} }

View File

@@ -1,92 +1,92 @@
import { RawData, WebSocket } from 'ws'; import { RawData, WebSocket } from "ws";
import { IncomingMessage } from 'http'; import { IncomingMessage } from "http";
import { logger } from '../../logger.js'; import { logger } from "../../logger.js";
import OutboundProxyWebSocket from '../network/outbound/websocket.js'; import OutboundProxyWebSocket from "../network/outbound/websocket.js";
import { isHexKey } from 'applesauce-core/helpers'; import { isHexKey } from "applesauce-core/helpers";
import App from '../../app/index.js'; import App from "../../app/index.js";
export default class Switchboard { export default class Switchboard {
private app: App; private app: App;
private log = logger.extend('Switchboard'); private log = logger.extend("Switchboard");
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
public handleConnection(downstream: WebSocket, req: IncomingMessage) { public handleConnection(downstream: WebSocket, req: IncomingMessage) {
let upstream: WebSocket | undefined; let upstream: WebSocket | undefined;
const handleMessage = async (message: RawData) => { const handleMessage = async (message: RawData) => {
try { try {
// Parse JSON from the raw buffer // Parse JSON from the raw buffer
const data = JSON.parse(typeof message === 'string' ? message : message.toString('utf-8')); const data = JSON.parse(typeof message === "string" ? message : message.toString("utf-8"));
if (!Array.isArray(data)) throw new Error('Message is not an array'); if (!Array.isArray(data)) throw new Error("Message is not an array");
if (data[0] === 'PROXY' && data[1]) { if (data[0] === "PROXY" && data[1]) {
let addresses: string[]; let addresses: string[];
if (isHexKey(data[1])) { if (isHexKey(data[1])) {
addresses = await this.app.gossip.lookup(data[1]); addresses = await this.app.gossip.lookup(data[1]);
} else addresses = [data[1]]; } else addresses = [data[1]];
if (addresses.length === 0) { if (addresses.length === 0) {
downstream.send(JSON.stringify(['PROXY', 'ERROR', 'Lookup failed'])); downstream.send(JSON.stringify(["PROXY", "ERROR", "Lookup failed"]));
return; return;
} }
this.app.relay.disconnectSocket(downstream); this.app.relay.disconnectSocket(downstream);
downstream.send(JSON.stringify(['PROXY', 'CONNECTING'])); downstream.send(JSON.stringify(["PROXY", "CONNECTING"]));
let error: Error | undefined = undefined; let error: Error | undefined = undefined;
for (const address of addresses) { for (const address of addresses) {
try { try {
upstream = new OutboundProxyWebSocket(address); upstream = new OutboundProxyWebSocket(address);
// wait for connection // wait for connection
await new Promise<void>((res, rej) => { await new Promise<void>((res, rej) => {
upstream?.once('open', () => res()); upstream?.once("open", () => res());
upstream?.once('error', (error) => rej(error)); upstream?.once("error", (error) => rej(error));
}); });
this.log(`Proxy connection to ${address}`); this.log(`Proxy connection to ${address}`);
// clear last error // clear last error
error = undefined; error = undefined;
// Forward from client to target relay // Forward from client to target relay
downstream.on('message', (message, isBinary) => { downstream.on("message", (message, isBinary) => {
upstream?.send(message, { binary: isBinary }); upstream?.send(message, { binary: isBinary });
}); });
// Forward back from target relay to client // Forward back from target relay to client
upstream.on('message', (message, isBinary) => { upstream.on("message", (message, isBinary) => {
downstream.send(message, { binary: isBinary }); downstream.send(message, { binary: isBinary });
}); });
// connect the close events // connect the close events
upstream.on('close', () => downstream.close()); upstream.on("close", () => downstream.close());
downstream.on('close', () => upstream?.close()); downstream.on("close", () => upstream?.close());
// tell downstream its connected // tell downstream its connected
downstream.send(JSON.stringify(['PROXY', 'CONNECTED'])); downstream.send(JSON.stringify(["PROXY", "CONNECTED"]));
// Step away from the connection // Step away from the connection
downstream.off('message', handleMessage); downstream.off("message", handleMessage);
} catch (err) { } catch (err) {
upstream = undefined; upstream = undefined;
if (err instanceof Error) error = err; if (err instanceof Error) error = err;
} }
} }
// send the error back if we failed to connect to any address // send the error back if we failed to connect to any address
if (error) downstream.send(JSON.stringify(['PROXY', 'ERROR', error.message])); if (error) downstream.send(JSON.stringify(["PROXY", "ERROR", error.message]));
} }
} catch (err) { } catch (err) {
this.log('Failed to handle message', err); this.log("Failed to handle message", err);
} }
}; };
downstream.on('message', handleMessage); downstream.on("message", handleMessage);
this.app.relay.handleConnection(downstream, req); this.app.relay.handleConnection(downstream, req);
} }
} }

6
src/polyfill.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useWebSocketImplementation } from "nostr-tools/relay";
import OutboundProxyWebSocket from "./modules/network/outbound/websocket.js";
// @ts-expect-error
global.WebSocket = OutboundProxyWebSocket;
useWebSocketImplementation(OutboundProxyWebSocket);

View File

@@ -1,19 +1,19 @@
import HyperDHT from 'hyperdht'; import HyperDHT from "hyperdht";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
const log = logger.extend('HyperDHT'); const log = logger.extend("HyperDHT");
let node: HyperDHT | undefined; let node: HyperDHT | undefined;
export function getOrCreateNode() { export function getOrCreateNode() {
if (node) return node; if (node) return node;
log('Creating HyperDHT Node'); log("Creating HyperDHT Node");
return (node = new HyperDHT()); return (node = new HyperDHT());
} }
export function destroyNode() { export function destroyNode() {
if (node) { if (node) {
node.destroy(); node.destroy();
node = undefined; node = undefined;
} }
} }

View File

@@ -1,24 +1,24 @@
declare module 'holesail-server' { declare module "holesail-server" {
import HyperDHT, { KeyPair, Server } from 'hyperdht'; import HyperDHT, { KeyPair, Server } from "hyperdht";
type ServeArgs = { type ServeArgs = {
secure?: boolean; secure?: boolean;
buffSeed?: Buffer; buffSeed?: Buffer;
port?: number; port?: number;
address?: string; address?: string;
}; };
export default class HolesailServer { export default class HolesailServer {
dht: HyperDHT; dht: HyperDHT;
server: Server | null; server: Server | null;
seed: Buffer | null; seed: Buffer | null;
keyPair: KeyPair | null; keyPair: KeyPair | null;
buffer: Buffer | null; buffer: Buffer | null;
secure?: boolean; secure?: boolean;
keyPairGenerator(buffer?: Buffer): KeyPair; keyPairGenerator(buffer?: Buffer): KeyPair;
serve(args: ServeArgs, callback?: () => void): void; serve(args: ServeArgs, callback?: () => void): void;
destroy(): 0; destroy(): 0;
getPublicKey(): string; getPublicKey(): string;
} }
} }

View File

@@ -1,38 +1,38 @@
declare module 'hyperdht' { declare module "hyperdht" {
import type { Socket } from 'net'; import type { Socket } from "net";
import type EventEmitter from 'events'; import type EventEmitter from "events";
class NoiseStreamSocket extends Socket { class NoiseStreamSocket extends Socket {
remotePublicKey: Buffer; remotePublicKey: Buffer;
} }
export class Server extends EventEmitter<{ export class Server extends EventEmitter<{
listening: []; listening: [];
connection: [NoiseStreamSocket]; connection: [NoiseStreamSocket];
}> { }> {
address(): { host: string; port: string; publicKey: Buffer } | null; address(): { host: string; port: string; publicKey: Buffer } | null;
listen(keyPair: KeyPair): Promise<void>; listen(keyPair: KeyPair): Promise<void>;
} }
type KeyPair = { type KeyPair = {
publicKey: Buffer; publicKey: Buffer;
secretKey: Buffer; secretKey: Buffer;
}; };
export default class HyperDHT { export default class HyperDHT {
constructor(opts?: { keyPair: KeyPair; bootstrap?: string[] }); constructor(opts?: { keyPair: KeyPair; bootstrap?: string[] });
createServer( createServer(
opts?: { opts?: {
firewall?: (removePublicKey: Buffer, remoteHandshakePayload: any) => boolean; firewall?: (removePublicKey: Buffer, remoteHandshakePayload: any) => boolean;
}, },
onconnection?: (socket: NoiseStreamSocket) => void, onconnection?: (socket: NoiseStreamSocket) => void,
): Server; ): Server;
destroy(opts?: { force: boolean }): Promise<void>; destroy(opts?: { force: boolean }): Promise<void>;
connect(host: Buffer, opts?: { reusableSocket: boolean }): Socket; connect(host: Buffer, opts?: { reusableSocket: boolean }): Socket;
static keyPair(seed?: Buffer): KeyPair; static keyPair(seed?: Buffer): KeyPair;
} }
} }

View File

@@ -1,5 +1,5 @@
declare module 'streamx' { declare module "streamx" {
import { Duplex, Stream } from 'stream'; import { Duplex, Stream } from "stream";
export function pipeline(...streams: Stream[]): Duplex; export function pipeline(...streams: Stream[]): Duplex;
} }