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 app = express(); const _expressWs = require("express-ws")(app); const bodyParser = require("body-parser"); const model = require("./db"); const PORT = 3000; 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()); app.use( session({ store: new SQLiteStore({ client: db, expired: { clear: true } }), secret: "your secret key", resave: false, saveUninitialized: true, cookie: { secure: false }, }), ); app.use("/", wsRouter); app.use("/plumbing", require("./plumbing")); 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, ); async function saveFile(structureId, req, uploadedFile, asset=false) { const name = uploadedFile.name; const [mime_type, mime_subtype] = uploadedFile.mimetype.split("/"); const uploadPath = path.join(__dirname, "public", structureId); const storedPath = path.join(structureId, 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. function makeLibs(structureId, console) { const dbs = {}; for (let appDb of model.getDbsForStructure(structureId)) { const sql = model.getDbInstance(appDb.id); dbs[appDb.alias] = { library: runLibrary(sql, appDb.library, console), sql }; } return { eta: model.getTemplater(structureId), db: (alias) => dbs[alias], push: webPush, files: { saveFile: (...args) => saveFile(structureId, ...args) }, }; } function bootstrapContext(structureId, routeId, initContext) { const structure = model.getStructure(structureId); const console = makeConsole(structureId, routeId); const libs = makeLibs(structureId, console); 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. 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; 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; 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, }), ); }; 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(); // __ __ _______ _______ ______ // | | | || || || _ | // | | | || _____|| ___|| | || // | |_| || |_____ | |___ | |_||_ // | ||_____ || ___|| __ | // | | _____| || |___ | | | | // |_______||_______||_______||___| |_| app.post("/register", async (req, res) => { 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("/workshop"); } catch (e) { return res.send(eta.render("auth/register", { error: e })); } }); app.post("/login", async (req, res) => { const { username, password } = req.body; const user = model.getUser(username); if (user && (await bcrypt.compare(password, user.password))) { req.session.userId = user.id; return res.redirect("/"); } return res.send( eta.render("auth/login", { error: "are you sure you entered that right?", }), ); }); app.get("/register", async (req, res) => { if (req.session.userId) { return res.redirect("/"); } return res.send(eta.render("auth/register", { error: null })); }); app.get("/login", async (req, res) => { if (req.session.userId) { return res.redirect("/"); } return res.send(eta.render("auth/login", { error: null })); }); app.all("/logout", async (req, res) => { return req.session.destroy(() => { res.redirect("/"); }); }); // _ _ _______ ______ ___ _ _______ __ __ _______ _______ // | | _ | || || _ | | | | || || | | || || | // | || || || _ || | || | |_| || _____|| |_| || _ || _ | // | || | | || |_||_ | _|| |_____ | || | | || |_| | // | || |_| || __ || |_ |_____ || || |_| || ___| // | _ || || | | || _ | _____| || _ || || | // |__| |__||_______||___| |_||___| |_||_______||__| |__||_______||___| // // 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/`, }; 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(); } // HTMX partial: stamp provenance on the swapped-in elements (an oob swap loses // its own attrs, so we descend into its children) and append an out-of-band // so head injection still reaches the page. function decorateFragment(html, { headInjection, source } = {}) { const $ = cheerio.load(html, null, false); const attrs = blissAttrs(source); if (attrs) { const targets = []; for (const child of $.root().children()) { if (child.attribs && child.attribs["hx-swap-oob"]) { for (const grandchild of child.children) { if (grandchild.attribs) targets.push(grandchild); } } else { targets.push(child); } } for (const target of targets) Object.assign(target.attribs, attrs); } return ( $.html() + `${headInjection || ""}` ); } // Single entry point for turning rendered HTML into a response body. A full // document (or any non-htmx request) becomes a decorated page; an htmx partial // carrying provenance becomes a decorated fragment; anything else passes through. function decorate(html, { headInjection, source, fragment } = {}) { const lower = html.toLowerCase(); const isFullDoc = lower.startsWith("") || lower.startsWith(" withPrefix(structure.route_prefix, url); } function renderTemplate(structureId, templateName, context) { return model.getTemplater(structureId).render(templateName, context); } // Provenance for a rendered response: GET routes are re-embeddable, so they // carry the copy-embed snippet; other verbs don't. function sourceFor(route, req) { return { structureId: route.structure_id, routeId: route.id, copyUrl: route.verb === "GET" ? embedHTML(req.originalUrl) : null, }; } app.post("/workshop", (req, res) => { let structId = model.createStructure(req.body.name); return res.redirect("/workshop/" + structId); }); app.get("/workshop", (req, res) => { return renderWorkshop(res, "workshop/index", { structures: model.getStructures(), }); }); function renderWorkshop(res, template, context) { res.send(decorate(eta.render(template, context))); } function smartRedirect(req, res, redirectUrl) { if (req.headers["hx-request"]) { res.set("HX-Redirect", redirectUrl); res.send(); } else { res.redirect(redirectUrl); } } function sidebarStuff(structId) { return { structure: model.getStructure(structId), routes: model.getRoutes(structId), templates: model.getTemplates(structId), dbs: model.getDbsForStructure(structId), }; } app.get("/workshop/:structure_id", (req, res) => { return renderWorkshop(res, "workshop/editor", sidebarStuff(req.params.structure_id)); }); app.post("/workshop/:structure_id/clone", (req, res) => { let routePrefix = req.body.route_prefix; routePrefix = routePrefix[0] == "/" ? routePrefix : "/" + routePrefix; let newStructureId; try { newStructureId = model.cloneStructure( req.params.structure_id, req.body.name, 1, // req.session.userId, routePrefix, req.body.clone_dbs, ); buildRoutes(); } catch (e) { return res.send(e.stack); } return smartRedirect(req, res, `/workshop/${newStructureId}`); }); app.post("/workshop/:structure_id/db", (req, res) => { const dbId = model.createDb(req.params.structure_id, req.body.name); const redirectUrl = `/workshop/${req.params.structure_id}/db/${dbId}`; return smartRedirect(req, res, redirectUrl); }); app.post("/workshop/:structure_id/db/attach", (req, res) => { model.attachDb(req.params.structure_id, req.body.db_id, req.body.alias); const redirectUrl = `/workshop/${req.params.structure_id}/db/${req.body.db_id}`; return smartRedirect(req, res, redirectUrl); }); app.get("/workshop/:structure_id/db/:db_id", (req, res) => { const structdb = model.getDbForStructure( req.params.structure_id, req.params.db_id, ); if (!structdb) { return res.send("uh oh"); } return renderWorkshop(res, "workshop/db_garden", { db: structdb, ...sidebarStuff(req.params.structure_id), }); }); app.post("/workshop/:structure_id/route", (req, res) => { let p = req.body.path; let dummyHandler = "// put your handler code here\n\nfunction handler(req, res) {\n res.send('henlo world') \n}"; if (req.body.verb == "WS") { dummyHandler = `// put your websocket handler code here\n\nfunction handler(ws, req) {\n ws.on('message', function(msg) {\n ws.send(msg);\n })\n}`; } if (p[0] == "/") { p = p.substring(1); } if (p[p.length - 1] == "/") { p = p.substring(0, p.length - 1); } const route = model.createRoute( req.body.verb, p[0] == "/" ? p.substring(1) : "/" + p, req.params.structure_id, dummyHandler, ); // todo optimize by only adding new, don't just rebuild all buildRoutes(); return smartRedirect( req, res, `/workshop/${req.params.structure_id}/route/${route}`, ); }); app.put("/workshop/:structure_id/route/:route_id", (req, res) => { const route = model.getRoute(req.params.route_id); model.updateRoute({ ...route, ...req.body }); if (req.body.scaffold_page) { // TODO: make this send over scaffold page id too someday const latestPage = model.getLatestScaffoldPage(req.params.route_id) model.updateScaffoldPage({ ...latestPage, content: req.body.scaffold_page }) } buildRoutes(); return res.send("good"); }); app.put("/workshop/:structure_id/db/:db_id/library", (req, res) => { try { let dbId = req.params.db_id; let appDb = model.getDb(dbId); model.updateDb({ ...appDb, library: req.body.library }); appDb = model.getDb(dbId); let dbInstance = model.getDbInstance(dbId); let capturedOutput = []; let context = vm.createContext({ module: { exports: null }, sql: dbInstance, fetch, console: { log: (...args) => capturedOutput.push(inspectArgs(args)), }, }); let evaledCode = vm.runInContext(appDb.library, context); let stdout = capturedOutput.join("\n"); return res.send(`${stdout}\n\n> ${evaledCode}`.trim()); } catch (e) { return res.send(`${e}\n\n${e.stack}`); } }); app.post("/workshop/:structure_id/db/:db_id/repl", (req, res) => { try { let dbId = req.params.db_id; let appDb = model.getDb(dbId); let dbInstance = model.getDbInstance(dbId); let capturedOutput = []; let context = vm.createContext({ module: { exports: null }, sql: dbInstance, fetch, console: { log: (...args) => capturedOutput.push(inspectArgs(args)), }, }); const libraryScript = new vm.Script(appDb.library); libraryScript.runInContext(context); context.library = context.module.exports; const replScript = new vm.Script(req.body.code); let evaledCode = JSON.stringify(replScript.runInContext(context)); let stdout = capturedOutput.join("\n"); return res.send(`${stdout}\n\n> ${evaledCode}`.trim()); } catch (e) { return res.send(`${e}\n\n${e.stack}`); } }); app.post("/workshop/:structure_id/template", (req, res) => { let name = req.body.name; const template = model.createTemplate( req.params.structure_id, name, "