diff --git a/db.js b/db.js index ecebe77..db795c3 100644 --- a/db.js +++ b/db.js @@ -126,7 +126,10 @@ function inspectableContext(data) { function annotateTemplateHtml(html, template, data) { const context = inspectableContext(data); const contextId = randomUUID(); - templateContextCache.set(contextId, { templateId: String(template.id), context }); + templateContextCache.set(contextId, { + templateId: String(template.id), + context, + }); function annotate($, element) { const current = $(element).attr("data-bliss-templates"); let templates = []; @@ -147,7 +150,10 @@ function annotateTemplateHtml(html, template, data) { } catch (_) {} } contextIds[String(template.id)] = contextId; - $(element).attr("data-bliss-template-context-ids", JSON.stringify(contextIds)); + $(element).attr( + "data-bliss-template-context-ids", + JSON.stringify(contextIds), + ); if (!$(element).attr("data-bliss-template-id")) { $(element).attr({ "data-bliss-template-id": String(template.id), @@ -207,14 +213,20 @@ function createStructure(name, userId) { return info.lastInsertRowid; // Returns the structure_id of the newly created structure } -// ---- access control (private structures) -------------------------------- +// ---- access control ------------------------------------------------------- // -// A structure is public unless `private` is set. Access to a private structure -// is exactly its member list (structure_members) — a flat set of equals, no -// creator or owner. Anyone on the list can reach it, edit it, manage the list, -// or make it public again. This is the single source of truth the platform -// consults before serving a user route, mounting a db, or opening the workshop -// — never the structure's own code. +// Two independent flags, three tiers of reach: +// +// public = 1 -> anyone, no login (opt-in, with a warning) +// public = 0, private = 0 -> any logged-in user (the default) +// public = 0, private = 1 -> invited members only +// +// So login is required by default; making a structure public is the explicit +// way back onto the open web. `private` layers a stricter members-only gate on +// top: access is then exactly its member list (structure_members) — a flat set +// of equals, no creator or owner. This is the single source of truth the +// platform consults before serving a user route, mounting a db, or opening the +// workshop — never the structure's own code. function isMember(structureId, userId) { return !!db @@ -227,9 +239,10 @@ function isMember(structureId, userId) { function canAccessStructure(userId, structureId) { const s = getStructure(structureId); if (!s) return false; - if (!s.private) return true; - if (userId == null) return false; - return isMember(structureId, userId); + if (s.public) return true; // open web, no login + if (userId == null) return false; // everything else needs a login + if (!s.private) return true; // any logged-in user + return isMember(structureId, userId); // members-only } // Members of a structure, with their usernames, for the settings UI. @@ -271,16 +284,28 @@ function setStructurePrivacy(structureId, isPrivate, actorUserId) { if (isPrivate && actorUserId != null) addMember(structureId, actorUserId); } +// Put a structure on the open web (public = 1) or take it back off (login +// required). Independent of the members-only `private` flag; public wins in +// canAccessStructure, so a public structure is reachable regardless of private. +function setStructurePublic(structureId, isPublic) { + const s = getStructure(structureId); + if (!s) return; + db.prepare("UPDATE structures SET public = ? WHERE id = ?").run( + isPublic ? 1 : 0, + structureId, + ); +} + function createRoute(verb, path, structureId, handler) { path = encodeURI(path); const stmt = db.prepare( - "INSERT INTO routes (verb, path, structure_id, handler) VALUES (?, ?, ?, ?)" + "INSERT INTO routes (verb, path, structure_id, handler) VALUES (?, ?, ?, ?)", ); const info = stmt.run(verb, path, structureId, handler); const routeId = info.lastInsertRowid; // Get the newly created route ID - if (verb !== 'GET') { + if (verb !== "GET") { createScaffoldPage(routeId); } @@ -306,13 +331,17 @@ const ROUTE_SELECT = ` function getRoutes(structureId) { return db - .prepare(`${ROUTE_SELECT} WHERE r.structure_id = ? ORDER BY sp.created_at DESC, sp2.created_at DESC`) + .prepare( + `${ROUTE_SELECT} WHERE r.structure_id = ? ORDER BY sp.created_at DESC, sp2.created_at DESC`, + ) .all(structureId); } function getRoute(routeId) { return db - .prepare(`${ROUTE_SELECT} WHERE r.id = ? ORDER BY sp.created_at DESC, sp2.created_at DESC LIMIT 1`) + .prepare( + `${ROUTE_SELECT} WHERE r.id = ? ORDER BY sp.created_at DESC, sp2.created_at DESC LIMIT 1`, + ) .get(routeId); } @@ -321,7 +350,9 @@ function update(table, fields, obj) { const placeholders = fields.map((field) => `${field} = ?`).join(", "); const values = fields.map((field) => obj[field]); values.push(obj.id); - return db.prepare(`UPDATE ${table} SET ${placeholders} WHERE id = ?`).run(...values); + return db + .prepare(`UPDATE ${table} SET ${placeholders} WHERE id = ?`) + .run(...values); } // ---- version history (append-only) -------------------------------------- @@ -337,7 +368,11 @@ function snapshotFor(entityType, obj) { case "route": return { verb: obj.verb, path: obj.path, handler: obj.handler }; case "template": - return { name: obj.name, content: obj.content, test_object: obj.test_object }; + return { + name: obj.name, + content: obj.content, + test_object: obj.test_object, + }; case "db": return { name: obj.name, library: obj.library }; default: @@ -377,7 +412,11 @@ function getVersion(versionId) { } function updateRoute(route) { - update("routes", ["verb", "path", "structure_id", "handler", "updated_at", "error"], route); + update( + "routes", + ["verb", "path", "structure_id", "handler", "updated_at", "error"], + route, + ); recordVersion("route", route.id, route.structure_id, route); } @@ -689,18 +728,25 @@ function getMostRecentLogIdByRoute(routeId) { } function buildScaffoldUrl(endpoint, urlParams, queryString) { - let populatedUrl = endpoint.replace(/:([^/]+)/g, () => urlParams.shift() || ''); + let populatedUrl = endpoint.replace( + /:([^/]+)/g, + () => urlParams.shift() || "", + ); return queryString ? `${populatedUrl}?${queryString}` : populatedUrl; } -function createScaffoldPage(routeId, content=null) { +function createScaffoldPage(routeId, content = null) { if (!content) { - const route = getRoute(routeId) - const endpoint = buildScaffoldUrl(route.path, route.url_params, route.query_params); + const route = getRoute(routeId); + const endpoint = buildScaffoldUrl( + route.path, + route.url_params, + route.query_params, + ); content = getDefaultScaffoldContentByVerb(endpoint, route.verb); } const stmt = db.prepare( - "INSERT INTO scaffold_pages (route_id, content) VALUES (?, ?)" + "INSERT INTO scaffold_pages (route_id, content) VALUES (?, ?)", ); const info = stmt.run(routeId, content); return info.lastInsertRowid; // Returns the scaffold_page id of the newly created scaffold page @@ -708,7 +754,7 @@ function createScaffoldPage(routeId, content=null) { function getLatestScaffoldPage(routeId) { const stmt = db.prepare( - "SELECT * FROM scaffold_pages WHERE route_id = ? ORDER BY created_at DESC LIMIT 1" + "SELECT * FROM scaffold_pages WHERE route_id = ? ORDER BY created_at DESC LIMIT 1", ); return stmt.get(routeId); } @@ -819,11 +865,9 @@ function generateWSPage(endpoint) { function getDefaultScaffoldContentByVerb(url, verb) { if (verb == "POST") { return generatePostForm(url); - } - else if (verb == "PUT" || verb == "DELETE") { + } else if (verb == "PUT" || verb == "DELETE") { return generateModifyForm(url, verb); - } - else if (verb == "WS") { + } else if (verb == "WS") { return generateWSPage(url); } } @@ -845,6 +889,7 @@ module.exports = { addMember, removeMember, setStructurePrivacy, + setStructurePublic, createRoute, getRoutes, getRoute, @@ -877,5 +922,5 @@ module.exports = { getMostRecentLogIdByRoute, createScaffoldPage, getLatestScaffoldPage, - updateScaffoldPage + updateScaffoldPage, }; diff --git a/index.js b/index.js index e86a2b7..3fa42ff 100644 --- a/index.js +++ b/index.js @@ -320,7 +320,12 @@ wsRouter.ws("*", (ws, req) => { } const { route } = found; - if (!model.canAccessStructure(req.session && req.session.userId, route.structure_id)) { + if ( + !model.canAccessStructure( + req.session && req.session.userId, + route.structure_id, + ) + ) { return ws.close(1008, "WebSocket route not found"); } req.params = found.params; @@ -382,7 +387,11 @@ buildRoutes(); // 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("//")) { + if ( + typeof next !== "string" || + !next.startsWith("/") || + next.startsWith("//") + ) { return null; } return next; @@ -1000,14 +1009,18 @@ app.put("/workshop/:structure_id/settings", (req, res) => { routePrefix = routePrefix.substring(0, routePrefix.length - 1); } - // An unchecked checkbox sends no field at all, so absence means "public". + // An unchecked checkbox sends no field at all, so absence means "off". const wantPrivate = req.body.private === "on" || req.body.private === "1"; + const wantPublic = req.body.public === "on" || req.body.public === "1"; if (wantPrivate && !req.session.userId) { return res .status(403) - .send("log in before making a structure private, or you'll lock everyone out"); + .send( + "log in before making a structure private, or you'll lock everyone out", + ); } model.setStructurePrivacy(structId, wantPrivate, req.session.userId); + model.setStructurePublic(structId, wantPublic); model.updateStruct({ ...struct, @@ -1093,9 +1106,13 @@ app.all("*", async (req, res) => { // logged-in non-members still 404. if (!model.canAccessStructure(req.session.userId, route.structure_id)) { if (!req.session.userId && verb === "GET") { - return res.redirect("/login?next=" + encodeURIComponent(req.originalUrl)); + return res.redirect( + "/login?next=" + encodeURIComponent(req.originalUrl), + ); } - return res.status(404).json({ success: false, message: "Path not found" }); + return res + .status(404) + .json({ success: false, message: "Path not found" }); } // Minimal, handcrafted view of the logged-in Bliss user for user routes. diff --git a/migrations/006_add_public.sql b/migrations/006_add_public.sql new file mode 100644 index 0000000..2df8fcb --- /dev/null +++ b/migrations/006_add_public.sql @@ -0,0 +1,14 @@ +-- Login-required by default. Until now a structure was reachable by anyone +-- unless it was made members-only (`private = 1`). We flip that baseline: every +-- structure now requires a logged-in user to view, and `public = 1` is the +-- explicit opt-out that puts a structure back on the open web (no login). +-- +-- The two flags are independent axes, resolved by canAccessStructure in db.js: +-- public = 1 -> anyone, no login (opt-in, with a warning) +-- public = 0, private = 0 -> any logged-in user (the new default) +-- public = 0, private = 1 -> invited members only (unchanged) +ALTER TABLE structures ADD COLUMN public INTEGER NOT NULL DEFAULT 0; + +-- squirtify (prod id 28) was live on the open web under the old public-by-default +-- world; keep it reachable without login so nothing breaks for its users. +UPDATE structures SET public = 1 WHERE id = 28; diff --git a/views/workshop/settings.eta b/views/workshop/settings.eta index 0f2a6db..239be31 100644 --- a/views/workshop/settings.eta +++ b/views/workshop/settings.eta @@ -27,9 +27,19 @@
+