private structures
This commit is contained in:
parent
1f7dbf21e0
commit
e179814859
6 changed files with 243 additions and 18 deletions
72
db.js
72
db.js
|
|
@ -203,10 +203,74 @@ function createStructure(name, userId) {
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
"INSERT INTO structures (name, user_id) VALUES (?, ?)",
|
"INSERT INTO structures (name, user_id) VALUES (?, ?)",
|
||||||
);
|
);
|
||||||
const info = stmt.run(name, userId);
|
const info = stmt.run(name, userId ?? null);
|
||||||
return info.lastInsertRowid; // Returns the structure_id of the newly created structure
|
return info.lastInsertRowid; // Returns the structure_id of the newly created structure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- access control (private structures) --------------------------------
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
function isMember(structureId, userId) {
|
||||||
|
return !!db
|
||||||
|
.prepare(
|
||||||
|
"SELECT 1 FROM structure_members WHERE structure_id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.get(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Members of a structure, with their usernames, for the settings UI.
|
||||||
|
function getMembers(structureId) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT sv.user_id, u.username, sv.created_at
|
||||||
|
FROM structure_members sv
|
||||||
|
JOIN users u ON u.id = sv.user_id
|
||||||
|
WHERE sv.structure_id = ?
|
||||||
|
ORDER BY sv.created_at ASC`,
|
||||||
|
)
|
||||||
|
.all(structureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMember(structureId, userId) {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR IGNORE INTO structure_members (structure_id, user_id) VALUES (?, ?)",
|
||||||
|
).run(structureId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMember(structureId, userId) {
|
||||||
|
db.prepare(
|
||||||
|
"DELETE FROM structure_members WHERE structure_id = ? AND user_id = ?",
|
||||||
|
).run(structureId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a structure's privacy. Anyone may lock any structure down; the actor is
|
||||||
|
// added to the member list so they don't shut themselves out. (Access is purely
|
||||||
|
// the member set, so without this the person who flipped it private would lose
|
||||||
|
// the very structure they just privatized.)
|
||||||
|
function setStructurePrivacy(structureId, isPrivate, actorUserId) {
|
||||||
|
const s = getStructure(structureId);
|
||||||
|
if (!s) return;
|
||||||
|
db.prepare("UPDATE structures SET private = ? WHERE id = ?").run(
|
||||||
|
isPrivate ? 1 : 0,
|
||||||
|
structureId,
|
||||||
|
);
|
||||||
|
if (isPrivate && actorUserId != null) addMember(structureId, actorUserId);
|
||||||
|
}
|
||||||
|
|
||||||
function createRoute(verb, path, structureId, handler) {
|
function createRoute(verb, path, structureId, handler) {
|
||||||
path = encodeURI(path);
|
path = encodeURI(path);
|
||||||
|
|
||||||
|
|
@ -769,6 +833,12 @@ module.exports = {
|
||||||
getStructures,
|
getStructures,
|
||||||
getStructure,
|
getStructure,
|
||||||
createStructure,
|
createStructure,
|
||||||
|
canAccessStructure,
|
||||||
|
isMember,
|
||||||
|
getMembers,
|
||||||
|
addMember,
|
||||||
|
removeMember,
|
||||||
|
setStructurePrivacy,
|
||||||
createRoute,
|
createRoute,
|
||||||
getRoutes,
|
getRoutes,
|
||||||
getRoute,
|
getRoute,
|
||||||
|
|
|
||||||
101
index.js
101
index.js
|
|
@ -72,6 +72,16 @@ app.use(
|
||||||
app.use("/", wsRouter);
|
app.use("/", wsRouter);
|
||||||
app.use("/plumbing", require("./plumbing"));
|
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)) {
|
||||||
|
return res.status(404).send("Not found");
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;
|
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;
|
||||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
|
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
|
||||||
|
|
||||||
|
|
@ -165,10 +175,21 @@ function runLibrary(sql, librarySource, console) {
|
||||||
return libContext.module.exports;
|
return libContext.module.exports;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The `require(name)` targets available to a user handler.
|
// The `require(name)` targets available to a user handler. `memberUserId` is the
|
||||||
function makeLibs(structureId, console) {
|
// 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 dbs = {};
|
||||||
|
const forbidden = new Set();
|
||||||
for (let appDb of model.getDbsForStructure(structureId)) {
|
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);
|
const sql = model.getDbInstance(appDb.id);
|
||||||
dbs[appDb.alias] = {
|
dbs[appDb.alias] = {
|
||||||
library: runLibrary(sql, appDb.library, console),
|
library: runLibrary(sql, appDb.library, console),
|
||||||
|
|
@ -177,7 +198,12 @@ function makeLibs(structureId, console) {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
eta: model.getTemplater(structureId),
|
eta: model.getTemplater(structureId),
|
||||||
db: (alias) => dbs[alias],
|
db: (alias) => {
|
||||||
|
if (forbidden.has(alias)) {
|
||||||
|
throw new Error(`db '${alias}' is private; you do not have access`);
|
||||||
|
}
|
||||||
|
return dbs[alias];
|
||||||
|
},
|
||||||
push: push,
|
push: push,
|
||||||
files: { saveFile: (...args) => saveFile(structureId, ...args) },
|
files: { saveFile: (...args) => saveFile(structureId, ...args) },
|
||||||
};
|
};
|
||||||
|
|
@ -197,10 +223,10 @@ function currentUserFor(req) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bootstrapContext(structureId, routeId, initContext) {
|
function bootstrapContext(structureId, routeId, initContext, memberUserId) {
|
||||||
const structure = model.getStructure(structureId);
|
const structure = model.getStructure(structureId);
|
||||||
const console = makeConsole(structureId, routeId);
|
const console = makeConsole(structureId, routeId);
|
||||||
const libs = makeLibs(structureId, console);
|
const libs = makeLibs(structureId, console, memberUserId);
|
||||||
|
|
||||||
return vm.createContext({
|
return vm.createContext({
|
||||||
...initContext,
|
...initContext,
|
||||||
|
|
@ -227,6 +253,11 @@ function compileWebsocketHandler(route) {
|
||||||
try {
|
try {
|
||||||
// Keep the existing WS handler context for compatibility. Restricting the
|
// Keep the existing WS handler context for compatibility. Restricting the
|
||||||
// exposed capabilities is a separate runtime-sandboxing change.
|
// 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 context = bootstrapContext(route.structure_id, route.id, { app });
|
||||||
const handler = vm.runInContext(`${route.handler}\n\nhandler;`, context);
|
const handler = vm.runInContext(`${route.handler}\n\nhandler;`, context);
|
||||||
model.updateRoute({ ...route, error: null });
|
model.updateRoute({ ...route, error: null });
|
||||||
|
|
@ -284,6 +315,9 @@ wsRouter.ws("*", (ws, req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { route } = 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;
|
req.params = found.params;
|
||||||
let clients = wsConnections.get(route.id);
|
let clients = wsConnections.get(route.id);
|
||||||
if (!clients) {
|
if (!clients) {
|
||||||
|
|
@ -594,9 +628,10 @@ app.post("/workshop", (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/workshop", (req, res) => {
|
app.get("/workshop", (req, res) => {
|
||||||
return renderWorkshop(res, "workshop/index", {
|
const structures = model
|
||||||
structures: model.getStructures(),
|
.getStructures()
|
||||||
});
|
.filter((s) => model.canAccessStructure(req.session.userId, s.id));
|
||||||
|
return renderWorkshop(res, "workshop/index", { structures });
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderWorkshop(res, template, context) {
|
function renderWorkshop(res, template, context) {
|
||||||
|
|
@ -928,6 +963,7 @@ app.get("/workshop/:structure_id/files", (req, res) => {
|
||||||
|
|
||||||
app.get("/workshop/:structure_id/settings", (req, res) => {
|
app.get("/workshop/:structure_id/settings", (req, res) => {
|
||||||
return renderWorkshop(res, "workshop/settings", {
|
return renderWorkshop(res, "workshop/settings", {
|
||||||
|
members: model.getMembers(req.params.structure_id),
|
||||||
...sidebarStuff(req.params.structure_id),
|
...sidebarStuff(req.params.structure_id),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -944,6 +980,15 @@ app.put("/workshop/:structure_id/settings", (req, res) => {
|
||||||
routePrefix = routePrefix.substring(0, routePrefix.length - 1);
|
routePrefix = routePrefix.substring(0, routePrefix.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An unchecked checkbox sends no field at all, so absence means "public".
|
||||||
|
const wantPrivate = req.body.private === "on" || req.body.private === "1";
|
||||||
|
if (wantPrivate && !req.session.userId) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.send("log in before making a structure private, or you'll lock everyone out");
|
||||||
|
}
|
||||||
|
model.setStructurePrivacy(structId, wantPrivate, req.session.userId);
|
||||||
|
|
||||||
model.updateStruct({
|
model.updateStruct({
|
||||||
...struct,
|
...struct,
|
||||||
route_prefix: routePrefix,
|
route_prefix: routePrefix,
|
||||||
|
|
@ -955,6 +1000,29 @@ app.put("/workshop/:structure_id/settings", (req, res) => {
|
||||||
res.send("success!");
|
res.send("success!");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invite a member to a private structure by username. Renders the updated
|
||||||
|
// member list back into the settings page (hx-target).
|
||||||
|
app.post("/workshop/:structure_id/members", (req, res) => {
|
||||||
|
const structId = req.params.structure_id;
|
||||||
|
const user = model.getUser((req.body.username || "").trim());
|
||||||
|
if (user) model.addMember(structId, user.id);
|
||||||
|
return renderWorkshop(res, "workshop/members", {
|
||||||
|
structure: model.getStructure(structId),
|
||||||
|
members: model.getMembers(structId),
|
||||||
|
notFound: user ? null : req.body.username,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/workshop/:structure_id/members/:user_id", (req, res) => {
|
||||||
|
const structId = req.params.structure_id;
|
||||||
|
model.removeMember(structId, req.params.user_id);
|
||||||
|
return renderWorkshop(res, "workshop/members", {
|
||||||
|
structure: model.getStructure(structId),
|
||||||
|
members: model.getMembers(structId),
|
||||||
|
notFound: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/workshop/:structure_id/new_template_modal", (req, res) => {
|
app.get("/workshop/:structure_id/new_template_modal", (req, res) => {
|
||||||
return renderWorkshop(res, "workshop/new_template_modal", {
|
return renderWorkshop(res, "workshop/new_template_modal", {
|
||||||
structure: model.getStructure(req.params.structure_id),
|
structure: model.getStructure(req.params.structure_id),
|
||||||
|
|
@ -999,6 +1067,12 @@ app.all("*", async (req, res) => {
|
||||||
req.params = routeMatch.params;
|
req.params = routeMatch.params;
|
||||||
const route = routeMatch.route;
|
const route = routeMatch.route;
|
||||||
|
|
||||||
|
// Private structures are invisible to uninvited users: 404, so a private
|
||||||
|
// route is indistinguishable from one that doesn't exist.
|
||||||
|
if (!model.canAccessStructure(req.session.userId, route.structure_id)) {
|
||||||
|
return res.status(404).json({ success: false, message: "Path not found" });
|
||||||
|
}
|
||||||
|
|
||||||
// Minimal, handcrafted view of the logged-in Bliss user for user routes.
|
// Minimal, handcrafted view of the logged-in Bliss user for user routes.
|
||||||
// Deliberately NOT the raw session/user object — just {id, username} —
|
// Deliberately NOT the raw session/user object — just {id, username} —
|
||||||
// so structures can greet whoever is logged in without exposing internals.
|
// so structures can greet whoever is logged in without exposing internals.
|
||||||
|
|
@ -1009,11 +1083,12 @@ app.all("*", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// todo: only add req res to contexst when running the handler()
|
// todo: only add req res to contexst when running the handler()
|
||||||
// which means moving to runscript instead of runincontext for that
|
// which means moving to runscript instead of runincontext for that
|
||||||
let context = bootstrapContext(route.structure_id, route.id, {
|
let context = bootstrapContext(
|
||||||
req,
|
route.structure_id,
|
||||||
res,
|
route.id,
|
||||||
eta,
|
{ req, res, eta },
|
||||||
});
|
req.session.userId,
|
||||||
|
);
|
||||||
|
|
||||||
res.render = (template, context = {}) => {
|
res.render = (template, context = {}) => {
|
||||||
context.route = makeRoute(structure);
|
context.route = makeRoute(structure);
|
||||||
|
|
|
||||||
15
migrations/005_add_privacy.sql
Normal file
15
migrations/005_add_privacy.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- Private structures. A structure is public by default (private = 0); when
|
||||||
|
-- private = 1 only the users listed in structure_members may reach its routes,
|
||||||
|
-- mount its dbs, or open it in the workshop — a flat set of equals, no creator
|
||||||
|
-- or owner. Enforcement lives in the platform (index.js / plumbing.js), never in
|
||||||
|
-- user code — see canAccessStructure in db.js.
|
||||||
|
ALTER TABLE structures ADD COLUMN private INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE TABLE structure_members (
|
||||||
|
structure_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY(structure_id, user_id),
|
||||||
|
FOREIGN KEY(structure_id) REFERENCES structures(id),
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
38
plumbing.js
38
plumbing.js
|
|
@ -11,6 +11,16 @@ const model = require("./db");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Private structures are invisible through the plumbing too. Every structure-
|
||||||
|
// scoped route carries :id, so one param guard covers them all; a caller who
|
||||||
|
// isn't on the member list gets a 404, same as the workshop.
|
||||||
|
router.param("id", (req, res, next, id) => {
|
||||||
|
if (!model.canAccessStructure(req.session && req.session.userId, id)) {
|
||||||
|
return res.status(404).json({ error: "not found" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Wrap a handler so thrown errors come back as JSON instead of an HTML stack.
|
// Wrap a handler so thrown errors come back as JSON instead of an HTML stack.
|
||||||
function json(handler) {
|
function json(handler) {
|
||||||
return (req, res) => {
|
return (req, res) => {
|
||||||
|
|
@ -26,10 +36,17 @@ function json(handler) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// All structures.
|
// All structures the caller is allowed to see (private ones they aren't a
|
||||||
|
// member of are omitted).
|
||||||
router.get(
|
router.get(
|
||||||
"/structures",
|
"/structures",
|
||||||
json(() => model.getStructures()),
|
json((req) =>
|
||||||
|
model
|
||||||
|
.getStructures()
|
||||||
|
.filter((s) =>
|
||||||
|
model.canAccessStructure(req.session && req.session.userId, s.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// One structure with everything the sidebar shows, in one call.
|
// One structure with everything the sidebar shows, in one call.
|
||||||
|
|
@ -87,10 +104,23 @@ router.get(
|
||||||
json((req) => model.getVersions("db", req.params.dbId)),
|
json((req) => model.getVersions("db", req.params.dbId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// One version, including its full snapshot (the versioned fields).
|
// One version, including its full snapshot (the versioned fields). Not scoped
|
||||||
|
// by :id, so it carries its own access check against the version's structure.
|
||||||
router.get(
|
router.get(
|
||||||
"/versions/:versionId",
|
"/versions/:versionId",
|
||||||
json((req) => model.getVersion(req.params.versionId)),
|
json((req) => {
|
||||||
|
const version = model.getVersion(req.params.versionId);
|
||||||
|
if (!version) return null;
|
||||||
|
if (
|
||||||
|
!model.canAccessStructure(
|
||||||
|
req.session && req.session.userId,
|
||||||
|
version.structure_id,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return version;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Logs for a route. ?since=<id> returns only newer rows (for polling).
|
// Logs for a route. ?since=<id> returns only newer rows (for polling).
|
||||||
|
|
|
||||||
28
views/workshop/members.eta
Normal file
28
views/workshop/members.eta
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<div id="members" class="flex flex-col gap-1">
|
||||||
|
<label>Members</label>
|
||||||
|
<% if (!it.members.length) { %>
|
||||||
|
<div class="text-xs italic">no members yet — only you can see this while private</div>
|
||||||
|
<% } %>
|
||||||
|
<% it.members.forEach((v) => { %>
|
||||||
|
<div class="flex flex-row items-center gap-2 text-sm">
|
||||||
|
<span class="flex-grow"><%~ v.username %></span>
|
||||||
|
<button
|
||||||
|
hx-delete="/workshop/<%= it.structure.id %>/members/<%= v.user_id %>"
|
||||||
|
hx-target="#members"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>remove</button>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
<% if (it.notFound) { %>
|
||||||
|
<div class="text-xs text-red-600">no user named "<%~ it.notFound %>"</div>
|
||||||
|
<% } %>
|
||||||
|
<form
|
||||||
|
hx-post="/workshop/<%= it.structure.id %>/members"
|
||||||
|
hx-target="#members"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="flex flex-row gap-1"
|
||||||
|
>
|
||||||
|
<input name="username" placeholder="username" class="flex-grow">
|
||||||
|
<button type="submit">invite</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
@ -27,9 +27,16 @@
|
||||||
<div class="text-xs"></div>
|
<div class="text-xs"></div>
|
||||||
<textarea name="head_injection" value="<%= it.structure.route_prefix || "" %>"><%= it.structure.head_injection || "" %></textarea>
|
<textarea name="head_injection" value="<%= it.structure.route_prefix || "" %>"><%= it.structure.head_injection || "" %></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<input type="checkbox" id="private" name="private" <%= it.structure.private ? "checked" : "" %>>
|
||||||
|
<label for="private">Private (only invited members)</label>
|
||||||
|
</div>
|
||||||
<button id="save-btn" type="submit">Save</button>
|
<button id="save-btn" type="submit">Save</button>
|
||||||
<div id="result"></div>
|
<div id="result"></div>
|
||||||
</form>
|
</form>
|
||||||
|
<div class="p-2 max-w-48">
|
||||||
|
<%~ include('/workshop/members', it) %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue