diff --git a/apps/ignis-server/server/index.js b/apps/ignis-server/server/index.js index 1ad05ec..051892c 100644 --- a/apps/ignis-server/server/index.js +++ b/apps/ignis-server/server/index.js @@ -213,7 +213,6 @@ const wss = setupWebSocket(server, { getVaultPath: config.getVaultPath, originAllowlist: settings.get("wsOrigins"), }); -app.set("wss", wss); wireDemoWebSocket(server); async function gracefulShutdown(signal) { diff --git a/apps/ignis-server/server/routes/proxy.js b/apps/ignis-server/server/routes/proxy.js index ac562b2..530f59c 100644 --- a/apps/ignis-server/server/routes/proxy.js +++ b/apps/ignis-server/server/routes/proxy.js @@ -100,16 +100,20 @@ router.post("/", async (req, res) => { return res.status(400).json({ error: "Missing url" }); } + const proxyMode = settings.get("proxyMode"); + + if (proxyMode === "disabled") { + return res.status(403).json({ error: "Proxy is disabled" }); + } + try { await assertPublicUrl(url); } catch (e) { return res.status(e.statusCode || 400).json({ error: e.message }); } - // When a host allowlist is defined , the proxy only reaches those hosts. - const allowlist = settings.get("proxyAllowlist"); - - if (allowlist.length > 0) { + if (proxyMode === "allowlist") { + const allowlist = settings.get("proxyAllowlist"); const host = new URL(url).hostname; if (!allowlist.includes(host)) { diff --git a/apps/ignis-server/server/routes/settings.js b/apps/ignis-server/server/routes/settings.js index 540e83d..56b6c0b 100644 --- a/apps/ignis-server/server/routes/settings.js +++ b/apps/ignis-server/server/routes/settings.js @@ -12,11 +12,21 @@ const NUMBER_KEYS = [ "writeCoalesceMs", "maxBodyBytes", ]; -const LIST_KEYS = ["proxyAllowlist", "wsOrigins"]; +const LIST_KEYS = ["proxyAllowlist"]; function validate(body) { const clean = {}; + if (body.proxyMode !== undefined) { + if (!settings.PROXY_MODES.includes(body.proxyMode)) { + throw new Error( + `proxyMode must be one of: ${settings.PROXY_MODES.join(", ")}`, + ); + } + + clean.proxyMode = body.proxyMode; + } + for (const key of NUMBER_KEYS) { if (body[key] === undefined) { continue; @@ -57,14 +67,8 @@ function validate(body) { return clean; } -function applySettings(effective, req) { +function applySettings(effective) { writeCoalescer.configure({ writeCoalesceMs: effective.writeCoalesceMs }); - - const wss = req.app.get("wss"); - - if (wss && typeof wss.setOriginAllowlist === "function") { - wss.setOriginAllowlist(effective.wsOrigins); - } } router.get("/", (req, res) => { @@ -81,7 +85,7 @@ router.post("/", (req, res) => { } const effective = settings.update(clean); - applySettings(effective, req); + applySettings(effective); // Cache sizes ride in the bootstrap response; clear it so the next page load picks up new values. bootstrapRoutes.invalidateAll(); diff --git a/apps/ignis-server/server/settings.js b/apps/ignis-server/server/settings.js index 036e952..597c2af 100644 --- a/apps/ignis-server/server/settings.js +++ b/apps/ignis-server/server/settings.js @@ -12,13 +12,20 @@ const DEFAULTS = { inputCacheTtlMs: 5 * 60 * 1000, writeCoalesceMs: 5000, maxBodyBytes: 50 * 1024 * 1024, - // Empty arrays mean "no restriction": any proxy host, any WS origin. + // "any" reaches any public host, "allowlist" restricts to proxyAllowlist, "disabled" blocks all proxying. + proxyMode: "any", + // Empty allows any public host. proxyAllowlist: [], wsOrigins: [], }; +const PROXY_MODES = ["any", "allowlist", "disabled"]; + const KEYS = Object.keys(DEFAULTS); +// Env vars only; never persisted to the settings file. +const ENV_ONLY_KEYS = ["wsOrigins"]; + // Hard ceiling for request bodies. const MAX_BODY_BACKSTOP = 500 * 1024 * 1024; @@ -56,6 +63,10 @@ function loadFile() { const clean = {}; for (const key of KEYS) { + if (ENV_ONLY_KEYS.includes(key)) { + continue; + } + if (parsed[key] !== undefined) { clean[key] = parsed[key]; } @@ -80,7 +91,7 @@ function get(key) { // Merge validated changes into the persisted file and return the new effective settings. function update(partial) { for (const [key, value] of Object.entries(partial)) { - if (KEYS.includes(key)) { + if (KEYS.includes(key) && !ENV_ONLY_KEYS.includes(key)) { fileOverrides[key] = value; } } @@ -91,4 +102,13 @@ function update(partial) { return getAll(); } -module.exports = { DEFAULTS, KEYS, MAX_BODY_BACKSTOP, getAll, get, update }; +module.exports = { + DEFAULTS, + KEYS, + ENV_ONLY_KEYS, + PROXY_MODES, + MAX_BODY_BACKSTOP, + getAll, + get, + update, +}; diff --git a/packages/bridge/src/demo-guards.js b/packages/bridge/src/demo-guards.js index 2b7783e..8a792e6 100644 --- a/packages/bridge/src/demo-guards.js +++ b/packages/bridge/src/demo-guards.js @@ -51,4 +51,4 @@ function stopDemoGuards() { } } -module.exports = { startDemoGuards, stopDemoGuards }; +module.exports = { startDemoGuards, stopDemoGuards, isDemoMode }; diff --git a/packages/bridge/src/settings/general-tab.js b/packages/bridge/src/settings/general-tab.js index bfcff57..529c5b5 100644 --- a/packages/bridge/src/settings/general-tab.js +++ b/packages/bridge/src/settings/general-tab.js @@ -1,4 +1,7 @@ -const { Setting } = require("obsidian"); +const { Setting, Notice } = require("obsidian"); +const { isDemoMode } = require("../demo-guards"); +const { stripBuildMetadata, isNewer } = require("../util/version"); +const { ListEditorModal } = require("./list-editor-modal"); const GITHUB_URL = "https://github.com/Nystik-gh/ignis"; const GITHUB_API_LATEST = @@ -8,11 +11,6 @@ function getVersion() { return window.__ignis?.version || "unknown"; } -// SemVer build metadata (`+xyz`) is informational and ignored for precedence. -function stripBuildMetadata(version) { - return (version || "").split("+")[0]; -} - async function checkForUpdate(currentVersion) { try { const res = await fetch(GITHUB_API_LATEST); @@ -25,7 +23,7 @@ async function checkForUpdate(currentVersion) { const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, "")); const current = stripBuildMetadata(currentVersion); - if (latest && latest !== current) { + if (isNewer(latest, current)) { return { version: latest, url: data.html_url }; } @@ -88,6 +86,7 @@ function display(containerEl, app) { }); addServerStatus(containerEl); + addServerSettings(containerEl, app); } const STATUS_LABELS = { @@ -102,10 +101,26 @@ const STATUS_DOT_CLASSES = { closed: "ignis-status-disconnected", }; +// Obsidian's grouped-settings container. The .setting-group > .setting-items +// structure renders its child settings inside one shared box, with an optional +// heading as a sibling above the box. The shipped stylesheet supplies the +// styling from theme variables, so the rows need no custom CSS. +function createSettingGroup(containerEl, heading) { + const group = containerEl.createDiv("setting-group"); + + if (heading) { + new Setting(group).setName(heading).setHeading(); + } + + return group.createDiv("setting-items"); +} + function addServerStatus(containerEl) { const ws = window.__ignis.ws; - const setting = new Setting(containerEl).setName("Server status"); + const items = createSettingGroup(containerEl); + + const setting = new Setting(items).setName("Server status"); const dotEl = setting.controlEl.createEl("span", { cls: "ignis-status-dot", @@ -138,4 +153,204 @@ function addServerStatus(containerEl) { }); } +const MB = 1024 * 1024; +const MINUTE = 60 * 1000; + +function addServerSettings(containerEl, app) { + if (isDemoMode()) { + const items = createSettingGroup(containerEl); + + new Setting(items) + .setName("Server settings") + .setDesc("Server settings are disabled in demo mode."); + return; + } + + const loading = containerEl.createEl("p", { + text: "Loading server settings...", + cls: "setting-item-description", + }); + + fetch("/api/settings") + .then((res) => (res.ok ? res.json() : Promise.reject(res))) + .then((current) => { + loading.remove(); + renderServerSettings(containerEl, current, app); + }) + .catch(() => { + loading.setText("Failed to load server settings."); + }); +} + +function renderServerSettings(containerEl, current, app) { + const caching = createSettingGroup(containerEl, "Caching"); + + numberField(caching, { + name: "Content cache (MB)", + desc: "Browser cache of file content. Applies after reload.", + value: Math.round(current.contentCacheBytes / MB), + key: "contentCacheBytes", + toStored: (n) => n * MB, + }); + + numberField(caching, { + name: "Input cache (MB)", + desc: "Cache for files picked for import. Applies after reload.", + value: Math.round(current.inputCacheBytes / MB), + key: "inputCacheBytes", + toStored: (n) => n * MB, + }); + + numberField(caching, { + name: "Input cache TTL (minutes)", + desc: "How long picked files stay cached. Applies after reload.", + value: Math.round(current.inputCacheTtlMs / MINUTE), + key: "inputCacheTtlMs", + toStored: (n) => n * MINUTE, + }); + + const security = createSettingGroup(containerEl, "Security"); + + numberField(security, { + name: "Max request body (MB)", + desc: "Largest request the server accepts.", + value: Math.round(current.maxBodyBytes / MB), + key: "maxBodyBytes", + toStored: (n) => n * MB, + }); + + proxyAccessField(security, current, app); + + const advanced = createSettingGroup(containerEl, "Advanced"); + + numberField(advanced, { + name: "Write coalesce window (ms)", + desc: "Debounce window for rapid writes on slow filesystems. 0 disables.", + value: current.writeCoalesceMs, + key: "writeCoalesceMs", + toStored: (n) => n, + }); +} + +// Persist a single setting. The server validates, applies the live ones, and saves. +async function saveSetting(partial) { + try { + const res = await fetch("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(partial), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Save failed"); + } + } catch (e) { + new Notice(`Failed to save setting: ${e.message}`); + } +} + +function numberField(containerEl, { name, desc, value, key, toStored }) { + new Setting(containerEl) + .setName(name) + .setDesc(desc) + .addText((text) => { + text.setValue(String(value)); + + // Commit on blur or Enter, the way a native number setting behaves. + const commit = () => { + const n = parseInt(text.getValue(), 10); + + if (Number.isInteger(n) && n >= 0) { + saveSetting({ [key]: toStored(n) }); + } + }; + + text.inputEl.addEventListener("blur", commit); + text.inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + commit(); + } + }); + }); +} + +// Proxy access mode plus the allowlist row, which only shows in "allowlist" mode. +function proxyAccessField(parent, current, app) { + let mode = current.proxyMode || "any"; + + const setting = new Setting(parent) + .setName("Proxy access") + .setDesc( + "Which external hosts Obsidian may reach through the server's CORS proxy.", + ); + + const allowlistSetting = listField(parent, { + name: "Proxy host allowlist", + desc: "Hostnames the proxy may reach, matched exactly.", + value: current.proxyAllowlist, + key: "proxyAllowlist", + app, + modal: { + placeholder: "api.example.com", + emptyNote: "No hosts yet.", + recommended: { + note: "Restricting the proxy stops Obsidian's plugin and theme browser and updates from working unless their hosts are allowed.", + hosts: ["releases.obsidian.md", "github.com", "raw.githubusercontent.com"], + buttonText: "Add recommended hosts", + }, + }, + }); + + const applyVisibility = () => { + allowlistSetting.settingEl.style.display = + mode === "allowlist" ? "" : "none"; + }; + + setting.addDropdown((dd) => { + dd.addOption("any", "Any public host"); + dd.addOption("allowlist", "Allowlist only"); + dd.addOption("disabled", "Disabled"); + dd.setValue(mode); + + dd.onChange((value) => { + mode = value; + saveSetting({ proxyMode: value }); + applyVisibility(); + }); + }); + + applyVisibility(); +} + +function listField(containerEl, { name, desc, value, key, app, modal }) { + let current = [...(value || [])]; + + const setting = new Setting(containerEl).setName(name).setDesc(desc); + + const setLabel = (btn) => + btn.setButtonText(current.length ? `Edit (${current.length})` : "Edit"); + + setting.addButton((btn) => { + setLabel(btn); + + btn.onClick(() => { + new ListEditorModal(app, { + title: name, + placeholder: modal.placeholder, + emptyNote: modal.emptyNote, + recommended: modal.recommended, + values: current, + onChange: (next) => { + current = next; + saveSetting({ [key]: current }); + setLabel(btn); + }, + }).open(); + }); + }); + + return setting; +} + module.exports = { display }; diff --git a/packages/bridge/src/settings/list-editor-modal.js b/packages/bridge/src/settings/list-editor-modal.js new file mode 100644 index 0000000..63b4af6 --- /dev/null +++ b/packages/bridge/src/settings/list-editor-modal.js @@ -0,0 +1,134 @@ +const { Modal, Setting, Notice } = require("obsidian"); + +// Modal editor for a list of string entries (the proxy host allowlist). +class ListEditorModal extends Modal { + constructor(app, opts) { + super(app); + this.opts = opts; + this.values = [...(opts.values || [])]; + } + + onOpen() { + this.titleEl.setText(this.opts.title); + + if (this.opts.recommended) { + new Setting(this.contentEl) + .setDesc(this.opts.recommended.note) + .addButton((btn) => + btn + .setButtonText( + this.opts.recommended.buttonText || "Add recommended", + ) + .onClick(() => this.addRecommended()), + ); + } + + this.listEl = this.contentEl.createDiv("ignis-list-editor"); + this.renderList(); + + new Setting(this.contentEl) + .setName("Add entry") + .addText((text) => { + this.input = text; + text.setPlaceholder(this.opts.placeholder || ""); + + text.inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + this.addCurrent(); + } + }); + }) + .addButton((btn) => + btn + .setButtonText("Add") + .setCta() + .onClick(() => this.addCurrent()), + ); + } + + addEntry(entry) { + if (this.values.includes(entry)) { + return false; + } + + this.values.push(entry); + return true; + } + + addCurrent() { + const entry = this.input.getValue().trim(); + + if (!entry) { + return; + } + + if (!this.addEntry(entry)) { + new Notice("That entry is already in the list."); + return; + } + + this.input.setValue(""); + this.input.inputEl.focus(); + this.commit(); + this.renderList(); + } + + addRecommended() { + let added = 0; + + for (const host of this.opts.recommended.hosts) { + if (this.addEntry(host)) { + added++; + } + } + + if (added > 0) { + this.commit(); + this.renderList(); + } + + new Notice( + added > 0 + ? `Added ${added} host${added === 1 ? "" : "s"}.` + : "All recommended hosts are already in the list.", + ); + } + + remove(entry) { + this.values = this.values.filter((v) => v !== entry); + this.commit(); + this.renderList(); + } + + renderList() { + this.listEl.empty(); + + if (this.values.length === 0) { + this.listEl.createDiv({ + text: this.opts.emptyNote, + cls: "ignis-list-empty", + }); + return; + } + + for (const entry of this.values) { + new Setting(this.listEl).setName(entry).addExtraButton((btn) => + btn + .setIcon("trash-2") + .setTooltip("Remove") + .onClick(() => this.remove(entry)), + ); + } + } + + commit() { + this.opts.onChange([...this.values]); + } + + onClose() { + this.contentEl.empty(); + } +} + +module.exports = { ListEditorModal }; diff --git a/packages/bridge/src/util/version.js b/packages/bridge/src/util/version.js new file mode 100644 index 0000000..c44ce81 --- /dev/null +++ b/packages/bridge/src/util/version.js @@ -0,0 +1,39 @@ +// Version comparison helpers for the update check. + +// SemVer build metadata (`+xyz`) is informational and ignored for precedence. +function stripBuildMetadata(version) { + return (version || "").split("+")[0]; +} + +// Parse X.Y.Z to [major, minor, patch], or null when it isn't three integers. +function parseSemver(version) { + const parts = (version || "").split("."); + + if (parts.length < 3) { + return null; + } + + const nums = parts.slice(0, 3).map((p) => parseInt(p, 10)); + + return nums.some((n) => !Number.isInteger(n)) ? null : nums; +} + +// True only when latest is strictly newer than current. +function isNewer(latest, current) { + const a = parseSemver(latest); + const b = parseSemver(current); + + if (!a || !b) { + return false; + } + + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) { + return a[i] > b[i]; + } + } + + return false; +} + +module.exports = { stripBuildMetadata, parseSemver, isNewer }; diff --git a/packages/bridge/styles.css b/packages/bridge/styles.css index 2820ac9..86c48a7 100644 --- a/packages/bridge/styles.css +++ b/packages/bridge/styles.css @@ -141,3 +141,18 @@ font-size: var(--font-ui-small); margin-bottom: 16px; } + +.ignis-list-editor { + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + max-height: 220px; + overflow-y: auto; + padding: 0 var(--size-4-3); + margin-bottom: var(--size-4-4); +} + +.ignis-list-empty { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + padding: var(--size-4-3) 0; +} diff --git a/packages/server-core/src/ws.js b/packages/server-core/src/ws.js index 5717de6..02416c7 100644 --- a/packages/server-core/src/ws.js +++ b/packages/server-core/src/ws.js @@ -14,14 +14,10 @@ function setupWebSocket(server, opts = {}) { throw new Error("setupWebSocket: opts.getVaultPath is required"); } - let originSet = toOriginSet(originAllowlist); + const originSet = toOriginSet(originAllowlist); const wss = new WebSocketServer({ server, path: "/ws" }); - wss.setOriginAllowlist = function (list) { - originSet = toOriginSet(list); - }; - // Global message handlers: type -> handler(msg, ws). wss.messageHandlers = new Map(); diff --git a/packages/shim/src/fs/content-cache.js b/packages/shim/src/fs/content-cache.js index f329be2..a48945d 100644 --- a/packages/shim/src/fs/content-cache.js +++ b/packages/shim/src/fs/content-cache.js @@ -10,6 +10,14 @@ export class ContentCache { this._maxSize = maxSize; } + setMaxSize(maxSize) { + this._maxSize = maxSize; + + while (this._currentSize > this._maxSize && this._cache.size > 0) { + this._evictOne(); + } + } + has(path) { return this._cache.has(this._normalize(path)); } diff --git a/packages/shim/src/fs/input-cache.js b/packages/shim/src/fs/input-cache.js index 1ed24ab..4aa32fc 100644 --- a/packages/shim/src/fs/input-cache.js +++ b/packages/shim/src/fs/input-cache.js @@ -7,8 +7,8 @@ import { normalize } from "../util/path.js"; -const MAX_SIZE = 200 * 1024 * 1024; -const TTL_MS = 5 * 60 * 1000; +let MAX_SIZE = 200 * 1024 * 1024; +let TTL_MS = 5 * 60 * 1000; const cache = new Map(); // path -> { data, size, createdAt } let currentSize = 0; @@ -112,6 +112,20 @@ export function inputCacheClear() { currentSize = 0; } +export function setInputCacheLimits({ maxSize, ttlMs }) { + if (Number.isFinite(maxSize)) { + MAX_SIZE = maxSize; + + while (currentSize > MAX_SIZE && cache.size > 0) { + evictOldest(); + } + } + + if (Number.isFinite(ttlMs)) { + TTL_MS = ttlMs; + } +} + export function isInputCachePath(path) { const norm = normalize(path); return norm.startsWith(".obsidian/imports/"); diff --git a/packages/shim/src/init.js b/packages/shim/src/init.js index 0a94543..c9154b1 100644 --- a/packages/shim/src/init.js +++ b/packages/shim/src/init.js @@ -8,11 +8,26 @@ import { initWorkspacePatch, } from "./workspace.js"; import { prefetchVaultContent } from "./fs/indexer-prefetch.js"; +import { setInputCacheLimits } from "./fs/input-cache.js"; import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js"; import { initNativeMenuGuard } from "./native-menu-guard.js"; let bootstrapVirtualPlugins = []; +// Cache sizes come from the bootstrap response and are applied at page load. +// The server owns the rest of the settings and applies them on its side. +function applyServerSettings(s) { + if (!s) { + return; + } + + if (Number.isFinite(s.contentCacheBytes)) { + fsShim._contentCache.setMaxSize(s.contentCacheBytes); + } + + setInputCacheLimits({ maxSize: s.inputCacheBytes, ttlMs: s.inputCacheTtlMs }); +} + export function getBootstrapVirtualPlugins() { return bootstrapVirtualPlugins; } @@ -212,6 +227,7 @@ export function initialize() { applyTree(bootstrap.tree); applyCoreSyncGuard(bootstrap.plugins); bootstrapVirtualPlugins = bootstrap.virtualPlugins || []; + applyServerSettings(bootstrap.settings); // Race the indexer: batch-fetch text content into ContentCache so // Obsidian's startup indexing reads hit the cache instead of the network.