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/
|
||||
plugin/main.js
|
||||
server/plugins/*/plugin/main.js
|
||||
demo-vaults/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
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 { 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
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 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...`);
|
||||
|
||||
15
server/routes/bootstrap.js
vendored
15
server/routes/bootstrap.js
vendored
@@ -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
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,
|
||||
} 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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user