implement demo mode

This commit is contained in:
Nystik
2026-05-14 19:26:53 +02:00
parent e021993d61
commit ab89a94088
21 changed files with 1186 additions and 8 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ investigation/
vaults/
plugin/main.js
server/plugins/*/plugin/main.js
demo-vaults/

View File

@@ -117,6 +117,12 @@ volumes:
| `PUID` | User ID for file ownership | `1000` |
| `PGID` | Group ID for file ownership | `1000` |
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. Useful for slow filesystems (rclone, NFS, SMB). Set to `0` to disable. | `5000` |
| `DEMO_MODE` | Enable demo mode (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [examples/demo/](examples/demo/). | `false` |
| `DEMO_MAX_SESSIONS` | Concurrent demo session cap. New visitors get a 503 capacity page when full. | `20` |
| `DEMO_VAULTS_PER_SESSION` | Max vaults per session (vault create returns 507 past this). | `3` |
| `DEMO_SESSION_QUOTA_BYTES` | Cumulative byte budget per session across all session vaults. | `716800` |
| `DEMO_TIMEOUT_MS` | Inactivity timeout before a demo session and its vaults are cleaned up. | `1800000` |
| `DEMO_TEMPLATE_DIR` | Directory copied into each new demo vault. | `server/demo-template/` |
## Contributing

View File

@@ -111,4 +111,21 @@ A basic plugin system for extending the server. Still early, the core lifecycle
An Ignis plugin is a Node.js package under `server/plugins/<name>/` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`.
When enabled, a plugin's Express router is mounted at `/api/ext/<pluginId>/`. A plugin can also optionally bundle an Obsidian plugin, a directory containing a standard Obsidian plugin (manifest.json, main.js) that gets auto-installed into the vault on enable and removed on disable. This bridges the server and client sides: the Ignis plugin handles server logic and routes, while the bundled Obsidian plugin provides the in-app UI or behavior.
When enabled, a plugin's Express router is mounted at `/api/ext/<pluginId>/`. A plugin can also optionally bundle an Obsidian plugin, a directory containing a standard Obsidian plugin (manifest.json, main.js) that gets auto-installed into the vault on enable and removed on disable. This bridges the server and client sides: the Ignis plugin handles server logic and routes, while the bundled Obsidian plugin provides the in-app UI or behavior.
## Demo mode
A separate operating mode for running Ignis as a public-facing demo. Enabled by `DEMO_MODE=true`. When off, none of the demo code runs and the server behaves normally.
In demo mode, each visitor gets a session identified by a cookie. Their vaults are stored on disk under a session-prefixed name (`demo-<sessionId>__<userVaultName>`) to avoid naming collisons; demo middleware translates inbound `?vault=X` and request bodies, and rewrites vault id/name fields in JSON responses on the way out.
The bootstrap endpoint's pre-compressed buffer path is bypassed in demo mode so the response wrapper can rewrite per-session names.
Other demo behaviors:
- Per-session caps on vault count and cumulative bytes, returning 507 when exceeded.
- Proxy allowlist limiting `/api/proxy` to a known-safe set of hosts (no `obsidian.md`/`api.obsidian.md` so account login attempts fail at the network layer).
- A `setInterval` cleanup that removes inactive sessions and orphaned `demo-*` directories, with a recovery redirect that sends users to `/` if their requested vault was wiped under them.
- Server-side plugins (e.g. headless-sync) hidden from the client; enable/disable returns 403.
- The bridge plugin disables any `<input type="email">` or `<input type="password">` it sees anywhere in the document, with a placeholder telling users not to enter credentials.
All server-side demo code lives in `server/demo/`. The client-side hooks live in `src/shims/demo.js`. The deployment example is in `examples/demo/` (tmpfs-mounted vaults, restricted proxy, all the env vars).

View File

@@ -0,0 +1,37 @@
# Public demo of Ignis.
#
# - Vaults live on tmpfs (RAM-backed, transient by design)
# - 20 MB total tmpfs, 700 KB per session, 3 vaults per session
# - 30-minute inactivity timeout, in-process cleanup
# - CORS proxy locked down to a known-safe domain allowlist
# - Obsidian account login blocked (proxy + UI)
services:
ignis-demo:
build:
context: ../..
ports:
- "8080:8080"
environment:
- DEMO_MODE=true
- DEMO_MAX_SESSIONS=20
- DEMO_VAULTS_PER_SESSION=3
- DEMO_SESSION_QUOTA_BYTES=716800 # 700 KB
- DEMO_TIMEOUT_MS=1800000 # 30 min
# Mount your own template at /app/demo-template to ship a richer
# starter vault without committing it to the repo. Defaults to the
# bundled server/demo-template/.
# - DEMO_TEMPLATE_DIR=/app/demo-template
- WRITE_COALESCE_MS=0 # tmpfs doesn't need debouncing
- PUID=1000
- PGID=1000
tmpfs:
- /vaults:size=20m,mode=1700
volumes:
- ./data:/app/data
- obsidian-app:/app/obsidian-app
# - ./my-demo-template:/app/demo-template:ro
restart: unless-stopped
volumes:
obsidian-app:

54
plugin/src/demo-guards.js Normal file
View File

@@ -0,0 +1,54 @@
// Demo-mode UX guards that run at the document level.
//
// Disable any email/password inputs to prevent users from entering credentials into a server they don't control.
const PLACEHOLDER =
"Disabled in demo. Don't enter credentials on a server you don't control.";
function isDemoMode() {
return document.body && document.body.dataset.demoMode === "true";
}
function disableInputs(root) {
const inputs = root.querySelectorAll(
'input[type="email"], input[type="password"]',
);
for (const input of inputs) {
if (input.dataset.ignisDemoDisabled === "1") {
continue;
}
input.disabled = true;
input.value = "";
input.placeholder = PLACEHOLDER;
input.dataset.ignisDemoDisabled = "1";
}
}
let observer = null;
function startDemoGuards() {
if (!isDemoMode() || observer) {
return;
}
// Walk what's already there.
disableInputs(document.body);
// And watch for anything added later (login modals, plugin dialogs, etc.).
observer = new MutationObserver(() => {
disableInputs(document.body);
});
observer.observe(document.body, { childList: true, subtree: true });
}
function stopDemoGuards() {
if (observer) {
observer.disconnect();
observer = null;
}
}
module.exports = { startDemoGuards, stopDemoGuards };

View File

@@ -4,6 +4,7 @@ const { patchSettingsModal, unpatchSettingsModal } = require("./settings/inject"
const pluginRegistry = require("./plugin-registry");
const { initStatusBar } = require("./status-bar");
const { WorkspacePickerModal } = require("./workspace-picker");
const { startDemoGuards, stopDemoGuards } = require("./demo-guards");
window.__obsidianAPI = require("obsidian");
@@ -13,6 +14,7 @@ class IgnisBridgePlugin extends Plugin {
await pluginRegistry.refresh();
patchSettingsModal(this);
startDemoGuards();
this._statusBarInterval = initStatusBar(this);
this.addRibbonIcon("upload", "Upload file", () => {
@@ -44,6 +46,7 @@ class IgnisBridgePlugin extends Plugin {
}
unpatchSettingsModal(this);
stopDemoGuards();
console.log("[ignis-bridge] Plugin unloaded");
}
}

View File

@@ -79,6 +79,16 @@ module.exports = {
? parseInt(process.env.WRITE_COALESCE_MS)
: 5000,
demoMode: process.env.DEMO_MODE === "true",
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,
demoVaultsPerSession: parseInt(process.env.DEMO_VAULTS_PER_SESSION) || 3,
demoSessionQuotaBytes:
parseInt(process.env.DEMO_SESSION_QUOTA_BYTES) || 700 * 1024,
demoTimeoutMs: parseInt(process.env.DEMO_TIMEOUT_MS) || 30 * 60 * 1000,
demoTemplateDir:
process.env.DEMO_TEMPLATE_DIR ||
path.join(__dirname, "demo-template"),
obsidianAssetsPath:
process.env.OBSIDIAN_ASSETS_PATH ||
path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked"),

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,4 @@
{
"baseFontSize": 16,
"theme": "obsidian"
}

View File

@@ -0,0 +1,18 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"outgoing-link": true,
"tag-pane": true,
"page-preview": true,
"command-palette": true,
"editor-status": true,
"markdown-importer": false,
"word-count": true,
"outline": true,
"file-recovery": false,
"publish": false,
"sync": false
}

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Demo at capacity</title>
<style>
body {
font: 16px -apple-system, sans-serif;
background: #202020;
color: #b3b3b3;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
text-align: center;
}
.box {
max-width: 480px;
padding: 32px;
}
h1 { color: #ddd; }
</style>
</head>
<body>
<div class="box">
<h1>Demo at capacity</h1>
<p>All demo slots are currently in use.</p>
<p>Try again in a few minutes. Sessions auto-expire after a period of inactivity.</p>
</div>
</body>
</html>

101
server/demo/demo-cleanup.js Normal file
View File

@@ -0,0 +1,101 @@
// Inactivity sweep + orphan scan, run on a 60s setInterval.
const fs = require("fs");
const fsp = fs.promises;
const path = require("path");
const config = require("../config");
const watcher = require("../watcher");
const bootstrapRoutes = require("../routes/bootstrap");
const {
sessions,
makeStorageName,
PREFIX_SEPARATOR,
} = require("./demo-sessions");
async function cleanupSession(sessionId) {
const s = sessions.get(sessionId);
if (!s) {
return;
}
for (const userVaultName of s.vaults) {
const storageName = makeStorageName(sessionId, userVaultName);
const vaultPath = config.getVaultPath(storageName);
if (!vaultPath) {
continue;
}
try {
watcher.stopWatching(storageName);
} catch {}
try {
await fsp.rm(vaultPath, { recursive: true, force: true });
} catch (e) {
console.warn(`[demo] Failed to remove ${storageName}:`, e.message);
}
bootstrapRoutes.invalidateVault(storageName);
}
config.refreshVaults();
sessions.delete(sessionId);
console.log(`[demo] Cleaned up session ${sessionId}`);
}
async function cleanupExpired() {
const now = Date.now();
const expired = [];
for (const [sessionId, s] of sessions) {
if (now - s.lastActivity > config.demoTimeoutMs) {
expired.push(sessionId);
}
}
for (const sessionId of expired) {
await cleanupSession(sessionId);
}
// Orphan scan: directories matching demo-* whose session is gone
let entries;
try {
entries = await fsp.readdir(config.vaultRoot, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith("demo-")) {
continue;
}
const idx = entry.name.indexOf(PREFIX_SEPARATOR);
if (idx < 0) {
continue;
}
const sessionId = entry.name.slice("demo-".length, idx);
if (!sessions.has(sessionId)) {
const orphanPath = path.join(config.vaultRoot, entry.name);
try {
await fsp.rm(orphanPath, { recursive: true, force: true });
bootstrapRoutes.invalidateVault(entry.name);
console.log(`[demo] Removed orphan ${entry.name}`);
} catch {}
}
}
config.refreshVaults();
}
module.exports = { cleanupSession, cleanupExpired };

View 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,
};

View File

@@ -0,0 +1,149 @@
// Vault provisioning for demo sessions.
//
// Copies the template into a session-prefixed dir, installs the bridge plugin, and registers the vault on the session.
// Re-provisions if disk was wiped under an existing session.
const fs = require("fs");
const fsp = fs.promises;
const path = require("path");
const config = require("../config");
const { installBridgePlugin } = require("../bridge-plugin");
const bootstrapRoutes = require("../routes/bootstrap");
const { sessions, makeStorageName } = require("./demo-sessions");
const DEFAULT_VAULT_NAME = "Welcome";
async function dirSize(dir) {
let total = 0;
async function walk(d) {
let entries;
try {
entries = await fsp.readdir(d, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(d, e.name);
if (e.isDirectory()) {
await walk(full);
} else {
try {
const st = await fsp.stat(full);
total += st.size;
} catch {}
}
}
}
await walk(dir);
return total;
}
async function recomputeBytes(sessionId) {
const s = sessions.get(sessionId);
if (!s) {
return 0;
}
let total = 0;
for (const userVaultName of s.vaults) {
const storageName = makeStorageName(sessionId, userVaultName);
const vaultPath = config.getVaultPath(storageName);
if (vaultPath) {
total += await dirSize(vaultPath);
}
}
s.bytesUsed = total;
return total;
}
async function provisionVault(sessionId, userVaultName) {
const s = sessions.get(sessionId);
if (!s) {
return null;
}
if (s.vaults.size >= config.demoVaultsPerSession) {
return { error: "vaults-per-session-limit" };
}
const storageName = makeStorageName(sessionId, userVaultName);
const vaultPath = path.join(config.vaultRoot, storageName);
await fsp.mkdir(config.vaultRoot, { recursive: true });
try {
await fsp.mkdir(vaultPath, { recursive: false });
} catch (e) {
if (e.code === "EEXIST") {
return { error: "vault-exists" };
}
throw e;
}
// Copy template (default: Welcome.md, Getting Started.md, .obsidian/*).
await fsp.cp(config.demoTemplateDir, vaultPath, { recursive: true });
// Install bridge plugin
await installBridgePlugin(vaultPath);
config.refreshVaults();
bootstrapRoutes.invalidateVault(storageName);
s.vaults.add(userVaultName);
await recomputeBytes(sessionId);
return { storageName, userVaultName };
}
async function ensureDefaultVault(sessionId) {
const s = sessions.get(sessionId);
if (!s) {
return null;
}
const storageName = makeStorageName(sessionId, DEFAULT_VAULT_NAME);
const vaultPath = config.getVaultPath(storageName);
const onDisk = vaultPath && fs.existsSync(vaultPath);
if (s.vaults.has(DEFAULT_VAULT_NAME) && onDisk) {
return DEFAULT_VAULT_NAME;
}
if (onDisk) {
// Disk has it but session forgot (cookie outlived in-memory session).
s.vaults.add(DEFAULT_VAULT_NAME);
return DEFAULT_VAULT_NAME;
}
// Disk wiped under us; clear stale Set entry before re-provisioning.
s.vaults.delete(DEFAULT_VAULT_NAME);
const result = await provisionVault(sessionId, DEFAULT_VAULT_NAME);
if (result && result.userVaultName) {
return result.userVaultName;
}
return null;
}
module.exports = {
DEFAULT_VAULT_NAME,
provisionVault,
ensureDefaultVault,
recomputeBytes,
};

View File

@@ -0,0 +1,126 @@
// In-memory session map keyed by cookie value.
//
// Each entry tracks the user's vault names, last-activity timestamp, and bytes used.
// On disk, vaults are stored under a session-prefixed name so two sessions can both have a vault called "Notes".
const crypto = require("crypto");
const config = require("../config");
const COOKIE_NAME = "ignis-demo";
const PREFIX_SEPARATOR = "__";
// sessionId -> { lastActivity, vaults: Set<userVaultName>, bytesUsed }
const sessions = new Map();
function newSessionId() {
return crypto.randomBytes(12).toString("hex");
}
function prefixFor(sessionId) {
return "demo-" + sessionId + PREFIX_SEPARATOR;
}
function makeStorageName(sessionId, userVaultName) {
return prefixFor(sessionId) + userVaultName;
}
function tryParseUserVaultName(sessionId, storageName) {
const prefix = prefixFor(sessionId);
if (storageName && storageName.startsWith(prefix)) {
return storageName.slice(prefix.length);
}
return null;
}
function parseCookies(req) {
const header = req.headers.cookie;
if (!header) {
return {};
}
const out = {};
for (const part of header.split(/;\s*/)) {
const eq = part.indexOf("=");
if (eq < 0) {
continue;
}
out[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1));
}
return out;
}
function setSessionCookie(res, sessionId) {
const maxAgeSeconds = Math.floor(config.demoTimeoutMs / 1000);
res.setHeader(
"Set-Cookie",
`${COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}`,
);
}
// Resolve the session for a request. If none exists, create one (unless options.peek is true).
function getOrCreateSession(req, res, options = {}) {
const cookies = parseCookies(req);
const existing = cookies[COOKIE_NAME];
if (existing && sessions.has(existing)) {
return existing;
}
if (existing && !sessions.has(existing)) {
// Cookie outlived in-memory session. reuse the id to keep the prefix.
sessions.set(existing, {
lastActivity: Date.now(),
vaults: new Set(),
bytesUsed: 0,
});
return existing;
}
if (options.peek) {
return null;
}
if (sessions.size >= config.demoMaxSessions) {
return null;
}
const sessionId = newSessionId();
sessions.set(sessionId, {
lastActivity: Date.now(),
vaults: new Set(),
bytesUsed: 0,
});
setSessionCookie(res, sessionId);
return sessionId;
}
function touchSession(sessionId) {
const s = sessions.get(sessionId);
if (s) {
s.lastActivity = Date.now();
}
}
module.exports = {
COOKIE_NAME,
PREFIX_SEPARATOR,
sessions,
prefixFor,
makeStorageName,
tryParseUserVaultName,
parseCookies,
setSessionCookie,
getOrCreateSession,
touchSession,
};

41
server/demo/demo-ws.js Normal file
View File

@@ -0,0 +1,41 @@
// Vault prefix translation for WebSocket upgrades.
//
// ws.js validates the upgrade's `?vault=` against config.vaults.
// We translate to the storage-prefixed name before it sees the request, otherwise the handshake fails and the client reconnect-loops.
const url = require("url");
const {
COOKIE_NAME,
sessions,
parseCookies,
makeStorageName,
touchSession,
} = require("./demo-sessions");
function wireWebSocket(server) {
const origEmit = server.emit.bind(server);
server.emit = function (event, req, ...rest) {
if (event === "upgrade") {
const cookies = parseCookies(req);
const sessionId = cookies[COOKIE_NAME];
if (sessionId && sessions.has(sessionId)) {
const u = new url.URL(req.url, "http://localhost");
const userVault = u.searchParams.get("vault");
if (userVault && !userVault.startsWith("demo-")) {
u.searchParams.set("vault", makeStorageName(sessionId, userVault));
req.url = u.pathname + u.search;
}
touchSession(sessionId);
}
}
return origEmit(event, req, ...rest);
};
}
module.exports = { wireWebSocket };

94
server/demo/index.js Normal file
View File

@@ -0,0 +1,94 @@
// Demo mode entrypoint.
//
// Each visitor gets an isolated session with up to N session-prefixed vaults, cleaned up after inactivity.
// The proxy is allowlisted, Obsidian account login is blocked to discourage inputing credentials in a demo environment.
const config = require("../config");
const { cleanupExpired } = require("./demo-cleanup");
const {
activityHeartbeat,
captureOriginalVaultName,
inboundTranslator,
outboundTranslator,
vaultsPerSessionEnforcer,
quotaEnforcer,
proxyAllowlist,
trackVaultLifecycle,
pluginsBlocker,
pageLoadHandler,
provisionEndpoint,
} = require("./demo-middleware");
const { wireWebSocket } = require("./demo-ws");
// Mount HTTP middleware.
// Call before the API routes mount so this middleware intercepts first.
function setupDemo(app) {
if (!config.demoMode) {
return;
}
console.log("[demo] Demo mode enabled");
console.log(`[demo] Max sessions: ${config.demoMaxSessions}`);
console.log(`[demo] Vaults per session: ${config.demoVaultsPerSession}`);
console.log(
`[demo] Quota per session: ${config.demoSessionQuotaBytes} bytes`,
);
console.log(`[demo] Inactivity timeout: ${config.demoTimeoutMs} ms`);
// Page-load capacity gate (before static html)
app.use(pageLoadHandler);
// Heartbeat on every request so /api/ext/*, /vault-files/*, etc. keep the session alive too.
app.use(activityHeartbeat);
// Provisioning endpoint for the client to call when no vault is selected
app.get("/api/demo/provision", provisionEndpoint);
// Snapshot the user-visible name before inbound translation rewrites it.
app.use(
["/api/vault", "/api/fs", "/api/bootstrap"],
captureOriginalVaultName,
);
// Inbound: rewrite ?vault= and bodies to prefixed storage names
app.use(["/api/vault", "/api/fs", "/api/bootstrap"], inboundTranslator);
// Outbound: filter vault lists and strip prefixes from responses
app.use(["/api/vault", "/api/fs", "/api/bootstrap"], outboundTranslator);
// quota enforcement
app.use("/api/vault", vaultsPerSessionEnforcer);
app.use("/api/fs", quotaEnforcer);
// Track vault create/rename/delete in session.vaults
app.use("/api/vault", trackVaultLifecycle);
// Restrict the CORS proxy
app.use("/api/proxy", proxyAllowlist);
// Hide server-side plugins (headless-sync) from the demo UI
app.use("/api/plugins", pluginsBlocker);
// Cleanup timer
const interval = setInterval(() => {
cleanupExpired().catch((e) =>
console.warn("[demo] Cleanup error:", e.message),
);
}, 60 * 1000);
if (interval.unref) {
interval.unref();
}
}
// Wire WebSocket-level vault translation. Called after setupWebSocket.
function wireDemoWebSocket(server) {
if (!config.demoMode) {
return;
}
wireWebSocket(server);
}
module.exports = { setupDemo, wireDemoWebSocket };

View File

@@ -10,6 +10,7 @@ const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
const pluginRoutes = require("./routes/plugins");
const { flushAll } = require("./write-coalescer");
const { setupDemo, wireDemoWebSocket } = require("./demo");
const ANSI_RED = "\x1b[31m";
const ANSI_YELLOW = "\x1b[33m";
@@ -56,6 +57,10 @@ const bootstrapRoutes = require("./routes/bootstrap");
app.use("/assets", express.static(path.join(__dirname, "assets")));
// Demo mode: layers session/quota/allowlist middleware on top of the existing routes.
// Must run BEFORE the routes are mounted. No-op when DEMO_MODE != true.
setupDemo(app);
app.use("/api/fs", fsRoutes);
app.use("/api/vault", vaultRoutes);
app.use("/api/proxy", proxyRoutes);
@@ -116,6 +121,13 @@ function buildIndexHtml() {
html = html.replace("__SHIM_LOADER_SRC__", `shim-loader.js?v=${version}`);
html = html.replace("__OBSIDIAN_SCRIPTS__", JSON.stringify(scripts));
if (config.demoMode) {
html = html.replace(
'<body class="theme-dark">',
'<body class="theme-dark" data-demo-mode="true">',
);
}
cachedHtml = html;
return cachedHtml;
}
@@ -155,12 +167,13 @@ const server = app.listen(config.port, async () => {
await updateBridgePluginInAllVaults(config.vaultRoot);
await initPlugins({ app, config, wss, watcher });
bootstrapRoutes.warmUp().catch((e) =>
console.warn("[bootstrap] warm-up error:", e.message),
);
bootstrapRoutes
.warmUp()
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
});
const wss = setupWebSocket(server);
wireDemoWebSocket(server);
async function gracefulShutdown(signal) {
console.log(`\n[ignis] Received ${signal}, shutting down gracefully...`);

View File

@@ -1,8 +1,7 @@
// Bootstrap endpoint for cold start.
//
// Combines vault info, vault list, metadata tree, and plugin list into a
// single pre-compressed response. Cache is per-vault and invalidated by
// directory mtime check + explicit invalidateVault() calls from the write/delete routes.
// Combines vault info, vault list, metadata tree, and plugin list into a single pre-compressed response.
// Cache is per-vault and invalidated by directory mtime check + explicit invalidateVault() calls from the write/delete routes.
const express = require("express");
const fs = require("fs");
@@ -144,7 +143,8 @@ async function buildEntry(vaultId) {
vault,
vaultList: buildVaultList(),
tree,
plugins: getDiscoveredPlugins(),
// In demo mode, hide server-side plugins from the client.
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
};
const jsonBuf = Buffer.from(JSON.stringify(response));
@@ -216,6 +216,13 @@ router.get("/", async (req, res) => {
return res.status(404).json({ error: "Vault not found" });
}
// In demo mode, route through res.json so the demo middleware can translate vault names per-session.
// The pre-compressed buffer path bakes the storage prefix in and would bypass the response wrapper.
// Deep-clone so the demo translator's in-place mutation doesn't pollute the cached response object.
if (req._demoSessionId) {
return res.json(JSON.parse(JSON.stringify(entry.response)));
}
const ae = req.headers["accept-encoding"] || "";
const { compressed } = entry;
let buf, encoding;

61
src/shims/demo.js Normal file
View File

@@ -0,0 +1,61 @@
// Client-side demo mode hooks.
//
// Detects demo mode via the body data attribute the server stamps in buildIndexHtml.
// Pre-trusts vaults so Obsidian skips its first-run "Trust author" dialog, and bridges no-vault landing to /api/demo/provision.
export function isDemoMode() {
return (
typeof document !== "undefined" &&
document.body &&
document.body.dataset.demoMode === "true"
);
}
// Demo vaults are provisioned from our own template, never from an unknown source.
export function autoTrustDemoVaults(vaultList) {
if (!isDemoMode() || !Array.isArray(vaultList)) {
return;
}
for (const v of vaultList) {
if (v && v.id) {
localStorage.setItem("enable-plugin-" + v.id, "true");
}
}
}
// In demo mode with no vault selected, ask the server to provision one and reload at ?vault=<name>.
// Sync XHR so we block before Obsidian boots. Returns true if navigation is in progress (caller should halt init).
export function maybeProvisionDemoVault() {
if (!isDemoMode()) {
return false;
}
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("vault")) {
return false;
}
try {
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/demo/provision", false);
xhr.send();
if (xhr.status === 200) {
const { vault } = JSON.parse(xhr.responseText);
if (vault) {
// Pre-trust before redirect.
localStorage.setItem("enable-plugin-" + vault, "true");
window.location.replace("/?vault=" + encodeURIComponent(vault));
return true;
}
}
} catch (e) {
console.warn("[ignis] Demo provision failed:", e);
}
return false;
}

View File

@@ -9,6 +9,7 @@ import {
initWorkspacePatch,
} from "./workspace.js";
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
function resolveVaultId() {
const urlParams = new URLSearchParams(window.location.search);
@@ -213,6 +214,10 @@ function initCoreSyncGuardFallback() {
}
export function initialize() {
if (maybeProvisionDemoVault()) {
return;
}
resolveVaultId();
resolveWorkspaceName();
loadPresetIfRequested();
@@ -222,6 +227,7 @@ export function initialize() {
if (bootstrap) {
applyVaultInfo(bootstrap.vault);
window.__vaultList = bootstrap.vaultList;
autoTrustDemoVaults(bootstrap.vaultList);
applyTree(bootstrap.tree);
applyCoreSyncGuard(bootstrap.plugins);