const axios = require("axios"); const bodyParser = require("body-parser"); const compression = require("compression"); const cookieParser = require("cookie-parser"); const cors = require("cors"); const express = require("express"); const { Op } = require("sequelize"); const persist = require("./lib/persist"); ah = require("express-async-handler"); l = require("pino")(); config = require("./config"); networks = []; prod = process.env.NODE_ENV === "production"; fail = msg => { throw new Error(msg); }; challenge = {}; logins = {}; sessions = {}; sockets = {}; convert = persist('data/conversions.json'); SATS = 100000000; toSats = n => parseInt((n * SATS).toFixed()); app = express(); app.enable("trust proxy"); app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.json()); app.use((err, req, res, next) => { const details = { path: req.path, body: req.body, msg: err.message, stack: err.stack }; if (req.user) details.username = req.user.username; l.error("JSON Error: ", details); res.status(500); res.set({ "Cache-Control": "no-cache" }); res.send(err.message); return res.end(); }); app.use(cookieParser()); app.use(cors({ credentials: true, origin: "*" })); app.use(compression()); server = require("http").Server(app); require("./db"); require("./lib/utils"); require("./lib/sockets"); require("./lib/webhooks"); require("./lib/passport"); require("./lib/register"); require("./lib/send"); require("./routes/assets"); require("./routes/info"); require("./routes/users"); // Exclude plugins for minimalist version if (process.env.SCOPE !== "MIN") { if (config.bitcoin) networks.push("bitcoin"); if (config.liquid) networks.push("liquid"); if (config.lna) networks.push("lightning"); require("./lib/rates"); require("./lib/notifications"); if (config.imap) require("./lib/mail"); if (config.lnurl) require("./routes/lnurl"); if (config.mailgun) require("./routes/funding"); require("./routes/invoices"); require("./routes/payments"); require("./routes/swaps"); } // Scope based Route Handling var referralsRouter = require("./routes/referrals.js"); app.use("/referrals", referralsRouter); var adminRouter = require("./routes/admin.js"); app.use("/admin", adminAuth, adminRouter); require("./startup"); app.use((err, req, res, next) => { const details = { path: req.path, body: req.body, msg: err.message, stack: err.stack }; if (req.user) details.username = req.user.username; l.error("Error: ", details); res.status(500); res.set({ "Cache-Control": "no-cache" }); res.send(err.message); }); server.listen(config.port, () => console.log(`CoinOS Server listening on port ${config.port}`) ); process.on("SIGINT", process.exit); process.on("uncaughtException", function(exception) { console.log(exception); }); lob content' class='blob'>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
// Public kind-1 thread context, available before wallet unlock as well as
// inside a wallet. Keep complete event text/tags; truncating a cached event
// would make the content change when a relay answered after the first paint.
const KEY = 'btc-wallet-note-threads';
const MAX_THREADS = 12;
const MAX_EVENTS = 160;
const MAX_QUOTES = 24; // posts quoted inside the thread, so a reload paints them too
const MAX_BYTES = 1_000_000; // JSON code units; at most about 2 MB in storage
// Any kind: a thread can hang off a poll (1068), a picture (20), a comment
// (1111)… — requiring kind 1 left those threads uncached, so every reload
// sat on a spinner while the relays answered.
const validNote = (e) => Number.isSafeInteger(e?.kind) && e.kind >= 0 && typeof e.id === 'string'
&& /^[0-9a-f]{64}$/.test(e.id) && typeof e.pubkey === 'string'
&& /^[0-9a-f]{64}$/.test(e.pubkey) && typeof e.content === 'string'
&& Number.isFinite(e.created_at) && Array.isArray(e.tags)
&& e.tags.every((t) => Array.isArray(t) && t.every((v) => typeof v === 'string'));
// A quote can be any kind of event (a poll, an article…), not only a note.
const validQuote = validNote;
const savedNote = ({ id, pubkey, kind, created_at, content, tags }) => ({ id, pubkey, kind, created_at, content, tags });
const savedProfile = (p) => {
if (!p || typeof p !== 'object') return null;
const name = typeof p.name === 'string' ? p.name.slice(0, 120) : null;
const picture = typeof p.picture === 'string' ? p.picture.slice(0, 1000) : null;
if (!name && !picture) return null;
const out = { name, picture, eventAt: Number.isFinite(p.eventAt) ? p.eventAt : 0, t: Number.isFinite(p.t) ? p.t : 0 };
if (picture && p.thumbFor === picture && typeof p.thumb === 'string'
&& p.thumb.length <= 16000 && /^data:image\/(?:webp|jpeg|png);base64,/.test(p.thumb)) {
out.thumb = p.thumb; out.thumbFor = picture; out.thumbPx = p.thumbPx || 0;
}
return out;
};
const savedCounts = (c) => {
if (!c || typeof c !== 'object') return null;
const count = (n) => Number.isSafeInteger(n) && n >= 0 && n <= 1_000_000_000 ? n : 0;
return { likes: count(c.likes), boosts: count(c.boosts), sats: count(c.sats) };
};
export function createThreadStore(storage) {
if (storage === undefined) { try { storage = globalThis.localStorage; } catch {} }
const read = () => {
try {
const raw = storage?.getItem(KEY);
if (!raw || raw.length > MAX_BYTES) return [];
const data = JSON.parse(raw);
if (!Array.isArray(data)) return [];
return data.filter((c) => validNote(c?.root) && c.rootId === c.root.id
&& Array.isArray(c.replies) && c.replies.length < MAX_EVENTS
&& c.replies.every(validNote)).slice(0, MAX_THREADS);
} catch { return []; }
};
return {
find(id) {
return read().find((c) => c.rootId === id || c.replies.some((e) => e.id === id)) || null;
},
save(thread, focusId, profiles = {}, counts = {}, quotes = []) {
if (!validNote(thread.root)) return;
const events = new Map([thread.root, ...thread.replies.filter(validNote)].map((e) => [e.id, e]));
const keep = new Map([[thread.root.id, thread.root]]);
// The selected reply and every locally known ancestor get priority
// over sibling replies when a large conversation needs trimming.
const visit = (id) => {
if (keep.has(id) || keep.size >= MAX_EVENTS) return;
const e = events.get(id);
if (!e) return;
keep.set(id, e);
for (const t of e.tags) if (t[0] === 'e' && t[3] !== 'mention') visit(t[1]);
};
visit(focusId);
for (const e of events.values()) {
if (keep.size >= MAX_EVENTS) break;
visit(e.id);
}
const previous = read().find((c) => c.rootId === thread.root.id);
const quoteMap = new Map([...(previous?.quotes || []), ...quotes].filter(validQuote).map((e) => [e.id, e]));
const kept = [...quoteMap.values()].slice(-MAX_QUOTES);
const authors = [...new Set([...keep.values(), ...kept].map((e) => e.pubkey))].slice(0, 24 + MAX_QUOTES);
const faces = {};
for (const pk of authors) {
const fresh = savedProfile(profiles[pk]);
const old = savedProfile(previous?.profiles?.[pk]);
const face = !fresh || (old && old.eventAt > fresh.eventAt) ? old : fresh;
if (face === fresh && old?.thumb && !fresh.thumb && old.picture === fresh.picture) {
face.thumb = old.thumb; face.thumbFor = old.thumbFor; face.thumbPx = old.thumbPx;
}
if (face) faces[pk] = face;
}
const tallies = {};
for (const id of keep.keys()) {
const tally = savedCounts(counts[id] || previous?.counts?.[id]);
if (tally) tallies[id] = tally;
}
const entry = { rootId: thread.root.id, root: savedNote(thread.root),
profiles: faces, counts: tallies,
replies: [...keep.values()].filter((e) => e.id !== thread.root.id).map(savedNote),
quotes: kept.map(savedNote) };
const entries = [entry, ...read().filter((c) => c.rootId !== entry.rootId)].slice(0, MAX_THREADS);
let json = JSON.stringify(entries);
while (json.length > MAX_BYTES && entries.length > 1) { entries.pop(); json = JSON.stringify(entries); }
if (json.length > MAX_BYTES) return; // keep the previous cache if this thread alone is too large
try { storage?.setItem(KEY, json); } catch {} // full/disabled storage must not break reading
},
};
}