add server settings UI and enforcement

This commit is contained in:
Nystik
2026-06-06 17:05:26 +02:00
parent b43d12f702
commit a7824ac284
13 changed files with 497 additions and 33 deletions

View File

@@ -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) {

View File

@@ -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)) {

View File

@@ -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();

View File

@@ -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,
};

View File

@@ -51,4 +51,4 @@ function stopDemoGuards() {
}
}
module.exports = { startDemoGuards, stopDemoGuards };
module.exports = { startDemoGuards, stopDemoGuards, isDemoMode };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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));
}

View File

@@ -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/");

View File

@@ -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.