1052 lines
33 KiB
JavaScript
1052 lines
33 KiB
JavaScript
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 = process.env.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,
|
|
);
|
|
|
|
// 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.
|
|
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: push,
|
|
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 <name>".
|
|
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 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;
|
|
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,
|
|
}),
|
|
);
|
|
};
|
|
|
|
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 <head> of every rendered page: the client-side
|
|
// stack (htmx/hyperscript/tailwind) plus the in-page editor overlay.
|
|
function headChrome(headInjection) {
|
|
return `
|
|
<script src="/js/hyperscript.js"></script>
|
|
<script src="/js/tailwind.js"></script>
|
|
<script src="/js/htmx.js"></script>
|
|
<script src="/js/ace/ace.js"></script>
|
|
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/ws.js"></script>
|
|
<script src="/js/bliss_inspector.js"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
corePlugins: {
|
|
preflight: false
|
|
}
|
|
}
|
|
</script>
|
|
<style> body { margin: 0; }</style>
|
|
${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 <body>.
|
|
function decoratePage(html, { headInjection, source } = {}) {
|
|
const $ = cheerio.load(html);
|
|
let head = $("head");
|
|
if (head.length === 0) {
|
|
$("html").prepend("<head></head>");
|
|
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 <head> in an HTMX response is discarded by the browser's fragment
|
|
// parser, and HTMX does not discover a top-level <style hx-swap-oob>. The div
|
|
// survives long enough for HTMX to append its children to the host's real
|
|
// #head; the browser then keeps the valid style/link/meta children there.
|
|
function headOobSwaps(headHtml, headInjection) {
|
|
const $ = cheerio.load(
|
|
`<head>${headHtml || ""}${headInjection || ""}</head>`,
|
|
);
|
|
const contents = $("head").html();
|
|
return contents ? `<div hx-swap-oob="beforeend:#head">${contents}</div>` : "";
|
|
}
|
|
|
|
// HTMX partial: stamp provenance on the swapped-in body elements (an OOB swap
|
|
// loses its own attrs, so we descend into its children), then carry the child
|
|
// structure's rendered head and configured head injection into the host page.
|
|
function decorateFragment(html, { headInjection, source } = {}) {
|
|
// Parse as a document so browser-valid top-level styles and links are
|
|
// collected into head. `cheerio.load(..., null, false)` leaves those nodes as
|
|
// fragment siblings, which makes them get swapped into body and not apply.
|
|
const $ = cheerio.load(html);
|
|
const body = $("body");
|
|
const attrs = blissAttrs(source);
|
|
if (attrs) {
|
|
const targets = [];
|
|
for (const child of body.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 body.html() + headOobSwaps($("head").html(), headInjection);
|
|
}
|
|
|
|
// `res.render()` commonly returns a complete document even when it is serving
|
|
// an HTMX request. HTMX swaps that document's body into the current page, so
|
|
// preserve the template provenance from its body on every swapped root before
|
|
// reducing it to a fragment. Without this, templates from an hx-get child
|
|
// appear to belong to the outer page; saving `message-raw` in /sheepgpt then
|
|
// incorrectly refreshes /sheepgpt instead of the embedded /chat instance.
|
|
function fragmentFromPage(html) {
|
|
const $ = cheerio.load(html);
|
|
const body = $("body");
|
|
const templateAttrs = Object.fromEntries(
|
|
Object.entries(body[0]?.attribs || {}).filter(([name]) =>
|
|
name.startsWith("data-bliss-template"),
|
|
),
|
|
);
|
|
if (Object.keys(templateAttrs).length) {
|
|
for (const child of body.children().toArray()) {
|
|
Object.assign(child.attribs, templateAttrs);
|
|
}
|
|
}
|
|
return body.html();
|
|
}
|
|
|
|
// 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("<html>") || lower.startsWith("<!doctype");
|
|
if (!fragment) {
|
|
return decoratePage(html, { headInjection, source });
|
|
}
|
|
const fragmentHtml = isFullDoc ? fragmentFromPage(html) : html;
|
|
if (source) return decorateFragment(fragmentHtml, { headInjection, source });
|
|
return fragmentHtml;
|
|
}
|
|
|
|
// One prefix-bound route() helper per structure, shared by every render site.
|
|
function makeRoute(structure) {
|
|
return (url) => withPrefix(structure.route_prefix, url);
|
|
}
|
|
|
|
function renderTemplate(structureId, templateName, context) {
|
|
return model.getTemplater(structureId).render(templateName, context);
|
|
}
|
|
|
|
// Re-render one saved template instance with the JSON-safe values captured on
|
|
// that instance when the page was first produced. This is deliberately a
|
|
// platform endpoint rather than a user route: editing a template must not
|
|
// replay a route handler (and its side effects) just to update the markup.
|
|
app.post("/_bliss/render-template", (req, res) => {
|
|
const structureId = String(req.body?.structureId ?? "");
|
|
const templateId = String(req.body?.templateId ?? "");
|
|
const template = model.getTemplate(templateId);
|
|
if (!template || String(template.structure_id) !== structureId) {
|
|
return res.status(404).send("Template not found");
|
|
}
|
|
|
|
const structure = model.getStructure(structureId);
|
|
const savedContext = model.getInspectableTemplateContext(
|
|
req.body?.contextId,
|
|
templateId,
|
|
);
|
|
if (!savedContext)
|
|
return res.status(410).send("Template context expired; refresh the page");
|
|
const context = { ...savedContext };
|
|
context.route = makeRoute(structure);
|
|
return res
|
|
.type("html")
|
|
.send(renderTemplate(structureId, template.name, 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, templateName = null) {
|
|
const structure = model.getStructure(route.structure_id);
|
|
const template = templateName
|
|
? model.getTemplateByName(route.structure_id, templateName)
|
|
: null;
|
|
return {
|
|
structureId: route.structure_id,
|
|
structureName: structure.name,
|
|
routeId: route.id,
|
|
verb: route.verb,
|
|
path: route.path,
|
|
requestUrl: req.originalUrl,
|
|
templateId: template?.id,
|
|
templateName: template?.name,
|
|
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,
|
|
"<div>henlo <%= it.name %></div>",
|
|
"it = { name: 'templates!' };",
|
|
);
|
|
|
|
return smartRedirect(
|
|
req,
|
|
res,
|
|
`/workshop/${req.params.structure_id}/template/${template}`,
|
|
);
|
|
});
|
|
|
|
app.put("/workshop/:structure_id/template/:template_id", (req, res) => {
|
|
const id = req.params.template_id;
|
|
const template = model.getTemplate(id);
|
|
|
|
model.updateTemplate({
|
|
...template,
|
|
content: req.body.content ?? template.content,
|
|
test_object: req.body.test_object ?? template.test_object,
|
|
});
|
|
|
|
return res.send("good");
|
|
});
|
|
|
|
app.get("/workshop/:structure_id/template/:template_id", (req, res) => {
|
|
const template = model.getTemplate(req.params.template_id);
|
|
|
|
return renderWorkshop(res, "workshop/template", {
|
|
template: template,
|
|
...sidebarStuff(req.params.structure_id),
|
|
});
|
|
});
|
|
|
|
app.get("/workshop/:structure_id/template/:template_id/preview", (req, res) => {
|
|
const template = model.getTemplate(req.params.template_id);
|
|
const struct = model.getStructure(req.params.structure_id);
|
|
|
|
const context = vm.createContext({ it: {} });
|
|
if (template.test_object) {
|
|
vm.runInContext(template.test_object, context);
|
|
}
|
|
|
|
context.it ??= {};
|
|
context.it.route = makeRoute(struct);
|
|
|
|
return res.send(
|
|
decorate(
|
|
renderTemplate(req.params.structure_id, template.name, context.it),
|
|
{
|
|
headInjection: struct.head_injection,
|
|
},
|
|
),
|
|
);
|
|
});
|
|
|
|
app.get("/workshop/:structure_id/route/:id", (req, res) => {
|
|
const route = model.getRoute(req.params.id);
|
|
let routePrefix = model.getStructure(req.params.structure_id).route_prefix;
|
|
let previewUrl = null;
|
|
|
|
if (route["verb"] == "GET") {
|
|
previewUrl = routePrefix
|
|
? path.join(routePrefix || "", route.path)
|
|
: route.path;
|
|
} else {
|
|
previewUrl = `/workshop/${req.params.structure_id}/route/${req.params.id}/preview`;
|
|
}
|
|
|
|
const template =
|
|
route["verb"] == "GET" ? "workshop/route" : "workshop/cud_route";
|
|
|
|
return renderWorkshop(res, template, {
|
|
route: route,
|
|
previewUrl: previewUrl,
|
|
...sidebarStuff(req.params.structure_id),
|
|
});
|
|
});
|
|
|
|
app.get("/workshop/:structure_id/route/:route_id/preview", (req, res) => {
|
|
const route = model.getRoute(req.params.route_id);
|
|
const struct = model.getStructure(req.params.structure_id);
|
|
|
|
const it = { route: makeRoute(struct) };
|
|
|
|
return res.send(
|
|
decorate(eta.renderString(route.scaffold_page_content, it), {
|
|
headInjection: struct.head_injection,
|
|
}),
|
|
);
|
|
});
|
|
|
|
app.get("/workshop/:structure_id/route/:route_id/logs", (req, res) => {
|
|
const since = req.query.since;
|
|
let logs = [];
|
|
if (since != undefined) {
|
|
logs = model.getNewLogsByRoute(req.params.route_id, since);
|
|
} else {
|
|
logs = model.getLogsByRoute(req.params.route_id);
|
|
}
|
|
|
|
const lastId = model.getMostRecentLogIdByRoute(req.params.route_id);
|
|
|
|
return renderWorkshop(res, "workshop/logs", {
|
|
logs: logs,
|
|
structId: req.params.structure_id,
|
|
routeId: req.params.route_id,
|
|
since: lastId,
|
|
});
|
|
});
|
|
|
|
// POST route to handle file upload
|
|
app.post("/workshop/:structure_id/files", async (req, res) => {
|
|
if (!req.files || Object.keys(req.files).length === 0) {
|
|
return res.status(400).send("failed to upload that file");
|
|
}
|
|
|
|
const { structure_id } = req.params;
|
|
const targetFile = req.files.file;
|
|
|
|
try {
|
|
const uploadedFile = await saveFile(structure_id, req, targetFile);
|
|
return res.send(eta.render("workshop/file_detail", { file: uploadedFile }));
|
|
} catch (e) {
|
|
console.log(e);
|
|
res.status(500);
|
|
return res.send(e);
|
|
}
|
|
});
|
|
|
|
function prefixUrlWithHost(req, path) {
|
|
return req.protocol + "://" + req.get("host") + "/" + path;
|
|
}
|
|
|
|
// POST route to handle file upload
|
|
app.get("/workshop/:structure_id/files", (req, res) => {
|
|
const { structure_id } = req.params;
|
|
|
|
const files = model.getFilesForStruct(structure_id);
|
|
files.map((it) => {
|
|
it.url = prefixUrlWithHost(req, it.path);
|
|
});
|
|
|
|
return renderWorkshop(res, "workshop/files", {
|
|
files,
|
|
...sidebarStuff(structure_id),
|
|
});
|
|
});
|
|
|
|
app.get("/workshop/:structure_id/settings", (req, res) => {
|
|
return renderWorkshop(res, "workshop/settings", {
|
|
...sidebarStuff(req.params.structure_id),
|
|
});
|
|
});
|
|
|
|
app.put("/workshop/:structure_id/settings", (req, res) => {
|
|
let structId = req.params.structure_id;
|
|
let struct = model.getStructure(structId);
|
|
let routePrefix = req.body.route_prefix;
|
|
|
|
if (routePrefix[0] != "/") {
|
|
routePrefix = "/" + routePrefix;
|
|
}
|
|
if (routePrefix[routePrefix.length - 1] == "/") {
|
|
routePrefix = routePrefix.substring(0, routePrefix.length - 1);
|
|
}
|
|
|
|
model.updateStruct({
|
|
...struct,
|
|
route_prefix: routePrefix,
|
|
head_injection: req.body.head_injection,
|
|
});
|
|
buildRoutes();
|
|
struct = model.getStructure(structId);
|
|
|
|
res.send("success!");
|
|
});
|
|
|
|
app.get("/workshop/:structure_id/new_template_modal", (req, res) => {
|
|
return renderWorkshop(res, "workshop/new_template_modal", {
|
|
structure: model.getStructure(req.params.structure_id),
|
|
});
|
|
});
|
|
|
|
app.get("/workshop/:structure_id/new_route_modal", (req, res) => {
|
|
return renderWorkshop(res, "workshop/new_route_modal", {
|
|
structure: model.getStructure(req.params.structure_id),
|
|
});
|
|
});
|
|
|
|
app.get("/workshop/:structure_id/new_db_modal", (req, res) => {
|
|
return renderWorkshop(res, "workshop/new_db_modal", {
|
|
structure: model.getStructure(req.params.structure_id),
|
|
});
|
|
});
|
|
|
|
app.get("/workshop/:structure_id/clone_modal", (req, res) => {
|
|
const structure = model.getStructure(req.params.structure_id);
|
|
const dbs = model.getDbsForStructure(req.params.structure_id);
|
|
|
|
return renderWorkshop(res, "workshop/clone_structure_modal", {
|
|
structure,
|
|
dbs,
|
|
defaultRoutePrefix: path.join(structure.route_prefix || "", "clone"),
|
|
});
|
|
});
|
|
|
|
function embedHTML(url) {
|
|
return `<div hx-get="${url}" hx-trigger="load"></div>`;
|
|
}
|
|
|
|
app.all("*", async (req, res) => {
|
|
const req_path = req.path;
|
|
const verb = req.method;
|
|
|
|
try {
|
|
const routeMatch = findRuntimeRoute(verb, req_path);
|
|
|
|
if (routeMatch) {
|
|
req.params = routeMatch.params;
|
|
const route = routeMatch.route;
|
|
|
|
// Minimal, handcrafted view of the logged-in Bliss user for user routes.
|
|
// Deliberately NOT the raw session/user object — just {id, username} —
|
|
// so structures can greet whoever is logged in without exposing internals.
|
|
req.currentUser = currentUserFor(req);
|
|
|
|
const structure = model.getStructure(route.structure_id);
|
|
|
|
try {
|
|
// todo: only add req res to contexst when running the handler()
|
|
// which means moving to runscript instead of runincontext for that
|
|
let context = bootstrapContext(route.structure_id, route.id, {
|
|
req,
|
|
res,
|
|
eta,
|
|
});
|
|
|
|
res.render = (template, context = {}) => {
|
|
context.route = makeRoute(structure);
|
|
res.send(
|
|
decorate(renderTemplate(route.structure_id, template, context), {
|
|
headInjection: structure.head_injection,
|
|
source: sourceFor(route, req, template),
|
|
fragment: req.headers["hx-request"],
|
|
}),
|
|
);
|
|
};
|
|
const handlerScript = new vm.Script(route.handler, {
|
|
filename: `handler_${route.id}.js`,
|
|
});
|
|
// Prepare the execution code that invokes `handler(req, res)` and awaits it
|
|
const executionScript = new vm.Script(`handler(req, res); `, {
|
|
filename: `execution_${route.id}.js`,
|
|
});
|
|
await handlerScript.runInContext(context);
|
|
const result = await executionScript.runInContext(context);
|
|
} catch (e) {
|
|
logError(structure.id, route.id, e);
|
|
return res.status(500).send(formatError(e));
|
|
}
|
|
} else {
|
|
res.status(404).json({ success: false, message: "Path not found" });
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.status(500).json({ success: false, message: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Server is running on http://localhost:${PORT}`);
|
|
});
|