diff --git a/.gitignore b/.gitignore index 96243e3..f8eb38a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ investigation/ vaults/ plugin/main.js server/plugins/*/plugin/main.js +demo-vaults/ diff --git a/README.md b/README.md index 56f8c1c..610ee4b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5fc1f43..6fd096c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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//` 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//`. 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. \ No newline at end of file +When enabled, a plugin's Express router is mounted at `/api/ext//`. 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-__`) 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 `` or `` 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). \ No newline at end of file diff --git a/examples/demo/docker-compose.yml b/examples/demo/docker-compose.yml new file mode 100644 index 0000000..c08bf67 --- /dev/null +++ b/examples/demo/docker-compose.yml @@ -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: diff --git a/plugin/src/demo-guards.js b/plugin/src/demo-guards.js new file mode 100644 index 0000000..2b7783e --- /dev/null +++ b/plugin/src/demo-guards.js @@ -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 }; diff --git a/plugin/src/main.js b/plugin/src/main.js index d6772bb..40c75da 100644 --- a/plugin/src/main.js +++ b/plugin/src/main.js @@ -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"); } } diff --git a/server/config.js b/server/config.js index e713bef..dba738f 100644 --- a/server/config.js +++ b/server/config.js @@ -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"), diff --git a/server/demo-template/.obsidian/app.json b/server/demo-template/.obsidian/app.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/server/demo-template/.obsidian/app.json @@ -0,0 +1 @@ +{} diff --git a/server/demo-template/.obsidian/appearance.json b/server/demo-template/.obsidian/appearance.json new file mode 100644 index 0000000..4dfc752 --- /dev/null +++ b/server/demo-template/.obsidian/appearance.json @@ -0,0 +1,4 @@ +{ + "baseFontSize": 16, + "theme": "obsidian" +} diff --git a/server/demo-template/.obsidian/core-plugins.json b/server/demo-template/.obsidian/core-plugins.json new file mode 100644 index 0000000..9aac971 --- /dev/null +++ b/server/demo-template/.obsidian/core-plugins.json @@ -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 +} diff --git a/server/demo/demo-capacity.html b/server/demo/demo-capacity.html new file mode 100644 index 0000000..0c9db1f --- /dev/null +++ b/server/demo/demo-capacity.html @@ -0,0 +1,32 @@ + + + + + Demo at capacity + + + +
+

Demo at capacity

+

All demo slots are currently in use.

+

Try again in a few minutes. Sessions auto-expire after a period of inactivity.

+
+ + diff --git a/server/demo/demo-cleanup.js b/server/demo/demo-cleanup.js new file mode 100644 index 0000000..9739407 --- /dev/null +++ b/server/demo/demo-cleanup.js @@ -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 }; diff --git a/server/demo/demo-middleware.js b/server/demo/demo-middleware.js new file mode 100644 index 0000000..0371f01 --- /dev/null +++ b/server/demo/demo-middleware.js @@ -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, +}; diff --git a/server/demo/demo-provision.js b/server/demo/demo-provision.js new file mode 100644 index 0000000..1d616be --- /dev/null +++ b/server/demo/demo-provision.js @@ -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, +}; diff --git a/server/demo/demo-sessions.js b/server/demo/demo-sessions.js new file mode 100644 index 0000000..b39c421 --- /dev/null +++ b/server/demo/demo-sessions.js @@ -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, 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, +}; diff --git a/server/demo/demo-ws.js b/server/demo/demo-ws.js new file mode 100644 index 0000000..6054464 --- /dev/null +++ b/server/demo/demo-ws.js @@ -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 }; diff --git a/server/demo/index.js b/server/demo/index.js new file mode 100644 index 0000000..ab0b85d --- /dev/null +++ b/server/demo/index.js @@ -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 }; diff --git a/server/index.js b/server/index.js index 35aa8e2..eaad423 100644 --- a/server/index.js +++ b/server/index.js @@ -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( + '', + '', + ); + } + 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...`); diff --git a/server/routes/bootstrap.js b/server/routes/bootstrap.js index 4c1ead6..3a09eba 100644 --- a/server/routes/bootstrap.js +++ b/server/routes/bootstrap.js @@ -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; diff --git a/src/shims/demo.js b/src/shims/demo.js new file mode 100644 index 0000000..3a5ad35 --- /dev/null +++ b/src/shims/demo.js @@ -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=. +// 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; +} diff --git a/src/shims/init.js b/src/shims/init.js index 94fefa9..1471f25 100644 --- a/src/shims/init.js +++ b/src/shims/init.js @@ -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);