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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ investigation/
|
|||||||
vaults/
|
vaults/
|
||||||
plugin/main.js
|
plugin/main.js
|
||||||
server/plugins/*/plugin/main.js
|
server/plugins/*/plugin/main.js
|
||||||
|
demo-vaults/
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ volumes:
|
|||||||
| `PUID` | User ID for file ownership | `1000` |
|
| `PUID` | User ID for file ownership | `1000` |
|
||||||
| `PGID` | Group 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` |
|
| `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
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
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).
|
||||||
37
examples/demo/docker-compose.yml
Normal file
37
examples/demo/docker-compose.yml
Normal 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
54
plugin/src/demo-guards.js
Normal 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 };
|
||||||
@@ -4,6 +4,7 @@ const { patchSettingsModal, unpatchSettingsModal } = require("./settings/inject"
|
|||||||
const pluginRegistry = require("./plugin-registry");
|
const pluginRegistry = require("./plugin-registry");
|
||||||
const { initStatusBar } = require("./status-bar");
|
const { initStatusBar } = require("./status-bar");
|
||||||
const { WorkspacePickerModal } = require("./workspace-picker");
|
const { WorkspacePickerModal } = require("./workspace-picker");
|
||||||
|
const { startDemoGuards, stopDemoGuards } = require("./demo-guards");
|
||||||
|
|
||||||
window.__obsidianAPI = require("obsidian");
|
window.__obsidianAPI = require("obsidian");
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ class IgnisBridgePlugin extends Plugin {
|
|||||||
|
|
||||||
await pluginRegistry.refresh();
|
await pluginRegistry.refresh();
|
||||||
patchSettingsModal(this);
|
patchSettingsModal(this);
|
||||||
|
startDemoGuards();
|
||||||
this._statusBarInterval = initStatusBar(this);
|
this._statusBarInterval = initStatusBar(this);
|
||||||
|
|
||||||
this.addRibbonIcon("upload", "Upload file", () => {
|
this.addRibbonIcon("upload", "Upload file", () => {
|
||||||
@@ -44,6 +46,7 @@ class IgnisBridgePlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unpatchSettingsModal(this);
|
unpatchSettingsModal(this);
|
||||||
|
stopDemoGuards();
|
||||||
console.log("[ignis-bridge] Plugin unloaded");
|
console.log("[ignis-bridge] Plugin unloaded");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,16 @@ module.exports = {
|
|||||||
? parseInt(process.env.WRITE_COALESCE_MS)
|
? parseInt(process.env.WRITE_COALESCE_MS)
|
||||||
: 5000,
|
: 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:
|
obsidianAssetsPath:
|
||||||
process.env.OBSIDIAN_ASSETS_PATH ||
|
process.env.OBSIDIAN_ASSETS_PATH ||
|
||||||
path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked"),
|
path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked"),
|
||||||
|
|||||||
1
server/demo-template/.obsidian/app.json
vendored
Normal file
1
server/demo-template/.obsidian/app.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
4
server/demo-template/.obsidian/appearance.json
vendored
Normal file
4
server/demo-template/.obsidian/appearance.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"baseFontSize": 16,
|
||||||
|
"theme": "obsidian"
|
||||||
|
}
|
||||||
18
server/demo-template/.obsidian/core-plugins.json
vendored
Normal file
18
server/demo-template/.obsidian/core-plugins.json
vendored
Normal 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
|
||||||
|
}
|
||||||
32
server/demo/demo-capacity.html
Normal file
32
server/demo/demo-capacity.html
Normal 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
101
server/demo/demo-cleanup.js
Normal 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 };
|
||||||
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,
|
||||||
|
};
|
||||||
149
server/demo/demo-provision.js
Normal file
149
server/demo/demo-provision.js
Normal 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,
|
||||||
|
};
|
||||||
126
server/demo/demo-sessions.js
Normal file
126
server/demo/demo-sessions.js
Normal 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
41
server/demo/demo-ws.js
Normal 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
94
server/demo/index.js
Normal 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 };
|
||||||
@@ -10,6 +10,7 @@ const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
|
|||||||
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
|
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
|
||||||
const pluginRoutes = require("./routes/plugins");
|
const pluginRoutes = require("./routes/plugins");
|
||||||
const { flushAll } = require("./write-coalescer");
|
const { flushAll } = require("./write-coalescer");
|
||||||
|
const { setupDemo, wireDemoWebSocket } = require("./demo");
|
||||||
|
|
||||||
const ANSI_RED = "\x1b[31m";
|
const ANSI_RED = "\x1b[31m";
|
||||||
const ANSI_YELLOW = "\x1b[33m";
|
const ANSI_YELLOW = "\x1b[33m";
|
||||||
@@ -56,6 +57,10 @@ const bootstrapRoutes = require("./routes/bootstrap");
|
|||||||
|
|
||||||
app.use("/assets", express.static(path.join(__dirname, "assets")));
|
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/fs", fsRoutes);
|
||||||
app.use("/api/vault", vaultRoutes);
|
app.use("/api/vault", vaultRoutes);
|
||||||
app.use("/api/proxy", proxyRoutes);
|
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("__SHIM_LOADER_SRC__", `shim-loader.js?v=${version}`);
|
||||||
html = html.replace("__OBSIDIAN_SCRIPTS__", JSON.stringify(scripts));
|
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;
|
cachedHtml = html;
|
||||||
return cachedHtml;
|
return cachedHtml;
|
||||||
}
|
}
|
||||||
@@ -155,12 +167,13 @@ const server = app.listen(config.port, async () => {
|
|||||||
|
|
||||||
await updateBridgePluginInAllVaults(config.vaultRoot);
|
await updateBridgePluginInAllVaults(config.vaultRoot);
|
||||||
await initPlugins({ app, config, wss, watcher });
|
await initPlugins({ app, config, wss, watcher });
|
||||||
bootstrapRoutes.warmUp().catch((e) =>
|
bootstrapRoutes
|
||||||
console.warn("[bootstrap] warm-up error:", e.message),
|
.warmUp()
|
||||||
);
|
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
const wss = setupWebSocket(server);
|
const wss = setupWebSocket(server);
|
||||||
|
wireDemoWebSocket(server);
|
||||||
|
|
||||||
async function gracefulShutdown(signal) {
|
async function gracefulShutdown(signal) {
|
||||||
console.log(`\n[ignis] Received ${signal}, shutting down gracefully...`);
|
console.log(`\n[ignis] Received ${signal}, shutting down gracefully...`);
|
||||||
|
|||||||
15
server/routes/bootstrap.js
vendored
15
server/routes/bootstrap.js
vendored
@@ -1,8 +1,7 @@
|
|||||||
// Bootstrap endpoint for cold start.
|
// Bootstrap endpoint for cold start.
|
||||||
//
|
//
|
||||||
// Combines vault info, vault list, metadata tree, and plugin list into a
|
// Combines vault info, vault list, metadata tree, and plugin list into a single pre-compressed response.
|
||||||
// single pre-compressed response. Cache is per-vault and invalidated by
|
// Cache is per-vault and invalidated by directory mtime check + explicit invalidateVault() calls from the write/delete routes.
|
||||||
// directory mtime check + explicit invalidateVault() calls from the write/delete routes.
|
|
||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -144,7 +143,8 @@ async function buildEntry(vaultId) {
|
|||||||
vault,
|
vault,
|
||||||
vaultList: buildVaultList(),
|
vaultList: buildVaultList(),
|
||||||
tree,
|
tree,
|
||||||
plugins: getDiscoveredPlugins(),
|
// In demo mode, hide server-side plugins from the client.
|
||||||
|
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsonBuf = Buffer.from(JSON.stringify(response));
|
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" });
|
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 ae = req.headers["accept-encoding"] || "";
|
||||||
const { compressed } = entry;
|
const { compressed } = entry;
|
||||||
let buf, encoding;
|
let buf, encoding;
|
||||||
|
|||||||
61
src/shims/demo.js
Normal file
61
src/shims/demo.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
initWorkspacePatch,
|
initWorkspacePatch,
|
||||||
} from "./workspace.js";
|
} from "./workspace.js";
|
||||||
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
||||||
|
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
|
||||||
|
|
||||||
function resolveVaultId() {
|
function resolveVaultId() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -213,6 +214,10 @@ function initCoreSyncGuardFallback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initialize() {
|
export function initialize() {
|
||||||
|
if (maybeProvisionDemoVault()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
resolveVaultId();
|
resolveVaultId();
|
||||||
resolveWorkspaceName();
|
resolveWorkspaceName();
|
||||||
loadPresetIfRequested();
|
loadPresetIfRequested();
|
||||||
@@ -222,6 +227,7 @@ export function initialize() {
|
|||||||
if (bootstrap) {
|
if (bootstrap) {
|
||||||
applyVaultInfo(bootstrap.vault);
|
applyVaultInfo(bootstrap.vault);
|
||||||
window.__vaultList = bootstrap.vaultList;
|
window.__vaultList = bootstrap.vaultList;
|
||||||
|
autoTrustDemoVaults(bootstrap.vaultList);
|
||||||
applyTree(bootstrap.tree);
|
applyTree(bootstrap.tree);
|
||||||
applyCoreSyncGuard(bootstrap.plugins);
|
applyCoreSyncGuard(bootstrap.plugins);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user