mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
implement demo mode
This commit is contained in:
397
server/demo/demo-middleware.js
Normal file
397
server/demo/demo-middleware.js
Normal file
@@ -0,0 +1,397 @@
|
||||
// Demo Express middleware.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const url = require("url");
|
||||
|
||||
const config = require("../config");
|
||||
const {
|
||||
COOKIE_NAME,
|
||||
sessions,
|
||||
makeStorageName,
|
||||
tryParseUserVaultName,
|
||||
parseCookies,
|
||||
setSessionCookie,
|
||||
getOrCreateSession,
|
||||
touchSession,
|
||||
} = require("./demo-sessions");
|
||||
const { ensureDefaultVault } = require("./demo-provision");
|
||||
|
||||
const ALLOWED_PROXY_HOSTS = new Set([
|
||||
"releases.obsidian.md",
|
||||
"github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"objects.githubusercontent.com",
|
||||
"api.github.com",
|
||||
"codeload.github.com",
|
||||
]);
|
||||
|
||||
// Bump lastActivity on any cookie-bearing request.
|
||||
function activityHeartbeat(req, res, next) {
|
||||
const cookies = parseCookies(req);
|
||||
const sessionId = cookies[COOKIE_NAME];
|
||||
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
touchSession(sessionId);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Snapshot the user-visible vault name before inbound translation rewrites it.
|
||||
function captureOriginalVaultName(req, res, next) {
|
||||
if (req.query && req.query.vault) {
|
||||
req._demoOriginalVault = req.query.vault;
|
||||
}
|
||||
|
||||
if (req.body && req.body.vault) {
|
||||
req._demoOriginalVault = req.body.vault;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Rewrite inbound `?vault=` and request body vault names from user-visible to storage-prefixed.
|
||||
// Tags the request with the session id.
|
||||
function inboundTranslator(req, res, next) {
|
||||
const sessionId = getOrCreateSession(req, res, { peek: true });
|
||||
|
||||
if (!sessionId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
touchSession(sessionId);
|
||||
req._demoSessionId = sessionId;
|
||||
|
||||
if (req.query && req.query.vault) {
|
||||
req.query.vault = makeStorageName(sessionId, req.query.vault);
|
||||
}
|
||||
|
||||
if (req.body) {
|
||||
if (req.body.vault) {
|
||||
req.body.vault = makeStorageName(sessionId, req.body.vault);
|
||||
}
|
||||
|
||||
// Vault create/rename pass the new name as `name`
|
||||
if (req.body.name && (req.path === "/create" || req.path === "/rename")) {
|
||||
req.body.name = makeStorageName(sessionId, req.body.name);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function rewriteVaultIdInPlace(obj, sessionId) {
|
||||
if (!obj || typeof obj !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof obj.id === "string") {
|
||||
const userName = tryParseUserVaultName(sessionId, obj.id);
|
||||
|
||||
if (userName !== null) {
|
||||
obj.id = userName;
|
||||
obj.name = userName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filter/translate vault names in the JSON response body from storage-prefixed to user-visible
|
||||
function outboundTranslator(req, res, next) {
|
||||
const sessionId = req._demoSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const origJson = res.json.bind(res);
|
||||
|
||||
// clean path for UI display.
|
||||
const prefix = "demo-" + sessionId + "__";
|
||||
const stripPrefix = (s) =>
|
||||
typeof s === "string" ? s.split(prefix).join("") : s;
|
||||
|
||||
res.json = function (body) {
|
||||
if (Array.isArray(body)) {
|
||||
// /api/vault/list shape: [{ id, name, path }, ...]
|
||||
const filtered = [];
|
||||
|
||||
for (const entry of body) {
|
||||
const userName = tryParseUserVaultName(sessionId, entry.id);
|
||||
|
||||
if (userName !== null) {
|
||||
filtered.push({
|
||||
id: userName,
|
||||
name: userName,
|
||||
path: stripPrefix(entry.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return origJson(filtered);
|
||||
}
|
||||
|
||||
if (body && typeof body === "object") {
|
||||
// /api/vault/info, /api/bootstrap, /api/vault/create response
|
||||
rewriteVaultIdInPlace(body, sessionId);
|
||||
rewriteVaultIdInPlace(body.vault, sessionId);
|
||||
|
||||
if (typeof body.path === "string") {
|
||||
body.path = stripPrefix(body.path);
|
||||
}
|
||||
|
||||
if (body.vault && typeof body.vault.path === "string") {
|
||||
body.vault.path = stripPrefix(body.vault.path);
|
||||
}
|
||||
|
||||
if (Array.isArray(body.vaultList)) {
|
||||
body.vaultList = body.vaultList
|
||||
.map((v) => {
|
||||
const userName = tryParseUserVaultName(sessionId, v.id);
|
||||
|
||||
if (userName === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: userName,
|
||||
name: userName,
|
||||
path: stripPrefix(v.path),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
return origJson(body);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function vaultsPerSessionEnforcer(req, res, next) {
|
||||
if (req.path !== "/create" || req.method !== "POST") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const sessionId = req._demoSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const s = sessions.get(sessionId);
|
||||
|
||||
if (s && s.vaults.size >= config.demoVaultsPerSession) {
|
||||
return res.status(507).json({
|
||||
error: `Demo limit: max ${config.demoVaultsPerSession} vaults per session`,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function quotaEnforcer(req, res, next) {
|
||||
if (req.path !== "/writeFile" || req.method !== "POST") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const sessionId = req._demoSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const s = sessions.get(sessionId);
|
||||
|
||||
if (!s) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Estimate the size of the incoming payload
|
||||
const content = req.body && req.body.content;
|
||||
let size = 0;
|
||||
|
||||
if (typeof content === "string") {
|
||||
size = req.body.base64
|
||||
? Math.floor((content.length * 3) / 4)
|
||||
: Buffer.byteLength(content, "utf-8");
|
||||
}
|
||||
|
||||
if (s.bytesUsed + size > config.demoSessionQuotaBytes) {
|
||||
return res.status(507).json({
|
||||
error: `Demo quota exceeded (${config.demoSessionQuotaBytes} bytes per session)`,
|
||||
});
|
||||
}
|
||||
|
||||
// Optimistically add. recomputeBytes() corrects drift periodically
|
||||
s.bytesUsed += size;
|
||||
next();
|
||||
}
|
||||
|
||||
function proxyAllowlist(req, res, next) {
|
||||
const target = req.body && req.body.url;
|
||||
|
||||
if (!target) {
|
||||
return next();
|
||||
}
|
||||
|
||||
let host;
|
||||
|
||||
try {
|
||||
host = new url.URL(target).hostname;
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
if (!ALLOWED_PROXY_HOSTS.has(host)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: `Domain not allowed in demo mode: ${host}` });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function trackVaultLifecycle(req, res, next) {
|
||||
const sessionId = req._demoSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Hook res.json to update session.vaults on successful create/delete/rename
|
||||
const origJson = res.json.bind(res);
|
||||
|
||||
res.json = function (body) {
|
||||
const isOk =
|
||||
res.statusCode < 400 && body && typeof body === "object" && body.ok;
|
||||
|
||||
if (isOk) {
|
||||
const s = sessions.get(sessionId);
|
||||
|
||||
if (s) {
|
||||
if (req.path === "/create" && body.id) {
|
||||
s.vaults.add(body.id);
|
||||
} else if (req.path === "/rename") {
|
||||
const oldName = req.body && req.body._origVault;
|
||||
|
||||
if (oldName) {
|
||||
s.vaults.delete(oldName);
|
||||
}
|
||||
|
||||
if (body.id) {
|
||||
s.vaults.add(body.id);
|
||||
}
|
||||
} else if (req.method === "DELETE" && req.path === "/remove") {
|
||||
const removed = req._demoOriginalVault;
|
||||
|
||||
if (removed) {
|
||||
s.vaults.delete(removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return origJson(body);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Server-side plugins (headless-sync) have no place in a sandbox.
|
||||
// Hide the list and refuse enable/disable calls.
|
||||
function pluginsBlocker(req, res, next) {
|
||||
if (req.method === "GET") {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Server plugins are disabled in demo mode" });
|
||||
}
|
||||
|
||||
const CAPACITY_HTML = fs.readFileSync(
|
||||
path.join(__dirname, "demo-capacity.html"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
function pageLoadHandler(req, res, next) {
|
||||
if (req.path !== "/" && req.path !== "/index.html") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const cookies = parseCookies(req);
|
||||
let sessionId = cookies[COOKIE_NAME];
|
||||
let session =
|
||||
sessionId && sessions.has(sessionId) ? sessions.get(sessionId) : null;
|
||||
|
||||
if (!session) {
|
||||
if (sessions.size >= config.demoMaxSessions) {
|
||||
res.status(503).type("html").send(CAPACITY_HTML);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cookie missing or session expired/cleaned. Create or restore.
|
||||
sessionId = getOrCreateSession(req, res);
|
||||
session = sessionId ? sessions.get(sessionId) : null;
|
||||
} else {
|
||||
// Refresh max-age on every page load so long-tab users stay signed in.
|
||||
setSessionCookie(res, sessionId);
|
||||
}
|
||||
|
||||
// Recovery: if the requested vault no longer exists, redirect to / so the client's provisioning flow recreates it.
|
||||
const requestedVault = req.query?.vault;
|
||||
|
||||
if (requestedVault && session) {
|
||||
const storageName = makeStorageName(sessionId, requestedVault);
|
||||
const vaultPath = config.getVaultPath(storageName);
|
||||
const stillExists =
|
||||
session.vaults.has(requestedVault) &&
|
||||
vaultPath &&
|
||||
fs.existsSync(vaultPath);
|
||||
|
||||
if (!stillExists) {
|
||||
return res.redirect(302, "/");
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// GET /api/demo/provision - returns the default vault's user-visible name, creating it if needed.
|
||||
// Client calls this when no ?vault= is in the URL.
|
||||
function provisionEndpoint(req, res) {
|
||||
const sessionId = getOrCreateSession(req, res);
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(503).json({ error: "Demo at capacity" });
|
||||
}
|
||||
|
||||
ensureDefaultVault(sessionId)
|
||||
.then((userVaultName) => {
|
||||
if (!userVaultName) {
|
||||
return res.status(500).json({ error: "Provisioning failed" });
|
||||
}
|
||||
|
||||
res.json({ vault: userVaultName });
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[demo] provision error:", e);
|
||||
res.status(500).json({ error: e.message });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activityHeartbeat,
|
||||
captureOriginalVaultName,
|
||||
inboundTranslator,
|
||||
outboundTranslator,
|
||||
vaultsPerSessionEnforcer,
|
||||
quotaEnforcer,
|
||||
proxyAllowlist,
|
||||
trackVaultLifecycle,
|
||||
pluginsBlocker,
|
||||
pageLoadHandler,
|
||||
provisionEndpoint,
|
||||
};
|
||||
Reference in New Issue
Block a user