From 44bb01f16273bc47bf4de3d4de7cdb0ba7cb4b7d Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Fri, 5 Jun 2026 23:56:59 +0200 Subject: [PATCH 01/12] consolidate cross-origin proxy and add ssrf guard --- apps/ignis-server/server/routes/proxy.js | 121 ++++++++++++++++++++- packages/shim/src/electron/ipc-renderer.js | 42 ++----- packages/shim/src/globals.js | 34 ++---- packages/shim/src/request-url.js | 42 ++----- packages/shim/src/util/proxy.js | 54 +++++++++ 5 files changed, 199 insertions(+), 94 deletions(-) create mode 100644 packages/shim/src/util/proxy.js diff --git a/apps/ignis-server/server/routes/proxy.js b/apps/ignis-server/server/routes/proxy.js index 57eba76..a55f19b 100644 --- a/apps/ignis-server/server/routes/proxy.js +++ b/apps/ignis-server/server/routes/proxy.js @@ -1,9 +1,97 @@ const express = require("express"); +const dns = require("dns").promises; +const net = require("net"); const router = express.Router(); -// POST /api/proxy - forward a request to an external URL to bypass CORS -// Used by the requestUrl shim for plugin installation, etc. +const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; + +function isPrivateIp(ip) { + const type = net.isIP(ip); + + if (type === 4) { + const o = ip.split(".").map(Number); + + return ( + o[0] === 0 || + o[0] === 10 || + o[0] === 127 || + (o[0] === 169 && o[1] === 254) || + (o[0] === 172 && o[1] >= 16 && o[1] <= 31) || + (o[0] === 192 && o[1] === 168) || + (o[0] === 100 && o[1] >= 64 && o[1] <= 127) + ); + } + + if (type === 6) { + const a = ip.toLowerCase(); + + if (a === "::1" || a === "::") { + return true; + } + + if (/^fe[89ab]/.test(a) || a.startsWith("fc") || a.startsWith("fd")) { + return true; + } + + const mapped = a.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + + if (mapped) { + return isPrivateIp(mapped[1]); + } + + return false; + } + + return false; +} + +function httpError(status, message) { + const e = new Error(message); + e.statusCode = status; + return e; +} + +// Reject non-http(s) schemes and hosts that resolve to a private or link-local address. +async function assertPublicUrl(urlStr) { + let parsed; + + try { + parsed = new URL(urlStr); + } catch { + throw httpError(400, "Invalid URL"); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw httpError(400, "Only http and https URLs are allowed"); + } + + const host = parsed.hostname; + + if (net.isIP(host)) { + if (isPrivateIp(host)) { + throw httpError(403, "Host not allowed"); + } + + return; + } + + let addrs; + + try { + addrs = await dns.lookup(host, { all: true }); + } catch { + throw httpError(502, "DNS resolution failed"); + } + + for (const a of addrs) { + if (isPrivateIp(a.address)) { + throw httpError(403, "Host resolves to a private address"); + } + } +} + +// POST /api/proxy - forward a request to an external URL to bypass CORS. router.post("/", async (req, res) => { const { url, method, headers, body, binary } = req.body; @@ -12,6 +100,13 @@ router.post("/", async (req, res) => { } try { + await assertPublicUrl(url); + } catch (e) { + return res.status(e.statusCode || 400).json({ error: e.message }); + } + + try { + // Forward the caller's headers as-is. const fetchOpts = { method: method || "GET", headers: headers || {}, @@ -26,10 +121,25 @@ router.post("/", async (req, res) => { } const upstream = await fetch(url, fetchOpts); - const respBody = Buffer.from(await upstream.arrayBuffer()); - // Forward response headers, stripping hop-by-hop / encoding headers - // since the body is already decompressed by Node's fetch + const declaredLength = Number(upstream.headers.get("content-length")); + + if ( + Number.isFinite(declaredLength) && + declaredLength > MAX_RESPONSE_BYTES + ) { + return res.status(413).json({ error: "Upstream response too large" }); + } + + const respArrayBuf = await upstream.arrayBuffer(); + + if (respArrayBuf.byteLength > MAX_RESPONSE_BYTES) { + return res.status(413).json({ error: "Upstream response too large" }); + } + + const respBody = Buffer.from(respArrayBuf); + + // Strip hop-by-hop / encoding headers since the body is already decompressed. const skipHeaders = new Set([ "content-encoding", "transfer-encoding", @@ -37,6 +147,7 @@ router.post("/", async (req, res) => { "connection", ]); const respHeaders = {}; + upstream.headers.forEach((val, key) => { if (!skipHeaders.has(key)) { respHeaders[key] = val; diff --git a/packages/shim/src/electron/ipc-renderer.js b/packages/shim/src/electron/ipc-renderer.js index 57b198c..2326bd6 100644 --- a/packages/shim/src/electron/ipc-renderer.js +++ b/packages/shim/src/electron/ipc-renderer.js @@ -1,6 +1,6 @@ import { showVaultManager } from "../ui-registry.js"; import { vaultService } from "@ignis/services"; -import { arrayBufferToBase64, base64ToArrayBuffer } from "../util/base64.js"; +import { proxyFetch } from "../util/proxy.js"; const listeners = new Map(); @@ -88,41 +88,19 @@ const syncHandlers = { async function handleRequestUrl(requestId, request) { try { - let body = request.body; - let binary = false; - - if (body instanceof ArrayBuffer) { - body = arrayBufferToBase64(body); - binary = true; - } - - const res = await fetch("/api/proxy", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: request.url, - method: request.method || "GET", - headers: request.headers || {}, - contentType: request.contentType, - body, - binary, - }), + const result = await proxyFetch({ + url: request.url, + method: request.method, + headers: request.headers, + body: request.body, + contentType: request.contentType, }); - const proxyResult = await res.json(); - - if (!res.ok) { - ipcRenderer._emit(requestId, { - error: proxyResult.error || "Proxy request failed", - }); - return; - } - // Electron's e.reply(requestId, data) sends on the requestId channel ipcRenderer._emit(requestId, { - status: proxyResult.status, - headers: proxyResult.headers, - body: base64ToArrayBuffer(proxyResult.body), + status: result.status, + headers: result.headers, + body: result.body, }); } catch (e) { ipcRenderer._emit(requestId, { diff --git a/packages/shim/src/globals.js b/packages/shim/src/globals.js index ffc04f7..7a74b80 100644 --- a/packages/shim/src/globals.js +++ b/packages/shim/src/globals.js @@ -4,8 +4,8 @@ import { unregisterPopupWindow, } from "./electron/remote/window.js"; import { showVaultManager } from "./ui-registry.js"; -import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js"; import { isSameOrigin } from "./util/url.js"; +import { proxyFetch } from "./util/proxy.js"; function installProcess() { window.process = processShim; @@ -167,17 +167,15 @@ function installFetchShim() { } let body = null; - let binary = false; if (init?.body && method !== "GET" && method !== "HEAD") { if (typeof init.body === "string") { body = init.body; - } else if (init.body instanceof ArrayBuffer) { - body = arrayBufferToBase64(init.body); - binary = true; - } else if (init.body instanceof Uint8Array) { - body = arrayBufferToBase64(init.body.buffer); - binary = true; + } else if ( + init.body instanceof ArrayBuffer || + init.body instanceof Uint8Array + ) { + body = init.body; } else if (typeof init.body === "object") { body = JSON.stringify(init.body); } else { @@ -187,23 +185,15 @@ function installFetchShim() { console.log("[shim:fetch] Proxying cross-origin:", method, url); - const proxyRes = await originalFetch("/api/proxy", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url, method, headers, body, binary }), - }); + let result; - if (!proxyRes.ok) { - const err = await proxyRes - .json() - .catch(() => ({ error: "Proxy request failed" })); - throw new TypeError(err.error || "Failed to fetch"); + try { + result = await proxyFetch({ url, method, headers, body }); + } catch (e) { + throw new TypeError(e.message || "Failed to fetch"); } - const result = await proxyRes.json(); - const respBody = base64ToArrayBuffer(result.body); - - return new Response(respBody, { + return new Response(result.body, { status: result.status, headers: result.headers, }); diff --git a/packages/shim/src/request-url.js b/packages/shim/src/request-url.js index 39f304e..5c60c4e 100644 --- a/packages/shim/src/request-url.js +++ b/packages/shim/src/request-url.js @@ -2,7 +2,7 @@ // Obsidian sets window.requestUrl in app.js, so we override it after app.js loads. import { isSameOrigin } from "./util/url.js"; -import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js"; +import { proxyFetch } from "./util/proxy.js"; async function proxyRequestUrl(request) { if (typeof request === "string") { @@ -28,42 +28,14 @@ async function proxyRequestUrl(request) { } // Cross-origin: route through server proxy - let body = request.body; - let binary = false; - - if (body instanceof ArrayBuffer) { - body = arrayBufferToBase64(body); - binary = true; - } - - const res = await fetch("/api/proxy", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: request.url, - method: request.method || "GET", - headers: request.headers || {}, - body, - binary, - }), + const result = await proxyFetch({ + url: request.url, + method: request.method, + headers: request.headers, + body: request.body, }); - if (!res.ok) { - const err = await res - .json() - .catch(() => ({ error: "Proxy request failed" })); - throw new Error(err.error); - } - - const proxyResult = await res.json(); - const arrayBuf = base64ToArrayBuffer(proxyResult.body); - - return makeResponse( - request, - proxyResult.status, - proxyResult.headers, - arrayBuf, - ); + return makeResponse(request, result.status, result.headers, result.body); } function makeResponse(request, status, headers, arrayBuf) { diff --git a/packages/shim/src/util/proxy.js b/packages/shim/src/util/proxy.js new file mode 100644 index 0000000..df5d030 --- /dev/null +++ b/packages/shim/src/util/proxy.js @@ -0,0 +1,54 @@ +// Single round-trip through the server's /api/proxy endpoint for cross-origin requests. +// Encodes a binary request body to base64, returns the upstream response with its body as an ArrayBuffer. +// Throws an Error carrying the server's message on failure. + +import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js"; + +export async function proxyFetch({ url, method, headers, body, contentType }) { + let encodedBody = null; + let binary = false; + + if (body instanceof ArrayBuffer) { + encodedBody = arrayBufferToBase64(body); + binary = true; + } else if (body instanceof Uint8Array) { + encodedBody = arrayBufferToBase64(body.buffer); + binary = true; + } else if (body != null) { + encodedBody = body; + } + + const payload = { + url, + method: method || "GET", + headers: headers || {}, + body: encodedBody, + binary, + }; + + if (contentType !== undefined) { + payload.contentType = contentType; + } + + // Use native fetch to avoid an unnecessary call through the shim. proxy is already same origin. + const nativeFetch = window.__originalFetch || fetch; + + const res = await nativeFetch("/api/proxy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || "Proxy request failed"); + } + + const result = await res.json(); + + return { + status: result.status, + headers: result.headers, + body: base64ToArrayBuffer(result.body), + }; +} From 3129ed377c67a6cb65f99c67ed116cb9f5353bb1 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Fri, 5 Jun 2026 23:57:38 +0200 Subject: [PATCH 02/12] use up to date basic_auth directive in caddy. --- apps/ignis-server/examples/caddy-basic-auth/Caddyfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ignis-server/examples/caddy-basic-auth/Caddyfile b/apps/ignis-server/examples/caddy-basic-auth/Caddyfile index c39f9d9..1fc86af 100644 --- a/apps/ignis-server/examples/caddy-basic-auth/Caddyfile +++ b/apps/ignis-server/examples/caddy-basic-auth/Caddyfile @@ -1,6 +1,6 @@ # Replace with your domain, or use :443 for local access with a self-signed cert. ignis.example.com { - basicauth { + basic_auth { # Username: admin # Replace the hash below with your own. Generate one with: # docker run --rm caddy:2 caddy hash-password --plaintext YOUR_PASSWORD From 938a69879569849ab45e06ae38ccf0e0afa28f95 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sat, 6 Jun 2026 01:16:01 +0200 Subject: [PATCH 03/12] add server settings store --- apps/ignis-server/server/config.js | 10 --- apps/ignis-server/server/index.js | 5 +- apps/ignis-server/server/settings.js | 91 ++++++++++++++++++++++++++++ packages/server-core/src/ws.js | 15 +++-- 4 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 apps/ignis-server/server/settings.js diff --git a/apps/ignis-server/server/config.js b/apps/ignis-server/server/config.js index 0538dc8..637cc68 100644 --- a/apps/ignis-server/server/config.js +++ b/apps/ignis-server/server/config.js @@ -75,16 +75,6 @@ module.exports = { vaults = discoverVaults(); return vaults; }, - writeCoalesceMs: - process.env.WRITE_COALESCE_MS !== undefined - ? parseInt(process.env.WRITE_COALESCE_MS) - : 5000, - - wsOrigins: process.env.WS_ORIGINS - ? process.env.WS_ORIGINS.split(",") - .map((s) => s.trim()) - .filter(Boolean) - : null, demoMode: process.env.DEMO_MODE === "true", demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20, diff --git a/apps/ignis-server/server/index.js b/apps/ignis-server/server/index.js index bc40c64..dc94591 100644 --- a/apps/ignis-server/server/index.js +++ b/apps/ignis-server/server/index.js @@ -3,6 +3,7 @@ const fs = require("fs"); const path = require("path"); const compression = require("compression"); const config = require("./config"); +const settings = require("./settings"); const { getVersion } = require("./version"); const { setupWebSocket, @@ -19,7 +20,7 @@ const { getBundledPluginDirs, } = require("./plugin-system/manager"); const pluginRoutes = require("./routes/plugins"); -writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs }); +writeCoalescer.configure({ writeCoalesceMs: settings.get("writeCoalesceMs") }); const { flushAll } = writeCoalescer; const { setupDemo, wireDemoWebSocket } = require("./demo"); @@ -197,7 +198,7 @@ const server = app.listen(config.port, async () => { const wss = setupWebSocket(server, { getVaultPath: config.getVaultPath, - originAllowlist: config.wsOrigins, + originAllowlist: settings.get("wsOrigins"), }); wireDemoWebSocket(server); diff --git a/apps/ignis-server/server/settings.js b/apps/ignis-server/server/settings.js new file mode 100644 index 0000000..682cd18 --- /dev/null +++ b/apps/ignis-server/server/settings.js @@ -0,0 +1,91 @@ +const fs = require("fs"); +const path = require("path"); +const config = require("./config"); + +// Runtime server settings set through UI. + +const SETTINGS_FILE = path.join(config.dataRoot, "server-settings.json"); + +const DEFAULTS = { + contentCacheBytes: 50 * 1024 * 1024, + inputCacheBytes: 200 * 1024 * 1024, + inputCacheTtlMs: 5 * 60 * 1000, + writeCoalesceMs: 5000, + maxBodyBytes: 50 * 1024 * 1024, + // Empty arrays mean "no restriction": any proxy host, any WS origin. + proxyAllowlist: [], + wsOrigins: [], +}; + +const KEYS = Object.keys(DEFAULTS); + +function parseList(raw) { + return raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +function fromEnv() { + const env = {}; + + if (process.env.WRITE_COALESCE_MS !== undefined) { + const n = parseInt(process.env.WRITE_COALESCE_MS, 10); + + if (Number.isFinite(n)) { + env.writeCoalesceMs = n; + } + } + + if (process.env.WS_ORIGINS) { + env.wsOrigins = parseList(process.env.WS_ORIGINS); + } + + return env; +} + +const envOverrides = fromEnv(); + +function loadFile() { + try { + const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")); + // Keep only known keys so a stale or hand-edited file can't inject junk. + const clean = {}; + + for (const key of KEYS) { + if (parsed[key] !== undefined) { + clean[key] = parsed[key]; + } + } + + return clean; + } catch { + return {}; + } +} + +let fileOverrides = loadFile(); + +function getAll() { + return { ...DEFAULTS, ...envOverrides, ...fileOverrides }; +} + +function get(key) { + return getAll()[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)) { + fileOverrides[key] = value; + } + } + + fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true }); + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(fileOverrides, null, 2)); + + return getAll(); +} + +module.exports = { DEFAULTS, KEYS, getAll, get, update }; diff --git a/packages/server-core/src/ws.js b/packages/server-core/src/ws.js index 9b42e46..5717de6 100644 --- a/packages/server-core/src/ws.js +++ b/packages/server-core/src/ws.js @@ -2,6 +2,11 @@ const { WebSocketServer } = require("ws"); const url = require("url"); const watcher = require("./watcher"); +// Null / undefined / empty array means no Origin check. +function toOriginSet(list) { + return Array.isArray(list) && list.length > 0 ? new Set(list) : null; +} + function setupWebSocket(server, opts = {}) { const { getVaultPath, originAllowlist } = opts; @@ -9,14 +14,14 @@ function setupWebSocket(server, opts = {}) { throw new Error("setupWebSocket: opts.getVaultPath is required"); } - // Null / undefined / empty array = no Origin check. - const originSet = - Array.isArray(originAllowlist) && originAllowlist.length > 0 - ? new Set(originAllowlist) - : null; + let 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(); From b43d12f702e41fa5eba42a20a2dab3b3a8d568cf Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sat, 6 Jun 2026 13:04:34 +0200 Subject: [PATCH 04/12] add server settings api --- apps/ignis-server/server/demo/index.js | 5 ++ apps/ignis-server/server/index.js | 16 +++- apps/ignis-server/server/routes/bootstrap.js | 11 +++ apps/ignis-server/server/routes/proxy.js | 14 +++ apps/ignis-server/server/routes/settings.js | 92 ++++++++++++++++++++ apps/ignis-server/server/settings.js | 5 +- 6 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 apps/ignis-server/server/routes/settings.js diff --git a/apps/ignis-server/server/demo/index.js b/apps/ignis-server/server/demo/index.js index ab0b85d..11f1b3a 100644 --- a/apps/ignis-server/server/demo/index.js +++ b/apps/ignis-server/server/demo/index.js @@ -70,6 +70,11 @@ function setupDemo(app) { // Hide server-side plugins (headless-sync) from the demo UI app.use("/api/plugins", pluginsBlocker); + // Server settings are-fixed in demo mode. + app.use("/api/settings", (req, res) => { + res.status(403).json({ error: "Settings are disabled in demo mode" }); + }); + // Cleanup timer const interval = setInterval(() => { cleanupExpired().catch((e) => diff --git a/apps/ignis-server/server/index.js b/apps/ignis-server/server/index.js index dc94591..1ad05ec 100644 --- a/apps/ignis-server/server/index.js +++ b/apps/ignis-server/server/index.js @@ -33,7 +33,18 @@ const ANSI_RESET = "\x1b[0m"; const app = express(); -app.use(express.json({ limit: "50mb" })); +// Reject oversized requests by Content-Length before parsing. +app.use((req, res, next) => { + const declared = Number(req.headers["content-length"]); + + if (Number.isFinite(declared) && declared > settings.get("maxBodyBytes")) { + return res.status(413).json({ error: "Request body too large" }); + } + + next(); +}); + +app.use(express.json({ limit: settings.MAX_BODY_BACKSTOP })); app.use(compression()); // logger middleware @@ -67,6 +78,7 @@ const fsRoutes = require("./routes/fs"); const vaultRoutes = require("./routes/vault"); const proxyRoutes = require("./routes/proxy"); const versionRoutes = require("./routes/version"); +const settingsRoutes = require("./routes/settings"); const bootstrapRoutes = require("./routes/bootstrap"); app.use("/assets", express.static(path.join(__dirname, "assets"))); @@ -79,6 +91,7 @@ app.use("/api/fs", fsRoutes); app.use("/api/vault", vaultRoutes); app.use("/api/proxy", proxyRoutes); app.use("/api/version", versionRoutes); +app.use("/api/settings", settingsRoutes); app.use("/api/plugins", pluginRoutes); app.use("/api/bootstrap", bootstrapRoutes); @@ -200,6 +213,7 @@ 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/bootstrap.js b/apps/ignis-server/server/routes/bootstrap.js index 04fdc99..11e97cf 100644 --- a/apps/ignis-server/server/routes/bootstrap.js +++ b/apps/ignis-server/server/routes/bootstrap.js @@ -14,6 +14,7 @@ const { getVirtualPluginsForVault, } = require("../plugin-system/manager"); const { getVersion } = require("../version"); +const settings = require("../settings"); const router = express.Router(); @@ -140,6 +141,11 @@ async function buildEntry(vaultId) { // In demo mode, hide server-side plugins from the client. plugins: config.demoMode ? [] : getDiscoveredPlugins(), virtualPlugins: getVirtualPluginsForVault(vaultId, getVersion()), + settings: { + contentCacheBytes: settings.get("contentCacheBytes"), + inputCacheBytes: settings.get("inputCacheBytes"), + inputCacheTtlMs: settings.get("inputCacheTtlMs"), + }, }; const jsonBuf = Buffer.from(JSON.stringify(response)); @@ -185,6 +191,10 @@ function invalidateVault(vaultId) { cache.delete(vaultId); } +function invalidateAll() { + cache.clear(); +} + async function warmUp() { const ids = Object.keys(config.vaults); @@ -251,4 +261,5 @@ router.get("/", async (req, res) => { module.exports = router; module.exports.invalidateVault = invalidateVault; +module.exports.invalidateAll = invalidateAll; module.exports.warmUp = warmUp; diff --git a/apps/ignis-server/server/routes/proxy.js b/apps/ignis-server/server/routes/proxy.js index a55f19b..ac562b2 100644 --- a/apps/ignis-server/server/routes/proxy.js +++ b/apps/ignis-server/server/routes/proxy.js @@ -1,6 +1,7 @@ const express = require("express"); const dns = require("dns").promises; const net = require("net"); +const settings = require("../settings"); const router = express.Router(); @@ -105,6 +106,19 @@ router.post("/", async (req, res) => { 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) { + const host = new URL(url).hostname; + + if (!allowlist.includes(host)) { + return res + .status(403) + .json({ error: `Host not in proxy allowlist: ${host}` }); + } + } + try { // Forward the caller's headers as-is. const fetchOpts = { diff --git a/apps/ignis-server/server/routes/settings.js b/apps/ignis-server/server/routes/settings.js new file mode 100644 index 0000000..540e83d --- /dev/null +++ b/apps/ignis-server/server/routes/settings.js @@ -0,0 +1,92 @@ +const express = require("express"); +const { writeCoalescer } = require("@ignis/server-core"); +const settings = require("../settings"); +const bootstrapRoutes = require("./bootstrap"); + +const router = express.Router(); + +const NUMBER_KEYS = [ + "contentCacheBytes", + "inputCacheBytes", + "inputCacheTtlMs", + "writeCoalesceMs", + "maxBodyBytes", +]; +const LIST_KEYS = ["proxyAllowlist", "wsOrigins"]; + +function validate(body) { + const clean = {}; + + for (const key of NUMBER_KEYS) { + if (body[key] === undefined) { + continue; + } + + const n = body[key]; + + if (!Number.isInteger(n) || n < 0) { + throw new Error(`${key} must be a non-negative integer`); + } + + if (key === "maxBodyBytes" && (n < 1 || n > settings.MAX_BODY_BACKSTOP)) { + throw new Error( + `maxBodyBytes must be between 1 and ${settings.MAX_BODY_BACKSTOP}`, + ); + } + + clean[key] = n; + } + + for (const key of LIST_KEYS) { + if (body[key] === undefined) { + continue; + } + + const list = body[key]; + + if ( + !Array.isArray(list) || + list.some((v) => typeof v !== "string" || !v.trim()) + ) { + throw new Error(`${key} must be an array of non-empty strings`); + } + + clean[key] = list.map((v) => v.trim()); + } + + return clean; +} + +function applySettings(effective, req) { + 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) => { + res.json(settings.getAll()); +}); + +router.post("/", (req, res) => { + let clean; + + try { + clean = validate(req.body || {}); + } catch (e) { + return res.status(400).json({ error: e.message }); + } + + const effective = settings.update(clean); + applySettings(effective, req); + + // Cache sizes ride in the bootstrap response; clear it so the next page load picks up new values. + bootstrapRoutes.invalidateAll(); + + res.json(effective); +}); + +module.exports = router; diff --git a/apps/ignis-server/server/settings.js b/apps/ignis-server/server/settings.js index 682cd18..036e952 100644 --- a/apps/ignis-server/server/settings.js +++ b/apps/ignis-server/server/settings.js @@ -19,6 +19,9 @@ const DEFAULTS = { const KEYS = Object.keys(DEFAULTS); +// Hard ceiling for request bodies. +const MAX_BODY_BACKSTOP = 500 * 1024 * 1024; + function parseList(raw) { return raw .split(",") @@ -88,4 +91,4 @@ function update(partial) { return getAll(); } -module.exports = { DEFAULTS, KEYS, getAll, get, update }; +module.exports = { DEFAULTS, KEYS, MAX_BODY_BACKSTOP, getAll, get, update }; From a7824ac2840c84b884994ab4ed3f4e0da6fd556e Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sat, 6 Jun 2026 17:05:26 +0200 Subject: [PATCH 05/12] add server settings UI and enforcement --- apps/ignis-server/server/index.js | 1 - apps/ignis-server/server/routes/proxy.js | 12 +- apps/ignis-server/server/routes/settings.js | 22 +- apps/ignis-server/server/settings.js | 26 +- packages/bridge/src/demo-guards.js | 2 +- packages/bridge/src/settings/general-tab.js | 231 +++++++++++++++++- .../bridge/src/settings/list-editor-modal.js | 134 ++++++++++ packages/bridge/src/util/version.js | 39 +++ packages/bridge/styles.css | 15 ++ packages/server-core/src/ws.js | 6 +- packages/shim/src/fs/content-cache.js | 8 + packages/shim/src/fs/input-cache.js | 18 +- packages/shim/src/init.js | 16 ++ 13 files changed, 497 insertions(+), 33 deletions(-) create mode 100644 packages/bridge/src/settings/list-editor-modal.js create mode 100644 packages/bridge/src/util/version.js 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. From 7688de599a7a6cf1dc92c485a7a898b4f2a1ea88 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sat, 6 Jun 2026 17:25:12 +0200 Subject: [PATCH 06/12] fix native menu guard, fix /app/data file permission. --- apps/ignis-server/scripts/entrypoint.sh | 5 +++-- packages/shim/src/native-menu-guard.js | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/ignis-server/scripts/entrypoint.sh b/apps/ignis-server/scripts/entrypoint.sh index 3ba813d..4bb473c 100644 --- a/apps/ignis-server/scripts/entrypoint.sh +++ b/apps/ignis-server/scripts/entrypoint.sh @@ -23,8 +23,9 @@ else echo "[ignis] Using existing user $RUN_USER (UID $PUID)" fi -# Fix ownership of volumes -chown -R "$PUID:$PGID" /vaults /app/obsidian-app + +mkdir -p /app/data +chown -R "$PUID:$PGID" /vaults /app/obsidian-app /app/data OBSIDIAN_DIR="/app/obsidian-app" OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}" diff --git a/packages/shim/src/native-menu-guard.js b/packages/shim/src/native-menu-guard.js index 93b6161..0cdff52 100644 --- a/packages/shim/src/native-menu-guard.js +++ b/packages/shim/src/native-menu-guard.js @@ -49,7 +49,8 @@ function readTransform(data) { try { const obj = JSON.parse(text); - if (obj.nativeMenus) { + // force native menus to false since its never appropriate in a browser context. + if (obj.nativeMenus !== false) { obj.nativeMenus = false; return JSON.stringify(obj); } @@ -100,6 +101,9 @@ function patchSetConfig() { }; vault.__ignisNativeMenuGuarded = true; + // set to false to override any platform default (like macOS). + vault.setConfig("nativeMenus", false); + return true; }; From 04be97e48cf0a129c86242224a57dc9116551b7f Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sat, 6 Jun 2026 18:29:48 +0200 Subject: [PATCH 07/12] add unit tests for ssrf guard, version compare, and settings validation --- apps/ignis-server/server/routes/proxy.js | 1 + .../ignis-server/server/routes/proxy.test.mjs | 62 +++++++++++++++++++ apps/ignis-server/server/routes/settings.js | 1 + .../server/routes/settings.test.mjs | 47 ++++++++++++++ packages/bridge/src/util/version.test.mjs | 31 ++++++++++ 5 files changed, 142 insertions(+) create mode 100644 apps/ignis-server/server/routes/proxy.test.mjs create mode 100644 apps/ignis-server/server/routes/settings.test.mjs create mode 100644 packages/bridge/src/util/version.test.mjs diff --git a/apps/ignis-server/server/routes/proxy.js b/apps/ignis-server/server/routes/proxy.js index 530f59c..4b5fb97 100644 --- a/apps/ignis-server/server/routes/proxy.js +++ b/apps/ignis-server/server/routes/proxy.js @@ -183,3 +183,4 @@ router.post("/", async (req, res) => { }); module.exports = router; +module.exports.isPrivateIp = isPrivateIp; diff --git a/apps/ignis-server/server/routes/proxy.test.mjs b/apps/ignis-server/server/routes/proxy.test.mjs new file mode 100644 index 0000000..6ddd3d2 --- /dev/null +++ b/apps/ignis-server/server/routes/proxy.test.mjs @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { isPrivateIp } = require("./proxy.js"); + +describe("isPrivateIp", () => { + it("flags private and link-local IPv4", () => { + for (const ip of [ + "0.0.0.0", + "10.0.0.1", + "127.0.0.1", + "169.254.1.1", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "100.64.0.1", + "100.127.255.255", + ]) { + expect(isPrivateIp(ip), ip).toBe(true); + } + }); + + it("allows public IPv4, including range boundaries", () => { + for (const ip of [ + "8.8.8.8", + "1.1.1.1", + "172.15.255.255", + "172.32.0.0", + "100.63.255.255", + "100.128.0.0", + "169.253.0.0", + "169.255.0.0", + "11.0.0.1", + "192.169.0.1", + ]) { + expect(isPrivateIp(ip), ip).toBe(false); + } + }); + + it("flags private and link-local IPv6", () => { + for (const ip of ["::1", "::", "fc00::1", "fd12::1", "fe80::1", "feaf::1"]) { + expect(isPrivateIp(ip), ip).toBe(true); + } + }); + + it("allows public IPv6", () => { + for (const ip of ["2606:4700:4700::1111", "2001:4860:4860::8888"]) { + expect(isPrivateIp(ip), ip).toBe(false); + } + }); + + it("classifies IPv4-mapped IPv6 by the embedded address", () => { + expect(isPrivateIp("::ffff:127.0.0.1")).toBe(true); + expect(isPrivateIp("::ffff:8.8.8.8")).toBe(false); + }); + + it("returns false for non-IP input", () => { + expect(isPrivateIp("not-an-ip")).toBe(false); + expect(isPrivateIp("")).toBe(false); + }); +}); diff --git a/apps/ignis-server/server/routes/settings.js b/apps/ignis-server/server/routes/settings.js index 56b6c0b..1171660 100644 --- a/apps/ignis-server/server/routes/settings.js +++ b/apps/ignis-server/server/routes/settings.js @@ -94,3 +94,4 @@ router.post("/", (req, res) => { }); module.exports = router; +module.exports.validate = validate; diff --git a/apps/ignis-server/server/routes/settings.test.mjs b/apps/ignis-server/server/routes/settings.test.mjs new file mode 100644 index 0000000..5afa6dc --- /dev/null +++ b/apps/ignis-server/server/routes/settings.test.mjs @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { validate } = require("./settings.js"); +const settings = require("../settings.js"); + +describe("settings validate", () => { + it("rejects an unknown proxy mode", () => { + expect(() => validate({ proxyMode: "bogus" })).toThrow(); + }); + + it("rejects negative or non-integer numbers", () => { + expect(() => validate({ contentCacheBytes: -1 })).toThrow(); + expect(() => validate({ contentCacheBytes: 1.5 })).toThrow(); + expect(() => validate({ contentCacheBytes: "5" })).toThrow(); + }); + + it("enforces maxBodyBytes bounds", () => { + expect(() => validate({ maxBodyBytes: 0 })).toThrow(); + expect(() => + validate({ maxBodyBytes: settings.MAX_BODY_BACKSTOP + 1 }), + ).toThrow(); + expect(validate({ maxBodyBytes: 1048576 })).toEqual({ + maxBodyBytes: 1048576, + }); + }); + + it("trims a valid proxy allowlist", () => { + expect( + validate({ proxyAllowlist: [" api.example.com ", "github.com"] }), + ).toEqual({ proxyAllowlist: ["api.example.com", "github.com"] }); + }); + + it("rejects a non-array allowlist or an empty entry", () => { + expect(() => validate({ proxyAllowlist: "x" })).toThrow(); + expect(() => validate({ proxyAllowlist: ["ok", " "] })).toThrow(); + }); + + it("ignores wsOrigins, which is env-only", () => { + expect(validate({ wsOrigins: ["https://evil.example.com"] })).toEqual({}); + }); + + it("ignores unknown keys", () => { + expect(validate({ bogusKey: 1 })).toEqual({}); + }); +}); diff --git a/packages/bridge/src/util/version.test.mjs b/packages/bridge/src/util/version.test.mjs new file mode 100644 index 0000000..9472995 --- /dev/null +++ b/packages/bridge/src/util/version.test.mjs @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { stripBuildMetadata, isNewer } = require("./version.js"); + +describe("isNewer", () => { + it("is true when latest is strictly newer", () => { + expect(isNewer("0.8.4", "0.8.3")).toBe(true); + expect(isNewer("1.0.0", "0.9.9")).toBe(true); + expect(isNewer("0.9.0", "0.8.9")).toBe(true); + }); + + it("is false for older or equal, so no downgrade is prompted", () => { + expect(isNewer("0.8.3", "0.8.4")).toBe(false); + expect(isNewer("0.8.4", "0.8.4")).toBe(false); + expect(isNewer("0.9.9", "1.0.0")).toBe(false); + }); + + it("is false for malformed versions", () => { + expect(isNewer("x", "0.8.4")).toBe(false); + expect(isNewer("0.8", "0.8.4")).toBe(false); + expect(isNewer("1.x.0", "0.8.4")).toBe(false); + }); + + it("ignores build metadata, so an equal version with a build tag is not newer", () => { + expect( + isNewer(stripBuildMetadata("0.8.4"), stripBuildMetadata("0.8.4+q2fmfox")), + ).toBe(false); + }); +}); From a51b2d3ffa1070ad0cfa0074bc542674a0220ef1 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sat, 6 Jun 2026 19:28:17 +0200 Subject: [PATCH 08/12] convert bridge to ESM --- packages/bridge/package.json | 1 + packages/bridge/src/demo-guards.js | 2 +- packages/bridge/src/file-actions.js | 4 ++-- packages/bridge/src/main.js | 20 +++++++++---------- packages/bridge/src/plugin-registry.js | 2 +- packages/bridge/src/settings/general-tab.js | 10 +++++----- packages/bridge/src/settings/inject.js | 12 +++++------ .../bridge/src/settings/list-editor-modal.js | 4 ++-- packages/bridge/src/settings/plugin-tabs.js | 8 ++++---- .../bridge/src/settings/server-plugins-tab.js | 6 +++--- packages/bridge/src/settings/settings-ui.js | 4 ++-- packages/bridge/src/status-bar.js | 2 +- packages/bridge/src/util/version.js | 2 +- .../{version.test.mjs => version.test.js} | 5 +---- packages/bridge/src/workspace-picker.js | 4 ++-- packages/shim/src/loader.js | 2 +- 16 files changed, 43 insertions(+), 45 deletions(-) rename packages/bridge/src/util/{version.test.mjs => version.test.js} (85%) diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 7dadf12..347ff40 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -2,5 +2,6 @@ "name": "@ignis/bridge", "version": "0.0.0-internal", "private": true, + "type": "module", "main": "src/main.js" } diff --git a/packages/bridge/src/demo-guards.js b/packages/bridge/src/demo-guards.js index 8a792e6..fc52e0c 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, isDemoMode }; +export { startDemoGuards, stopDemoGuards, isDemoMode }; diff --git a/packages/bridge/src/file-actions.js b/packages/bridge/src/file-actions.js index 7624ac3..e680a43 100644 --- a/packages/bridge/src/file-actions.js +++ b/packages/bridge/src/file-actions.js @@ -1,4 +1,4 @@ -const { Notice, TFile, TFolder } = require("obsidian"); +import { Notice, TFile, TFolder } from "obsidian"; function getVaultId() { return window.__currentVaultId || ""; @@ -92,4 +92,4 @@ function addFolderMenuItems(menu, folder, app) { }); } -module.exports = { showFilePicker, addFileMenuItems, addFolderMenuItems }; +export { showFilePicker, addFileMenuItems, addFolderMenuItems }; diff --git a/packages/bridge/src/main.js b/packages/bridge/src/main.js index 3dae017..015584a 100644 --- a/packages/bridge/src/main.js +++ b/packages/bridge/src/main.js @@ -1,17 +1,17 @@ -const { Plugin, TFile, TFolder } = require("obsidian"); -const { +import { Plugin, TFile, TFolder } from "obsidian"; +import { showFilePicker, addFileMenuItems, addFolderMenuItems, -} = require("./file-actions"); -const { +} from "./file-actions.js"; +import { 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"); +} from "./settings/inject.js"; +import * as pluginRegistry from "./plugin-registry.js"; +import { initStatusBar } from "./status-bar.js"; +import { WorkspacePickerModal } from "./workspace-picker.js"; +import { startDemoGuards, stopDemoGuards } from "./demo-guards.js"; class IgnisBridgePlugin extends Plugin { async onload() { @@ -65,4 +65,4 @@ class IgnisBridgePlugin extends Plugin { } } -module.exports = IgnisBridgePlugin; +export default IgnisBridgePlugin; diff --git a/packages/bridge/src/plugin-registry.js b/packages/bridge/src/plugin-registry.js index 77f5fcc..3e95499 100644 --- a/packages/bridge/src/plugin-registry.js +++ b/packages/bridge/src/plugin-registry.js @@ -34,4 +34,4 @@ function getKnownIds() { return knownIds; } -module.exports = { refresh, isIgnisPlugin, addId, getKnownIds }; +export { refresh, isIgnisPlugin, addId, getKnownIds }; diff --git a/packages/bridge/src/settings/general-tab.js b/packages/bridge/src/settings/general-tab.js index 529c5b5..40475b3 100644 --- a/packages/bridge/src/settings/general-tab.js +++ b/packages/bridge/src/settings/general-tab.js @@ -1,7 +1,7 @@ -const { Setting, Notice } = require("obsidian"); -const { isDemoMode } = require("../demo-guards"); -const { stripBuildMetadata, isNewer } = require("../util/version"); -const { ListEditorModal } = require("./list-editor-modal"); +import { Setting, Notice } from "obsidian"; +import { isDemoMode } from "../demo-guards.js"; +import { stripBuildMetadata, isNewer } from "../util/version.js"; +import { ListEditorModal } from "./list-editor-modal.js"; const GITHUB_URL = "https://github.com/Nystik-gh/ignis"; const GITHUB_API_LATEST = @@ -353,4 +353,4 @@ function listField(containerEl, { name, desc, value, key, app, modal }) { return setting; } -module.exports = { display }; +export { display }; diff --git a/packages/bridge/src/settings/inject.js b/packages/bridge/src/settings/inject.js index 427c0e9..d92de8f 100644 --- a/packages/bridge/src/settings/inject.js +++ b/packages/bridge/src/settings/inject.js @@ -1,14 +1,14 @@ -const generalTab = require("./general-tab"); -const serverPluginsTab = require("./server-plugins-tab"); -const { createNavEl, createTab, createGroup } = require("./settings-ui"); -const { +import * as generalTab from "./general-tab.js"; +import * as serverPluginsTab from "./server-plugins-tab.js"; +import { createNavEl, createTab, createGroup } from "./settings-ui.js"; +import { allIgnisNavEls, setupPluginTabs, reconcilePluginTabs, hideIgnisFromCommunityPlugins, restoreCommunityPlugins, clearOwnedPluginIds, -} = require("./plugin-tabs"); +} from "./plugin-tabs.js"; function removeExistingIgnisGroups(tabHeadersEl) { const groups = tabHeadersEl.querySelectorAll(".vertical-tab-header-group"); @@ -139,4 +139,4 @@ function unpatchSettingsModal(plugin) { clearOwnedPluginIds(); } -module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs }; +export { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs }; diff --git a/packages/bridge/src/settings/list-editor-modal.js b/packages/bridge/src/settings/list-editor-modal.js index 63b4af6..7a503e3 100644 --- a/packages/bridge/src/settings/list-editor-modal.js +++ b/packages/bridge/src/settings/list-editor-modal.js @@ -1,4 +1,4 @@ -const { Modal, Setting, Notice } = require("obsidian"); +import { Modal, Setting, Notice } from "obsidian"; // Modal editor for a list of string entries (the proxy host allowlist). class ListEditorModal extends Modal { @@ -131,4 +131,4 @@ class ListEditorModal extends Modal { } } -module.exports = { ListEditorModal }; +export { ListEditorModal }; diff --git a/packages/bridge/src/settings/plugin-tabs.js b/packages/bridge/src/settings/plugin-tabs.js index a86da07..8362806 100644 --- a/packages/bridge/src/settings/plugin-tabs.js +++ b/packages/bridge/src/settings/plugin-tabs.js @@ -1,6 +1,6 @@ -const { setIcon } = require("obsidian"); -const { findGroupByTitle } = require("./settings-ui"); -const { isIgnisPlugin } = require("../plugin-registry"); +import { setIcon } from "obsidian"; +import { findGroupByTitle } from "./settings-ui.js"; +import { isIgnisPlugin } from "../plugin-registry.js"; // All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group). // Shared with inject.js so the openTab patch can manage is-active across all of them. @@ -232,7 +232,7 @@ function clearOwnedPluginIds() { ownedPluginIds.clear(); } -module.exports = { +export { allIgnisNavEls, setupPluginTabs, reconcilePluginTabs, diff --git a/packages/bridge/src/settings/server-plugins-tab.js b/packages/bridge/src/settings/server-plugins-tab.js index bff53e4..0791223 100644 --- a/packages/bridge/src/settings/server-plugins-tab.js +++ b/packages/bridge/src/settings/server-plugins-tab.js @@ -1,5 +1,5 @@ -const { Setting, Notice } = require("obsidian"); -const { reconcilePluginTabs } = require("./plugin-tabs"); +import { Setting, Notice } from "obsidian"; +import { reconcilePluginTabs } from "./plugin-tabs.js"; function getVaultId() { return window.__currentVaultId || ""; @@ -94,4 +94,4 @@ function display(containerEl, app) { }); } -module.exports = { display }; +export { display }; diff --git a/packages/bridge/src/settings/settings-ui.js b/packages/bridge/src/settings/settings-ui.js index 041c634..7382ee2 100644 --- a/packages/bridge/src/settings/settings-ui.js +++ b/packages/bridge/src/settings/settings-ui.js @@ -1,4 +1,4 @@ -const { setIcon } = require("obsidian"); +import { setIcon } from "obsidian"; function createNavEl(tab, setting) { const nav = document.createElement("div"); @@ -86,4 +86,4 @@ function findGroupByTitle(tabHeadersEl, title) { return null; } -module.exports = { createNavEl, createTab, createGroup, findGroupByTitle }; +export { createNavEl, createTab, createGroup, findGroupByTitle }; diff --git a/packages/bridge/src/status-bar.js b/packages/bridge/src/status-bar.js index e3f882d..567650c 100644 --- a/packages/bridge/src/status-bar.js +++ b/packages/bridge/src/status-bar.js @@ -32,4 +32,4 @@ function initStatusBar(plugin) { return ws.onStateChange(render); } -module.exports = { initStatusBar }; +export { initStatusBar }; diff --git a/packages/bridge/src/util/version.js b/packages/bridge/src/util/version.js index c44ce81..e7c2fd5 100644 --- a/packages/bridge/src/util/version.js +++ b/packages/bridge/src/util/version.js @@ -36,4 +36,4 @@ function isNewer(latest, current) { return false; } -module.exports = { stripBuildMetadata, parseSemver, isNewer }; +export { stripBuildMetadata, parseSemver, isNewer }; diff --git a/packages/bridge/src/util/version.test.mjs b/packages/bridge/src/util/version.test.js similarity index 85% rename from packages/bridge/src/util/version.test.mjs rename to packages/bridge/src/util/version.test.js index 9472995..f2eb66e 100644 --- a/packages/bridge/src/util/version.test.mjs +++ b/packages/bridge/src/util/version.test.js @@ -1,8 +1,5 @@ import { describe, it, expect } from "vitest"; -import { createRequire } from "module"; - -const require = createRequire(import.meta.url); -const { stripBuildMetadata, isNewer } = require("./version.js"); +import { stripBuildMetadata, isNewer } from "./version.js"; describe("isNewer", () => { it("is true when latest is strictly newer", () => { diff --git a/packages/bridge/src/workspace-picker.js b/packages/bridge/src/workspace-picker.js index 91e9753..14e9703 100644 --- a/packages/bridge/src/workspace-picker.js +++ b/packages/bridge/src/workspace-picker.js @@ -1,4 +1,4 @@ -const { FuzzySuggestModal } = require("obsidian"); +import { FuzzySuggestModal } from "obsidian"; class WorkspacePickerModal extends FuzzySuggestModal { constructor(app) { @@ -29,4 +29,4 @@ class WorkspacePickerModal extends FuzzySuggestModal { } } -module.exports = { WorkspacePickerModal }; +export { WorkspacePickerModal }; diff --git a/packages/shim/src/loader.js b/packages/shim/src/loader.js index 19a1b07..e45f4d1 100644 --- a/packages/shim/src/loader.js +++ b/packages/shim/src/loader.js @@ -52,7 +52,7 @@ if (window.__currentVaultId) { extractObsidianModule() .then(async () => { - // Dynamic import so bridge's top-level require("obsidian") fires after installRequire + extractObsidianModule. + // Dynamic import so the bridge's top-level obsidian import resolves after installRequire + extractObsidianModule. const mod = await import("@ignis/bridge"); const IgnisBridgePlugin = mod.default || mod; const bridge = new IgnisBridgePlugin(window.app, BRIDGE_MANIFEST); From 35348093a6c34c226f150cc0e924d1017c21cd10 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sat, 6 Jun 2026 20:40:42 +0200 Subject: [PATCH 09/12] minor settings fixes --- apps/ignis-server/README.md | 2 +- apps/ignis-server/server/settings.js | 2 +- packages/bridge/src/settings/general-tab.js | 22 +++++++++++++-------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/ignis-server/README.md b/apps/ignis-server/README.md index 05125be..5ba0088 100644 --- a/apps/ignis-server/README.md +++ b/apps/ignis-server/README.md @@ -76,7 +76,7 @@ To build from source instead of pulling the image, clone the repo and run `docke | `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` | | `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` | +| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. On slow filesystems (rclone, NFS, SMB), set an appropriate duration. | `0` | | `WS_ORIGINS` | Comma-separated allowlist of `Origin` headers accepted on the WebSocket endpoint. When unset, any origin is accepted. | unset | Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [`examples/demo/`](examples/demo/) if you want to run a public demo deployment. diff --git a/apps/ignis-server/server/settings.js b/apps/ignis-server/server/settings.js index 597c2af..854d06d 100644 --- a/apps/ignis-server/server/settings.js +++ b/apps/ignis-server/server/settings.js @@ -10,7 +10,7 @@ const DEFAULTS = { contentCacheBytes: 50 * 1024 * 1024, inputCacheBytes: 200 * 1024 * 1024, inputCacheTtlMs: 5 * 60 * 1000, - writeCoalesceMs: 5000, + writeCoalesceMs: 0, maxBodyBytes: 50 * 1024 * 1024, // "any" reaches any public host, "allowlist" restricts to proxyAllowlist, "disabled" blocks all proxying. proxyMode: "any", diff --git a/packages/bridge/src/settings/general-tab.js b/packages/bridge/src/settings/general-tab.js index 40475b3..b8419fa 100644 --- a/packages/bridge/src/settings/general-tab.js +++ b/packages/bridge/src/settings/general-tab.js @@ -101,10 +101,6 @@ 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"); @@ -251,19 +247,24 @@ async function saveSetting(partial) { } function numberField(containerEl, { name, desc, value, key, toStored }) { + let committed = value; + 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. + // Commit only on change. const commit = () => { const n = parseInt(text.getValue(), 10); - if (Number.isInteger(n) && n >= 0) { - saveSetting({ [key]: toStored(n) }); + if (!Number.isInteger(n) || n < 0 || n === committed) { + return; } + + committed = n; + saveSetting({ [key]: toStored(n) }); }; text.inputEl.addEventListener("blur", commit); @@ -296,7 +297,12 @@ function proxyAccessField(parent, current, app) { 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"], + hosts: [ + "releases.obsidian.md", + "github.com", + "api.github.com", + "raw.githubusercontent.com", + ], buttonText: "Add recommended hosts", }, }, From c3a9d511b2dc9019d6b58511344dbad1b54c13e7 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sun, 7 Jun 2026 12:51:27 +0200 Subject: [PATCH 10/12] expand shim coverage, additional fs shims, add light stream shim --- packages/shim/src/fs/callback.js | 34 +++++ packages/shim/src/fs/callback.test.js | 47 +++++++ packages/shim/src/fs/index.js | 17 +++ packages/shim/src/fs/promises.js | 10 +- packages/shim/src/fs/realpath.js | 12 ++ packages/shim/src/fs/realpath.test.js | 20 +++ packages/shim/src/fs/sync-mutations.test.js | 128 ++++++++++++++++++ packages/shim/src/fs/sync.js | 142 ++++++++++++++++++++ packages/shim/src/globals.js | 5 + packages/shim/src/node/assert.js | 82 +++++++++++ packages/shim/src/node/assert.test.js | 30 +++++ packages/shim/src/node/constants.js | 50 +++++++ packages/shim/src/node/stream.js | 85 ++++++++++++ packages/shim/src/node/stream.test.js | 16 +++ packages/shim/src/process.js | 1 + packages/shim/src/require.js | 6 + 16 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 packages/shim/src/fs/callback.js create mode 100644 packages/shim/src/fs/callback.test.js create mode 100644 packages/shim/src/fs/realpath.js create mode 100644 packages/shim/src/fs/realpath.test.js create mode 100644 packages/shim/src/fs/sync-mutations.test.js create mode 100644 packages/shim/src/node/assert.js create mode 100644 packages/shim/src/node/assert.test.js create mode 100644 packages/shim/src/node/constants.js create mode 100644 packages/shim/src/node/stream.js create mode 100644 packages/shim/src/node/stream.test.js diff --git a/packages/shim/src/fs/callback.js b/packages/shim/src/fs/callback.js new file mode 100644 index 0000000..a39be41 --- /dev/null +++ b/packages/shim/src/fs/callback.js @@ -0,0 +1,34 @@ +const CALLBACK_METHODS = [ + "stat", + "lstat", + "readdir", + "readFile", + "writeFile", + "appendFile", + "unlink", + "rename", + "mkdir", + "rmdir", + "rm", + "copyFile", + "access", + "utimes", + "chmod", +]; + +export function createFsCallbacks(fsPromises) { + const callbacks = {}; + + for (const name of CALLBACK_METHODS) { + callbacks[name] = function (...args) { + const callback = args.pop(); + + fsPromises[name](...args).then( + (result) => callback(null, result), + (err) => callback(err), + ); + }; + } + + return callbacks; +} diff --git a/packages/shim/src/fs/callback.test.js b/packages/shim/src/fs/callback.test.js new file mode 100644 index 0000000..77a8690 --- /dev/null +++ b/packages/shim/src/fs/callback.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { createFsCallbacks } from "./callback.js"; + +describe("fs callbacks", () => { + it("resolves the promise result through the callback", async () => { + const fakePromises = { readFile: async (p) => `data:${p}` }; + const cb = createFsCallbacks(fakePromises); + + const result = await new Promise((resolve) => + cb.readFile("/x", (err, data) => resolve([err, data])), + ); + + expect(result).toEqual([null, "data:/x"]); + }); + + it("passes a rejection to the callback as the error argument", async () => { + const boom = new Error("nope"); + const fakePromises = { + stat: async () => { + throw boom; + }, + }; + const cb = createFsCallbacks(fakePromises); + + const result = await new Promise((resolve) => + cb.stat("/x", (err) => resolve(err)), + ); + + expect(result).toBe(boom); + }); + + it("forwards the arguments that precede the callback", async () => { + let received = null; + const fakePromises = { + mkdir: async (p, opts) => { + received = [p, opts]; + }, + }; + const cb = createFsCallbacks(fakePromises); + + await new Promise((resolve) => + cb.mkdir("/d", { recursive: true }, () => resolve()), + ); + + expect(received).toEqual(["/d", { recursive: true }]); + }); +}); diff --git a/packages/shim/src/fs/index.js b/packages/shim/src/fs/index.js index fdfaa3e..afd8cbf 100644 --- a/packages/shim/src/fs/index.js +++ b/packages/shim/src/fs/index.js @@ -6,6 +6,8 @@ import { createFsSync } from "./sync.js"; import { createFsWatch } from "./watch.js"; import { createWatcherClient } from "./watcher-client.js"; import { createFdOps } from "./fd.js"; +import { createFsCallbacks } from "./callback.js"; +import { realpath, realpathSync } from "./realpath.js"; import { constants } from "./constants.js"; import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js"; import { wsClient } from "../ws-client.js"; @@ -18,10 +20,13 @@ const fsSync = createFsSync(metadataCache, contentCache, transport); const fsWatch = createFsWatch(transport); const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient); const fdOps = createFdOps(metadataCache, contentCache, transport); +const fsCallbacks = createFsCallbacks(fsPromises); export const fsShim = { promises: fsPromises, + ...fsCallbacks, + existsSync: fsSync.existsSync, readFileSync: fsSync.readFileSync, writeFileSync: fsSync.writeFileSync, @@ -29,6 +34,18 @@ export const fsShim = { accessSync: fsSync.accessSync, statSync: fsSync.statSync, readdirSync: fsSync.readdirSync, + lstatSync: fsSync.lstatSync, + mkdirSync: fsSync.mkdirSync, + rmdirSync: fsSync.rmdirSync, + rmSync: fsSync.rmSync, + renameSync: fsSync.renameSync, + copyFileSync: fsSync.copyFileSync, + appendFileSync: fsSync.appendFileSync, + utimesSync: fsSync.utimesSync, + chmodSync: fsSync.chmodSync, + + realpath, + realpathSync, open: fdOps.open, openSync: fdOps.openSync, diff --git a/packages/shim/src/fs/promises.js b/packages/shim/src/fs/promises.js index 565ecb9..7b059aa 100644 --- a/packages/shim/src/fs/promises.js +++ b/packages/shim/src/fs/promises.js @@ -1,6 +1,10 @@ import { markLocalOp } from "./echo-guard.js"; import { isInputCachePath, inputCacheGet } from "./input-cache.js"; -import { applyReadTransform, applyWriteTransform, resolvePath } from "./transforms.js"; +import { + applyReadTransform, + applyWriteTransform, + resolvePath, +} from "./transforms.js"; import { hasVirtualFile, getVirtualFile } from "./virtual-files.js"; export function createFsPromises(metadataCache, contentCache, transport) { @@ -270,6 +274,10 @@ export function createFsPromises(metadataCache, contentCache, transport) { } }, + async chmod() { + // No permission bits in the vault FS. No-op. + }, + async open(path, flags) { const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null; const resolved = resolvePath(path); diff --git a/packages/shim/src/fs/realpath.js b/packages/shim/src/fs/realpath.js new file mode 100644 index 0000000..17c909a --- /dev/null +++ b/packages/shim/src/fs/realpath.js @@ -0,0 +1,12 @@ +export function realpathSync(path) { + return typeof path === "string" ? path : String(path); +} + +export function realpath(path, options, callback) { + const cb = typeof options === "function" ? options : callback; + + queueMicrotask(() => cb(null, realpathSync(path))); +} + +realpath.native = realpath; +realpathSync.native = realpathSync; diff --git a/packages/shim/src/fs/realpath.test.js b/packages/shim/src/fs/realpath.test.js new file mode 100644 index 0000000..bc3c9eb --- /dev/null +++ b/packages/shim/src/fs/realpath.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { realpath } from "./realpath.js"; + +describe("fs realpath shim", () => { + it("realpath invokes the callback with the path", async () => { + const result = await new Promise((resolve) => + realpath("/a/b.md", (err, p) => resolve(p)), + ); + + expect(result).toBe("/a/b.md"); + }); + + it("realpath accepts an options argument before the callback", async () => { + const result = await new Promise((resolve) => + realpath("/a/b.md", "utf8", (err, p) => resolve(p)), + ); + + expect(result).toBe("/a/b.md"); + }); +}); diff --git a/packages/shim/src/fs/sync-mutations.test.js b/packages/shim/src/fs/sync-mutations.test.js new file mode 100644 index 0000000..4e0bc9a --- /dev/null +++ b/packages/shim/src/fs/sync-mutations.test.js @@ -0,0 +1,128 @@ +import { describe, it, expect, vi } from "vitest"; +import { createFsSync } from "./sync.js"; +import { resolvePath } from "./transforms.js"; + +function makeDeps() { + const store = new Map(); + + const metadataCache = { + has: (p) => store.has(p), + get: (p) => (store.has(p) ? store.get(p) : null), + set: (p, m) => store.set(p, m), + delete: (p) => store.delete(p), + rename: (a, b) => { + if (store.has(a)) { + store.set(b, store.get(a)); + store.delete(a); + } + }, + toStat: (p) => + store.has(p) + ? { + type: store.get(p).type, + isDirectory: () => store.get(p).type === "directory", + isFile: () => store.get(p).type === "file", + } + : null, + readdir: () => [], + }; + + const contentCache = { + get: () => null, + set: vi.fn(), + delete: vi.fn(), + invalidate: vi.fn(), + }; + + const transport = { + mkdir: vi.fn(async () => {}), + rmdir: vi.fn(async () => {}), + rm: vi.fn(async () => {}), + rename: vi.fn(async () => {}), + copyFile: vi.fn(async () => {}), + appendFile: vi.fn(async () => {}), + utimes: vi.fn(async () => {}), + stat: vi.fn(async () => ({ type: "file", size: 1 })), + }; + + return { metadataCache, contentCache, transport, store }; +} + +describe("sync fs mutations", () => { + it("lstatSync mirrors statSync", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + deps.store.set(resolvePath("dir"), { type: "directory" }); + + expect(fs.lstatSync("dir").isDirectory()).toBe(true); + }); + + it("mkdirSync updates the cache and fires the transport", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + + fs.mkdirSync("newdir", { recursive: true }); + + expect(deps.store.get("newdir")).toEqual({ type: "directory" }); + expect(deps.transport.mkdir).toHaveBeenCalledWith("newdir", true); + }); + + it("rmSync deletes from the cache and fires the transport", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + const key = resolvePath("gone.md"); + deps.store.set(key, { type: "file" }); + + fs.rmSync("gone.md", { recursive: true }); + + expect(deps.store.has(key)).toBe(false); + expect(deps.transport.rm).toHaveBeenCalled(); + }); + + it("renameSync moves cache metadata and fires the transport", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + const from = resolvePath("a.md"); + const to = resolvePath("b.md"); + deps.store.set(from, { type: "file", size: 2 }); + + fs.renameSync("a.md", "b.md"); + + expect(deps.store.has(from)).toBe(false); + expect(deps.store.get(to)).toEqual({ type: "file", size: 2 }); + expect(deps.transport.rename).toHaveBeenCalled(); + }); + + it("copyFileSync optimistically mirrors source metadata and fires the transport", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + const srcKey = resolvePath("src.md"); + const destKey = resolvePath("dest.md"); + deps.store.set(srcKey, { type: "file", size: 9 }); + + fs.copyFileSync("src.md", "dest.md"); + + expect(deps.store.get(destKey)).toEqual({ type: "file", size: 9 }); + expect(deps.transport.copyFile).toHaveBeenCalled(); + }); + + it("utimesSync sets mtime and fires the transport", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + const key = resolvePath("note.md"); + deps.store.set(key, { type: "file", mtime: 0 }); + + fs.utimesSync("note.md", 111, 222); + + expect(deps.store.get(key).mtime).toBe(222); + expect(deps.transport.utimes).toHaveBeenCalled(); + }); + + it("chmodSync is a no-op that does not throw", () => { + const deps = makeDeps(); + const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport); + + expect(() => fs.chmodSync("note.md", 0o644)).not.toThrow(); + expect(fs.chmodSync("note.md", 0o644)).toBeUndefined(); + }); +}); diff --git a/packages/shim/src/fs/sync.js b/packages/shim/src/fs/sync.js index 08549d1..f46642a 100644 --- a/packages/shim/src/fs/sync.js +++ b/packages/shim/src/fs/sync.js @@ -180,5 +180,147 @@ export function createFsSync(metadataCache, contentCache, transport) { const entries = metadataCache.readdir(path); return entries.map((e) => e.name); }, + + lstatSync(path) { + // No symlinks in our context. + return this.statSync(path); + }, + + mkdirSync(path, options) { + const recursive = + typeof options === "object" ? !!options.recursive : !!options; + + markLocalOp(path); + metadataCache.set(path, { type: "directory" }); + + transport.mkdir(path, recursive).catch((e) => { + console.error("[shim:fs] mkdirSync background create failed:", path, e); + }); + }, + + rmdirSync(path) { + markLocalOp(path); + metadataCache.delete(path); + + transport.rmdir(path).catch((e) => { + console.error("[shim:fs] rmdirSync background remove failed:", path, e); + }); + }, + + rmSync(path, options) { + const recursive = + typeof options === "object" ? !!options.recursive : false; + + const resolved = resolvePath(path); + + markLocalOp(resolved); + metadataCache.delete(resolved); + contentCache.delete(resolved); + + transport.rm(resolved, recursive).catch((e) => { + console.error( + "[shim:fs] rmSync background remove failed:", + resolved, + e, + ); + }); + }, + + renameSync(oldPath, newPath) { + const resolvedOld = resolvePath(oldPath); + const resolvedNew = resolvePath(newPath); + + markLocalOp(resolvedOld); + markLocalOp(resolvedNew); + const content = contentCache.get(resolvedOld); + + if (content !== null) { + contentCache.set(resolvedNew, content); + contentCache.delete(resolvedOld); + } + + metadataCache.rename(resolvedOld, resolvedNew); + + transport.rename(resolvedOld, resolvedNew).catch((e) => { + console.error( + "[shim:fs] renameSync background rename failed:", + resolvedOld, + e, + ); + }); + }, + + copyFileSync(src, dest) { + const resolvedSrc = resolvePath(src); + const resolvedDest = resolvePath(dest); + + markLocalOp(resolvedDest); + + // Optimistically mirror the source so a sync read right after sees it. + const content = contentCache.get(resolvedSrc); + + if (content !== null) { + contentCache.set(resolvedDest, content); + } + + const srcMeta = metadataCache.get(resolvedSrc); + + if (srcMeta) { + metadataCache.set(resolvedDest, { ...srcMeta }); + } + + transport + .copyFile(src, resolvedDest) + .then(() => transport.stat(resolvedDest)) + .then((meta) => metadataCache.set(resolvedDest, meta)) + .catch((e) => { + console.error( + "[shim:fs] copyFileSync background copy failed:", + resolvedDest, + e, + ); + }); + }, + + appendFileSync(path, data) { + const resolved = resolvePath(path); + + markLocalOp(resolved); + contentCache.invalidate(resolved); + + transport + .appendFile(resolved, data) + .then(() => transport.stat(resolved)) + .then((meta) => metadataCache.set(resolved, meta)) + .catch((e) => { + console.error( + "[shim:fs] appendFileSync background append failed:", + resolved, + e, + ); + }); + }, + + utimesSync(path, atime, mtime) { + const resolved = resolvePath(path); + const meta = metadataCache.get(resolved); + + if (meta) { + meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime(); + metadataCache.set(resolved, meta); + } + + transport.utimes(resolved, atime, mtime).catch((e) => { + console.error( + "[shim:fs] utimesSync background utimes failed:", + resolved, + e, + ); + }); + }, + + chmodSync() { + // The vault FS does not model permission bits. No-op. + }, }; } diff --git a/packages/shim/src/globals.js b/packages/shim/src/globals.js index 7a74b80..bd837c5 100644 --- a/packages/shim/src/globals.js +++ b/packages/shim/src/globals.js @@ -228,7 +228,12 @@ function installContextMenuFix() { ); } +function installGlobalAlias() { + window.global = window; +} + export function installGlobals() { + installGlobalAlias(); installProcess(); installBuffer(); installFetchShim(); diff --git a/packages/shim/src/node/assert.js b/packages/shim/src/node/assert.js new file mode 100644 index 0000000..8f6553a --- /dev/null +++ b/packages/shim/src/node/assert.js @@ -0,0 +1,82 @@ +class AssertionError extends Error { + constructor(message) { + super(message || "Assertion failed"); + this.name = "AssertionError"; + } +} + +function assert(value, message) { + if (!value) { + throw new AssertionError(message); + } +} + +assert.AssertionError = AssertionError; +assert.ok = assert; +assert.strict = assert; + +assert.fail = function (message) { + throw new AssertionError(message || "Failed"); +}; + +assert.equal = function (actual, expected, message) { + if (actual != expected) { + throw new AssertionError(message || `${actual} == ${expected}`); + } +}; + +assert.notEqual = function (actual, expected, message) { + if (actual == expected) { + throw new AssertionError(message || `${actual} != ${expected}`); + } +}; + +assert.strictEqual = function (actual, expected, message) { + if (actual !== expected) { + throw new AssertionError(message || `${actual} === ${expected}`); + } +}; + +assert.notStrictEqual = function (actual, expected, message) { + if (actual === expected) { + throw new AssertionError(message || `${actual} !== ${expected}`); + } +}; + +assert.deepEqual = function (actual, expected, message) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new AssertionError(message || "deepEqual"); + } +}; + +assert.deepStrictEqual = assert.deepEqual; + +assert.throws = function (fn, message) { + let threw = false; + + try { + fn(); + } catch { + threw = true; + } + + if (!threw) { + throw new AssertionError(message || "Missing expected exception"); + } +}; + +assert.doesNotThrow = function (fn, message) { + try { + fn(); + } catch (e) { + throw new AssertionError(message || `Got unwanted exception: ${e.message}`); + } +}; + +assert.ifError = function (value) { + if (value) { + throw new AssertionError(`ifError got unwanted exception: ${value}`); + } +}; + +export const assertShim = assert; diff --git a/packages/shim/src/node/assert.test.js b/packages/shim/src/node/assert.test.js new file mode 100644 index 0000000..b10916d --- /dev/null +++ b/packages/shim/src/node/assert.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { assertShim as assert } from "./assert.js"; + +describe("assert shim", () => { + it("is callable and throws on a falsy value", () => { + expect(() => assert(false)).toThrow(); + expect(() => assert(true)).not.toThrow(); + }); + + it("equal throws on mismatch and passes on loose match", () => { + expect(() => assert.equal(1, 2)).toThrow(); + expect(() => assert.equal(1, 1)).not.toThrow(); + expect(() => assert.equal(1, "1")).not.toThrow(); + }); + + it("strictEqual distinguishes type", () => { + expect(() => assert.strictEqual(1, "1")).toThrow(); + expect(() => assert.strictEqual(1, 1)).not.toThrow(); + }); + + it("throws() verifies that a function threw", () => { + expect(() => + assert.throws(() => { + throw new Error("x"); + }), + ).not.toThrow(); + + expect(() => assert.throws(() => {})).toThrow(); + }); +}); diff --git a/packages/shim/src/node/constants.js b/packages/shim/src/node/constants.js new file mode 100644 index 0000000..9d6c637 --- /dev/null +++ b/packages/shim/src/node/constants.js @@ -0,0 +1,50 @@ +// Linux constant values, to match the platform the process shim reports. +// O_SYMLINK and other macOS/BSD flags are omitted so feature checks treat platform as linux + +export const constantsShim = { + // File access checks (fs.access mode). + F_OK: 0, + X_OK: 1, + W_OK: 2, + R_OK: 4, + + // open() flags. + O_RDONLY: 0, + O_WRONLY: 1, + O_RDWR: 2, + O_CREAT: 64, + O_EXCL: 128, + O_NOCTTY: 256, + O_TRUNC: 512, + O_APPEND: 1024, + O_DIRECTORY: 65536, + O_NOATIME: 262144, + O_NOFOLLOW: 131072, + O_SYNC: 1052672, + O_DSYNC: 4096, + O_NONBLOCK: 2048, + + // File type bits (st_mode & S_IFMT). + S_IFMT: 61440, + S_IFREG: 32768, + S_IFDIR: 16384, + S_IFCHR: 8192, + S_IFBLK: 24576, + S_IFIFO: 4096, + S_IFLNK: 40960, + S_IFSOCK: 49152, + + // Permission bits. + S_IRWXU: 448, + S_IRUSR: 256, + S_IWUSR: 128, + S_IXUSR: 64, + S_IRWXG: 56, + S_IRGRP: 32, + S_IWGRP: 16, + S_IXGRP: 8, + S_IRWXO: 7, + S_IROTH: 4, + S_IWOTH: 2, + S_IXOTH: 1, +}; diff --git a/packages/shim/src/node/stream.js b/packages/shim/src/node/stream.js new file mode 100644 index 0000000..91e1aa1 --- /dev/null +++ b/packages/shim/src/node/stream.js @@ -0,0 +1,85 @@ +import { EventEmitter } from "./events.js"; + +let warned = false; + +function warnNoDataFlow(method) { + if (warned) { + return; + } + + warned = true; + console.warn( + `[shim:stream] ${method}() called, but stream data flow is not implemented. ` + + "This plugin needs the full stream shim.", + ); +} + +export class Stream extends EventEmitter { + pipe(destination) { + warnNoDataFlow("pipe"); + return destination; + } +} + +export class Readable extends Stream { + constructor(options) { + super(); + this.readable = true; + this._readableState = { options: options || {} }; + } + + read() { + warnNoDataFlow("read"); + return null; + } + + push() { + warnNoDataFlow("push"); + return false; + } + + _read() {} +} + +export class Writable extends Stream { + constructor(options) { + super(); + this.writable = true; + this._writableState = { options: options || {} }; + } + + write() { + warnNoDataFlow("write"); + return false; + } + + end() { + warnNoDataFlow("end"); + return this; + } + + _write() {} +} + +export class Duplex extends Readable { + constructor(options) { + super(options); + this.writable = true; + } + + write() { + warnNoDataFlow("write"); + return false; + } + + end() { + warnNoDataFlow("end"); + return this; + } +} + +export class Transform extends Duplex { + _transform() {} +} + +export class PassThrough extends Transform {} diff --git a/packages/shim/src/node/stream.test.js b/packages/shim/src/node/stream.test.js new file mode 100644 index 0000000..4708c16 --- /dev/null +++ b/packages/shim/src/node/stream.test.js @@ -0,0 +1,16 @@ +import { describe, it, expect, vi } from "vitest"; +import { Readable, Writable } from "./stream.js"; + +describe("stream shim", () => { + it("warns once when data-flow methods are used", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + new Readable().read(); + new Writable().write("x"); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("[shim:stream]"); + + warn.mockRestore(); + }); +}); diff --git a/packages/shim/src/process.js b/packages/shim/src/process.js index 1a93b79..fe6aa3c 100644 --- a/packages/shim/src/process.js +++ b/packages/shim/src/process.js @@ -1,5 +1,6 @@ export const processShim = { platform: "linux", + version: "v18.18.0", versions: { electron: "28.2.3", node: "18.18.0", diff --git a/packages/shim/src/require.js b/packages/shim/src/require.js index 1444460..3c33c21 100644 --- a/packages/shim/src/require.js +++ b/packages/shim/src/require.js @@ -11,6 +11,9 @@ import * as netShim from "./node/net.js"; import * as httpShim from "./node/http.js"; import * as zlibShim from "./node/zlib.js"; import * as utilShim from "./node/util.js"; +import { constantsShim } from "./node/constants.js"; +import { assertShim } from "./node/assert.js"; +import * as streamShim from "./node/stream.js"; import { wrapWithProxy, installDebugHelpers } from "./debug.js"; const rawRegistry = { @@ -29,6 +32,9 @@ const rawRegistry = { https: httpShim, zlib: zlibShim, util: utilShim, + constants: constantsShim, + assert: assertShim, + stream: streamShim, }; const shimRegistry = {}; From 5a5acb935a5122a9d15f417e37ec08d61727b529 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sun, 7 Jun 2026 13:33:36 +0200 Subject: [PATCH 11/12] update docs --- README.md | 4 ++-- apps/ignis-server/README.md | 2 ++ docs/ARCHITECTURE.md | 8 ++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a65cd0b..be6a4bd 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,12 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi **Server-side integration.** - Adds a plugin system inside the server itself, separate from Obsidian's community plugin system (WIP). - Ignis-specific settings appear as their own tabs inside Obsidian's Settings modal. +- Server runtime settings (cache sizes, request body limit, etc.) are configurable from the Ignis settings panel. - Status bar indicators surface server state and headless sync activity. ## Roadmap **Planned:** -- Server parameter configuration from the Ignis settings panel (LRU cache size, write coalesce window, etc.) - Continued shim work to support more community plugins. - Server-side plugin system improvements. @@ -94,7 +94,7 @@ A few design decisions worth knowing about for someone evaluating Ignis against - A pre-compressed bootstrap response delivers vault info, vault list, metadata tree, and plugin list in a single call. - Indexer pre-fetch warms the content cache so Obsidian's startup index hits cache instead of the network. - An LRU content cache (50 MB by default) keeps memory use bounded regardless of vault size, so Ignis doesn't hold the whole vault in memory. -- Write coalescing debounces rapid writes for slow filesystems (rclone, FUSE, NFS, SMB). +- Optional write coalescing debounces rapid writes for slow filesystems (rclone, FUSE, NFS, SMB); off unless `WRITE_COALESCE_MS` is set. ## Browser compatibility diff --git a/apps/ignis-server/README.md b/apps/ignis-server/README.md index 5ba0088..22b26dc 100644 --- a/apps/ignis-server/README.md +++ b/apps/ignis-server/README.md @@ -28,6 +28,8 @@ Example configurations for Basic Auth and Authelia are in [`examples/`](examples > [!CAUTION] > Do not run Ignis on a public network without auth. Anyone with the URL can read and write your vault files. +Ignis also runs a cross-origin proxy (`/api/proxy`) that reaches any public host by default. It rejects private, loopback, and link-local addresses, and you can narrow it to an allowlist or disable it entirely from the proxy settings in the Ignis settings panel. + ## Setup with Docker Compose Example `docker-compose.yml`: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fe7c687..83aa3e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -69,6 +69,9 @@ Immediately after the bootstrap response is applied, the client kicks off a batc | `net` | All classes/functions throw. | | `http` / `https` | Module is importable but `request()`/`get()` emit an `error` event; `createServer` throws. Plugins should use `requestUrl` or `fetch` (the shim routes cross-origin `fetch` through the server proxy). | | `buffer` | Aliased to the browser `Buffer` polyfill set up by the loader. | +| `assert` | Standard assertions: `assert`, `equal`, `strictEqual`, `deepEqual`, `throws`. | +| `constants` | File access and mode constants (`F_OK`, `O_RDONLY`, `S_IFMT`, etc.) for the reported Linux platform. | +| `stream` | Base classes (`Stream`, `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`) extending EventEmitter. Data-flow methods warn and do nothing. | Unknown modules return an empty proxy and log a warning. The `node:` prefix is stripped. The shim exposes two console helpers, `window.__shimLog()` (everything that has been accessed) and `window.__shimMisses()` (accessed-but-missing properties). @@ -78,7 +81,7 @@ Two caches on the client side. The **MetadataCache** holds `{ type, size, mtime, Reads not satisfied by ContentCache go through the transport layer to `/api/fs/readFile`. Sync calls use synchronous XHR to keep Obsidian's pre-boot module code working. Async calls use fetch. The transport handles vault id injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values (`ENOENT`, `EEXIST`, `ENOTDIR`). -Writes go through a server-side write coalescer (`packages/server-core/src/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (default 5 seconds, `WRITE_COALESCE_MS`) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown. +Writes go through a server-side write coalescer (`packages/server-core/src/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (`WRITE_COALESCE_MS`, default `0` which disables coalescing) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown. ### Transforms @@ -102,7 +105,7 @@ Obsidian on the desktop can make arbitrary cross-origin HTTP requests because it The shim handles this transparently. `window.fetch` and `window.requestUrl` are intercepted. Same-origin requests pass through unchanged. Cross-origin requests are POSTed to `/api/proxy`, which performs the outbound call from the server with headers that mimic Obsidian's desktop runtime: `Origin: app://obsidian.md` and the browser's own User-Agent. The response body is returned base64-encoded so binary content survives the JSON round-trip; the shim decodes it and hands the caller a normal `Response` or `requestUrl` result. -The proxy itself is intentionally generic. It forwards method, headers, and body verbatim and returns whatever the upstream sent. In demo mode, an allowlist restricts the hostname to a known-safe set; in normal self-hosted mode there's no restriction, which is one of the reasons the server needs to be behind authentication when exposed to the internet. +The proxy itself is intentionally generic. It forwards method, headers, and body verbatim and returns whatever the upstream sent. It always rejects requests whose hostname resolves to a private, loopback, or link-local address (SSRF guard). Outbound access is governed by `proxyMode`: `any` (the default) reaches any public host, `allowlist` restricts to a configured host list, and `disabled` blocks all proxying; demo mode pins it to `allowlist`. Under the default `any`, the proxy is an open relay to public hosts, which is one of the reasons the server needs to be behind authentication when exposed to the internet. ### Workspaces in browser tabs @@ -138,6 +141,7 @@ An Express server that handles filesystem operations, vault management, static f - `/api/bootstrap` - one-shot cold-start endpoint; returns vault info + list + metadata tree + plugin list as a single pre-compressed response, cached per vault with mtime-based invalidation. - `/api/proxy` - cross-origin HTTP proxy used by the fetch and requestUrl shims. - `/api/version` - Ignis version (SemVer), per-build identifier, and pinned Obsidian version. +- `/api/settings/*` - read and update runtime server settings (cache sizes, request body limit, write-coalesce window, proxy mode and allowlist). - `/api/plugins/*` - Ignis plugin management (list, enable, disable). __WIP__ - `/api/ext/:pluginId/*` - routes registered by individual Ignis plugins. - `/vault-files//` - static file serving rooted at a vault, used by Obsidian for image/attachment resource URLs. From 3f47618aafc4ae77288d342ea3881e84542b284d Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sun, 7 Jun 2026 13:40:11 +0200 Subject: [PATCH 12/12] update changelog, bump version to 0.8.5 --- CHANGELOG.md | 21 +++++++++++++++++++++ package.json | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 326fd5e..4d53cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. +## [0.8.5] - Karm (2026-06-07) + +### Added + +- Server settings panel in the Ignis settings tab. +- `assert`, `constants`, and `stream` shims, plus callback-style `fs` methods and `realpath`. + +### Changed + +- Write coalescing is now off by default (`WRITE_COALESCE_MS=0`). + +### Fixed + +- Native menus now stay disabled on platforms where its default is true +- `/app/data` is now created and owned by the runtime user. +- Caddy reverse-proxy example uses the current `basic_auth` directive. + +### Security + +- Cross-origin proxy rejects requests that resolve to private, loopback, or link-local addresses (SSRF guard). + ## [0.8.4] - Karm (2026-06-03) ### Fixed diff --git a/package.json b/package.json index b59e2ab..ffdc165 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ignis-monorepo", - "version": "0.8.4", + "version": "0.8.5", "private": true, "description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.", "workspaces": [