make em private
This commit is contained in:
parent
1394ce1e0c
commit
02cdca748d
4 changed files with 123 additions and 37 deletions
103
db.js
103
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) {
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
29
index.js
29
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.
|
||||
|
|
|
|||
14
migrations/006_add_public.sql
Normal file
14
migrations/006_add_public.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -27,9 +27,19 @@
|
|||
<div class="text-xs"></div>
|
||||
<textarea name="head_injection" value="<%= it.structure.route_prefix || "" %>"><%= it.structure.head_injection || "" %></textarea>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<input type="checkbox" id="public" name="public" <%= it.structure.public ? "checked" : "" %>
|
||||
_="on change toggle .hidden on #public-warning">
|
||||
<label for="public">Public (anyone on the open web, no login)</label>
|
||||
</div>
|
||||
<div id="public-warning" class="text-xs text-red-700 pl-1 <%= it.structure.public ? "" : "hidden" %>">
|
||||
⚠️ This puts the structure on the open web — anyone can view it
|
||||
without logging in. Every other structure requires a login by
|
||||
default; only check this if you truly mean to make it public.
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<input type="checkbox" id="private" name="private" <%= it.structure.private ? "checked" : "" %>>
|
||||
<label for="private">Private (only invited members)</label>
|
||||
<label for="private">Members only (invited members, stricter than login)</label>
|
||||
</div>
|
||||
<button id="save-btn" type="submit">Save</button>
|
||||
<div id="result"></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue