const fs = require("fs"); const util = require("util"); const vm = require("node:vm"); const path = require("path"); require("dotenv").config(); const express = require("express"); const session = require("express-session"); const fileUpload = require("express-fileupload"); const SQLiteStore = require("better-sqlite3-session-store")(session); const { Eta } = require("eta"); const { match } = require("path-to-regexp"); const bcrypt = require("bcrypt"); const cheerio = require("cheerio"); const webPush = require("web-push"); const { randomUUID } = require("node:crypto"); const app = express(); const _expressWs = require("express-ws")(app); const bodyParser = require("body-parser"); const model = require("./db"); const PORT = process.env.PORT || 3000; const SESSION_SECRET = process.env.SESSION_SECRET || randomUUID(); if (!process.env.SESSION_SECRET) { console.warn( "SESSION_SECRET is not set; using an ephemeral secret and invalidating sessions on restart.", ); } const db = model.db; const wsRouter = express.Router(); let viewpath = path.join(__dirname, "views"); let eta = new Eta({ views: viewpath, cache: false, autoEscape: true }); // A complete, immutable snapshot of the routes currently available to the // runtime. Route-changing writes replace this object only after the new index // has been built, so requests never observe a partially refreshed table. let routeIndex = Object.freeze({ GET: Object.freeze([]), POST: Object.freeze([]), PUT: Object.freeze([]), DELETE: Object.freeze([]), WS: Object.freeze([]), }); const wsConnections = new Map(); const INSPECT_OPTS = { showHidden: false, depth: null, colors: false, compact: false, }; function inspectArgs(args) { return args.map((v) => util.inspect(v, INSPECT_OPTS)).join(" "); } function formatError(e) { return e.stack ? `${e}\n\n${e.stack}` : `${e}`; } // Record a handler error in the structure's logs table. function logError(structureId, routeId, e) { model.createLog(structureId, routeId, formatError(e), true); } app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.json()); app.use(express.static("public")); app.use(fileUpload()); // In production we sit behind an HTTPS-terminating proxy (Cloudflare/nginx) that // forwards plain HTTP to Node, so trust its X-Forwarded-Proto header — otherwise // Express thinks every request is HTTP and a Secure cookie would never be sent. // Locally there's no proxy and no HTTPS, so Secure must stay off or login breaks. const isProd = process.env.NODE_ENV === "production"; if (isProd) app.set("trust proxy", 1); app.use( session({ store: new SQLiteStore({ client: db, expired: { clear: true } }), secret: SESSION_SECRET, resave: false, saveUninitialized: true, // Persistent login: without a maxAge this is a session cookie and iOS/PWAs // drop it on their own schedule, logging people out at random. Browsers cap // persistent cookies to ~400 days, so we set a long window and roll it // forward on every request — visit within 400 days and you stay logged in. rolling: true, cookie: { secure: isProd, // HTTPS-only in prod; off locally so http://localhost works sameSite: "lax", maxAge: 1000 * 60 * 60 * 24 * 400, }, }), ); app.use("/", wsRouter); app.use("/plumbing", require("./plumbing")); // Every workshop route is scoped by :structure_id, so one param guard covers // the whole editor: a private structure 404s for anyone who isn't on its member // list, exactly like its user-facing routes do. app.param("structure_id", (req, res, next, id) => { if (!model.canAccessStructure(req.session.userId, id)) { // Logged-out visitors get a chance to sign in and come back; logged-in // non-members stay 404 so we never confirm the structure exists to them. if (!req.session.userId && req.method === "GET") { return res.redirect("/login?next=" + encodeURIComponent(req.originalUrl)); } return res.status(404).send("Not found"); } next(); }); const vapidPublicKey = process.env.VAPID_PUBLIC_KEY; const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY; // Configure web-push with your VAPID details webPush.setVapidDetails( "mailto:signups@sheepmail.net", // a mailto URL or URL vapidPublicKey, vapidPrivateKey, ); // KaiOS's push service only understands the legacy draft `aesgcm` content // encoding; everyone else (iOS, Chrome, Firefox, modern Safari) uses the // RFC 8291 standard `aes128gcm`, which is web-push's default. The subscription // endpoint hostname tells us which is which, so we pick per-subscription and // keep the standard as the default for anything we don't recognize. const LEGACY_AESGCM_HOSTS = ["push.kaiostech.com", "kai.jiophone.net"]; function pushEncodingFor(subscription) { try { const host = new URL(subscription.endpoint).hostname; return LEGACY_AESGCM_HOSTS.some((h) => host === h || host.endsWith("." + h)) ? "aesgcm" : "aes128gcm"; } catch { return "aes128gcm"; } } // Transparent drop-in for the web-push module handed to structures via // require('push'): identical API (setVapidDetails, generateVAPIDKeys, …) via // the prototype chain, but sendNotification auto-selects the content encoding // from the subscription endpoint unless the caller passed one explicitly. const push = Object.create(webPush); push.sendNotification = function (subscription, payload, options = {}) { return webPush.sendNotification(subscription, payload, { ...options, contentEncoding: options.contentEncoding || pushEncodingFor(subscription), }); }; async function saveFile(structureId, req, uploadedFile, asset = false) { const name = uploadedFile.name; const [mime_type, mime_subtype] = uploadedFile.mimetype.split("/"); // structureId may arrive as a number (route.structure_id is an INTEGER) when // called from a sandboxed handler via require('files').saveFile — the workshop // /files route passes a string param, which masked this. path.join demands // strings, so coerce. const sid = String(structureId); const uploadPath = path.join(__dirname, "public", sid); const storedPath = path.join(sid, uploadedFile.name); fs.mkdirSync(uploadPath, { recursive: true }); await uploadedFile.mv(path.join(uploadPath, name)); let id = model.createFile( structureId, name, storedPath, mime_type, mime_subtype, asset, ); let file = model.getFile(id); file.url = prefixUrlWithHost(req, file.path); return file; } // A console whose log() mirrors to stdout and to the structure's logs table. function makeConsole(structureId, routeId) { return { log: function (...content) { content.forEach((c) => console.log(c)); model.createLog(structureId, routeId, inspectArgs(content)); }, }; } // Evaluate a db's "library" script with `sql`, `console`, and `fetch` bound, // returning its exports. Each library runs in its own context so nothing // leaks between dbs. function runLibrary(sql, librarySource, console) { const libContext = vm.createContext({ sql, console, fetch, module: { exports: null }, }); vm.runInContext(librarySource, libContext); return libContext.module.exports; } // The `require(name)` targets available to a user handler. `memberUserId` is the // user on whose behalf the handler runs. A structure can always mount its own // dbs, but a *foreign* db aliased in from another structure is only mounted if // that structure is reachable by this user — otherwise require('db')(alias) // throws, so aliasing a private db into a public structure can't leak it. (WS // handlers compile with no user; own dbs still mount, foreign private ones don't.) function makeLibs(structureId, console, memberUserId) { const dbs = {}; const forbidden = new Set(); for (let appDb of model.getDbsForStructure(structureId)) { const ownDb = String(appDb.db_struct_id) === String(structureId); if (!ownDb && !model.canAccessStructure(memberUserId, appDb.db_struct_id)) { forbidden.add(appDb.alias); continue; } const sql = model.getDbInstance(appDb.id); dbs[appDb.alias] = { library: runLibrary(sql, appDb.library, console), sql, }; } return { eta: model.getTemplater(structureId), db: (alias) => { if (forbidden.has(alias)) { throw new Error(`db '${alias}' is private; you do not have access`); } return dbs[alias]; }, push: push, notifications: makeNotifications(structureId, memberUserId), files: { saveFile: (...args) => saveFile(structureId, ...args) }, }; } // A small, safe projection of the logged-in user for user-route sandboxes. // Returns null when nobody is logged in. Never leak the password hash or the // raw session — hand structures exactly what they need to say "hi ". function currentUserFor(req) { const id = req && req.session && req.session.userId; if (!id) return null; try { const u = model.getUserById(id); return u ? { id: u.id, username: u.username } : null; } catch (e) { return null; } } function dbForWorkshopRequest(req, res) { const appDb = model.getDbForStructure( req.params.structure_id, req.params.db_id, ); if (!appDb) { res.status(404).send("Not found"); return null; } const ownDb = String(appDb.db_struct_id) === String(req.params.structure_id); if ( !ownDb && !model.canAccessStructure(req.session.userId, appDb.db_struct_id) ) { res.status(404).send("Not found"); return null; } return appDb; } function userIdFrom(value) { if (value == null) return null; if (typeof value === "object" && value.id != null) return Number(value.id); const n = Number(value); return Number.isInteger(n) ? n : null; } function normalizeNotificationUrl(structure, rawUrl) { const route = makeRoute(structure); const url = rawUrl == null || rawUrl === "" ? "/" : String(rawUrl); if (!url.startsWith("/") || url.startsWith("//")) { throw new Error("notification url must be a same-site path"); } const prefix = structure.route_prefix || ""; const scoped = prefix && !url.startsWith(prefix) ? route(url) : url; if (prefix && scoped !== prefix && !scoped.startsWith(prefix + "/")) { throw new Error("notification url must stay inside this structure"); } return scoped; } function normalizeNotificationRecipients(structureId, target) { const explicit = target !== "viewers" && target !== "subscribers"; let ids; if (target == null || target === "viewers" || target === "subscribers") { ids = model.getSubscribedNotificationUserIds(structureId); } else if (target === "members") { ids = model.getStructureMemberUserIds(structureId); } else if (Array.isArray(target)) { ids = target.map(userIdFrom); } else { ids = [userIdFrom(target)]; } if (ids.some((id) => !Number.isInteger(id))) { throw new Error("notification recipient must be a Bliss user"); } const unique = [...new Set(ids)]; const forbidden = unique.filter( (id) => !model.canAccessStructure(id, structureId), ); if (forbidden.length && explicit) { throw new Error("notification recipient cannot access this structure"); } return unique.filter((id) => !forbidden.includes(id)); } function normalizeNotificationExclusions(options = {}) { const ids = []; const collect = (value) => { if (Array.isArray(value)) return value.forEach(collect); const id = userIdFrom(value); if (id != null) ids.push(id); }; collect(options.except); collect(options.exceptUser); collect(options.exceptUserId); return { userIds: new Set(ids), clientId: options.exceptClientId == null ? null : String(options.exceptClientId), }; } function makeNotifications(structureId, actorUserId) { const structure = model.getStructure(structureId); return { async send(options = {}) { const target = options.to ?? options.users ?? options.user ?? "viewers"; const recipientIds = normalizeNotificationRecipients(structureId, target); const except = normalizeNotificationExclusions(options); const eligibleIds = recipientIds.filter((id) => !except.userIds.has(id)); const url = normalizeNotificationUrl(structure, options.url || "/"); const rows = model.getNotificationSubscriptions(structureId, eligibleIds); const payload = JSON.stringify({ structureId: String(structureId), title: String(options.title || structure.name || "Bliss"), body: String(options.body || ""), url, }); let sent = 0; let skipped = 0; await Promise.all( rows.map(async (row) => { if (except.clientId && row.client_id === except.clientId) { skipped += 1; return; } if (!model.canAccessStructure(row.user_id, structureId)) { skipped += 1; return; } try { await push.sendNotification(row.subscription, payload, { contentEncoding: row.content_encoding || undefined, }); sent += 1; } catch (err) { if (err && (err.statusCode === 404 || err.statusCode === 410)) { model.deleteNotificationSubscription(structureId, row.endpoint); } else { throw err; } } }), ); return { sent, skipped, structureId, actorUserId: actorUserId ?? null, }; }, }; } function bootstrapContext(structureId, routeId, initContext, memberUserId) { const structure = model.getStructure(structureId); const console = makeConsole(structureId, routeId); const libs = makeLibs(structureId, console, memberUserId); return vm.createContext({ ...initContext, require: (name) => libs[name], module: { exports: null }, console, vapidPublicKey: vapidPublicKey, fetch: fetch, clearTimeout: clearTimeout, setTimeout: setTimeout, route: makeRoute(structure), }); } function withPrefix(prefix, url) { return prefix ? path.join(prefix, url) : url; } function routeWithPrefix(route) { return withPrefix(route.route_prefix, route.path); } function compileWebsocketHandler(route) { try { // Keep the existing WS handler context for compatibility. Restricting the // exposed capabilities is a separate runtime-sandboxing change. // // WS handlers compile once at startup, not per connection, so there is no // requesting user here. With no user, makeLibs still mounts the structure's // own dbs but blocks foreign private ones. Who may actually *open* the socket // is enforced per-connection below. const context = bootstrapContext(route.structure_id, route.id, { app }); const handler = vm.runInContext(`${route.handler}\n\nhandler;`, context); model.updateRoute({ ...route, error: null }); return handler; } catch (e) { logError(route.structure_id, route.id, e); model.updateRoute({ ...route, error: e.stack }); return null; } } function buildRoutes() { const nextIndex = { GET: [], POST: [], PUT: [], DELETE: [], WS: [], }; for (let route of model.getAllRoutes()) { const p = routeWithPrefix(route); const entry = { matcher: match(p, { decode: decodeURIComponent }), route: Object.freeze({ ...route }), }; if (route.verb === "WS") entry.handler = compileWebsocketHandler(route); nextIndex[route.verb].push(Object.freeze(entry)); } for (const verb of Object.keys(nextIndex)) Object.freeze(nextIndex[verb]); routeIndex = Object.freeze(nextIndex); } function findRuntimeRoute(verb, requestPath) { for (const entry of routeIndex[verb] || []) { const result = entry.matcher(requestPath); if (result) return { ...entry, params: result.params }; } return null; } function websocketRequestPath(req) { // express-ws internally appends `/.websocket` before routing the upgrade. return req.path.replace(/\/?\.websocket$/, "") || "/"; } // One permanent WebSocket endpoint dispatches through the same replaceable // route index as HTTP. Moving or deleting a DB-backed WS route therefore does // not leave an old Express route registered forever. wsRouter.ws("*", (ws, req) => { const found = findRuntimeRoute("WS", websocketRequestPath(req)); if (!found || !found.handler) { return ws.close(1008, "WebSocket route not found"); } const { route } = found; if ( !model.canAccessStructure( req.session && req.session.userId, route.structure_id, ) ) { return ws.close(1008, "WebSocket route not found"); } req.params = found.params; let clients = wsConnections.get(route.id); if (!clients) { clients = new Set(); wsConnections.set(route.id, clients); } clients.add(ws); ws.once("close", () => { clients.delete(ws); if (clients.size === 0) wsConnections.delete(route.id); }); const originalOn = ws.on.bind(ws); ws.on = (event, callback) => originalOn(event, (...args) => { try { callback(...args); } catch (e) { logError(route.structure_id, route.id, e); } }); ws.clients = clients; req.currentUser = currentUserFor(req); ws.render = (template, context = {}) => { const structure = model.getStructure(route.structure_id); context.route = makeRoute(structure); ws.send( decorate(renderTemplate(route.structure_id, template, context), { headInjection: structure.head_injection, source: sourceFor(route, req), fragment: true, }), ); }; req.user = req.currentUser; req.notifications = makeNotifications(route.structure_id, req.session.userId); ws.user = req.currentUser; ws.notifications = req.notifications; try { return found.handler(ws, req); } catch (e) { logError(route.structure_id, route.id, e); return ws.close(1011, "WebSocket handler failed"); } }); model.applyMigrations(); buildRoutes(); // __ __ _______ _______ ______ // | | | || || || _ | // | | | || _____|| ___|| | || // | |_| || |_____ | |___ | |_||_ // | ||_____ || ___|| __ | // | | _____| || |___ | | | | // |_______||_______||_______||___| |_| // Only same-site absolute paths survive as post-login redirect targets, so a // crafted ?next= can't bounce someone to another origin (open redirect). A // leading `//` is protocol-relative and would escape our origin, so reject it. function safeNext(next) { if ( typeof next !== "string" || !next.startsWith("/") || next.startsWith("//") ) { return null; } return next; } app.post("/register", async (req, res) => { const next = safeNext(req.body.next); try { const { username, password } = req.body; const hashedPassword = await bcrypt.hash(password, 10); // 10 is the saltRounds const userId = model.createUser(username, hashedPassword); req.session.userId = userId; res.redirect(next || "/workshop"); } catch (e) { return res.send(eta.render("auth/register", { error: e, next })); } }); app.post("/login", async (req, res) => { const { username, password } = req.body; const next = safeNext(req.body.next); const user = model.getUser(username); if (user && (await bcrypt.compare(password, user.password))) { req.session.userId = user.id; return res.redirect(next || "/"); } return res.send( eta.render("auth/login", { error: "are you sure you entered that right?", next, }), ); }); app.get("/register", async (req, res) => { const next = safeNext(req.query.next); if (req.session.userId) { return res.redirect(next || "/"); } return res.send(eta.render("auth/register", { error: null, next })); }); app.get("/login", async (req, res) => { const next = safeNext(req.query.next); if (req.session.userId) { return res.redirect(next || "/"); } return res.send(eta.render("auth/login", { error: null, next })); }); app.all("/logout", async (req, res) => { return req.session.destroy(() => { res.redirect("/"); }); }); app.get("/_bliss/notifications/key", (req, res) => { if (!vapidPublicKey) return res.status(503).json({ error: "not configured" }); res.json({ publicKey: vapidPublicKey }); }); app.post("/_bliss/notifications/subscribe", (req, res) => { const structureId = Number(req.body?.structure_id); const userId = req.session && req.session.userId; const subscription = req.body?.subscription; if (!userId) return res.status(401).send("log in before enabling notifications"); if (!Number.isInteger(structureId) || !model.getStructure(structureId)) { return res.status(400).send("unknown structure"); } if (!model.canAccessStructure(userId, structureId)) { return res.status(403).send("you cannot access this structure"); } if (!subscription || !subscription.endpoint) { return res.status(400).send("missing push subscription"); } model.upsertNotificationSubscription({ structureId, userId, endpoint: subscription.endpoint, clientId: req.body?.client_id, subscription, contentEncoding: req.body?.content_encoding, }); res.status(201).json({ ok: true }); }); app.get("/_bliss/notification-sw.js", (req, res) => { res.type("application/javascript").set("Cache-Control", "no-cache").send(` const openStructures = new Set(); self.addEventListener("install", (event) => event.waitUntil(self.skipWaiting())); self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); self.addEventListener("message", (event) => { const data = event.data || {}; if (data.type !== "bliss:visibility" || !data.structureId) return; const id = String(data.structureId); if (data.state === "open") { openStructures.add(id); self.registration.getNotifications({ tag: "bliss:" + id }).then((items) => items.forEach((n) => n.close())); } else { openStructures.delete(id); } }); self.addEventListener("push", (event) => { let data = {}; try { data = event.data ? event.data.json() : {}; } catch (_) {} const structureId = String(data.structureId || ""); if (structureId && openStructures.has(structureId)) return; event.waitUntil(self.registration.showNotification(data.title || "Bliss", { body: data.body || "", tag: structureId ? "bliss:" + structureId : "bliss", data: { url: data.url || "/" }, })); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); const target = event.notification.data && event.notification.data.url || "/"; event.waitUntil(clients.matchAll({ type: "window", includeUncontrolled: true }).then((items) => { for (const client of items) { try { const url = new URL(client.url); if (url.pathname === target && "focus" in client) return client.focus(); } catch (_) {} } if (clients.openWindow) return clients.openWindow(target); })); }); `); }); // _ _ _______ ______ ___ _ _______ __ __ _______ _______ // | | _ | || || _ | | | | || || | | || || | // | || || || _ || | || | |_| || _____|| |_| || _ || _ | // | || | | || |_||_ | _|| |_____ | || | | || |_| | // | || |_| || __ || |_ |_____ || || |_| || ___| // | _ || || | | || _ | _____| || _ || || | // |__| |__||_______||___| |_||___| |_||_______||__| |__||_______||___| // // The chrome injected into the of every rendered page: the client-side // stack (htmx/hyperscript/tailwind) plus the in-page editor overlay. function headChrome(headInjection) { return ` ${headInjection || ""} `; } // A `source` is the provenance of a rendered fragment: which structure/route // produced it, and (for GET routes) the snippet that re-embeds it. It becomes // the data-bliss-* attributes the inspector reads. function blissAttrs(source) { if (!source) return null; const attrs = { "data-bliss-route": `/workshop/${source.structureId}/route/${source.routeId}`, "data-bliss-clone": `/workshop/${source.structureId}/clone_modal/`, "data-bliss-structure-id": String(source.structureId), "data-bliss-structure-name": source.structureName, "data-bliss-route-id": String(source.routeId), "data-bliss-route-name": `${source.verb} ${source.path}`, "data-bliss-method": source.verb, "data-bliss-request-url": source.requestUrl, }; if (source.templateId) { attrs["data-bliss-template-id"] = String(source.templateId); attrs["data-bliss-template-name"] = source.templateName; } if (source.copyUrl) attrs["data-bliss-copy"] = source.copyUrl; return attrs; } // Full HTML document: inject the head chrome and stamp provenance on . function decoratePage(html, { headInjection, source } = {}) { const $ = cheerio.load(html); let head = $("head"); if (head.length === 0) { $("html").prepend(""); head = $("head"); } head.attr("id", "head"); head.append(headChrome(headInjection)); const attrs = blissAttrs(source); if (attrs) { for (const [k, v] of Object.entries(attrs)) $("body").attr(k, v); } return $.html(); } // Carry rendered head content through HTMX with a neutral OOB wrapper. A // literal in an HTMX response is discarded by the browser's fragment // parser, and HTMX does not discover a top-level