mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
418 lines
10 KiB
JavaScript
418 lines
10 KiB
JavaScript
// 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) {
|
|
// body.id is storage-prefixed at this point (outboundTranslator runs after us).
|
|
// Translate to the user-visible name so it matches what pageLoadHandler queries with.
|
|
const userName = tryParseUserVaultName(sessionId, body.id);
|
|
|
|
if (userName !== null) {
|
|
s.vaults.add(userName);
|
|
} else {
|
|
console.warn(
|
|
"[demo] trackVaultLifecycle: could not parse user name from create response id:",
|
|
body.id,
|
|
);
|
|
}
|
|
} else if (req.path === "/rename") {
|
|
const oldName = req._demoOriginalVault;
|
|
|
|
if (oldName) {
|
|
s.vaults.delete(oldName);
|
|
}
|
|
|
|
if (body.id) {
|
|
const userName = tryParseUserVaultName(sessionId, body.id);
|
|
|
|
if (userName !== null) {
|
|
s.vaults.add(userName);
|
|
} else {
|
|
console.warn(
|
|
"[demo] trackVaultLifecycle: could not parse user name from rename response id:",
|
|
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,
|
|
};
|