Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a51b2d3ffa | ||
|
|
04be97e48c | ||
|
|
7688de599a | ||
|
|
a7824ac284 | ||
|
|
b43d12f702 | ||
|
|
938a698795 | ||
|
|
3129ed377c | ||
|
|
44bb01f162 |
37 changed files with 1100 additions and 160 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# Replace with your domain, or use :443 for local access with a self-signed cert.
|
# Replace with your domain, or use :443 for local access with a self-signed cert.
|
||||||
ignis.example.com {
|
ignis.example.com {
|
||||||
basicauth {
|
basic_auth {
|
||||||
# Username: admin
|
# Username: admin
|
||||||
# Replace the hash below with your own. Generate one with:
|
# Replace the hash below with your own. Generate one with:
|
||||||
# docker run --rm caddy:2 caddy hash-password --plaintext YOUR_PASSWORD
|
# docker run --rm caddy:2 caddy hash-password --plaintext YOUR_PASSWORD
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,9 @@ else
|
||||||
echo "[ignis] Using existing user $RUN_USER (UID $PUID)"
|
echo "[ignis] Using existing user $RUN_USER (UID $PUID)"
|
||||||
fi
|
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_DIR="/app/obsidian-app"
|
||||||
OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}"
|
OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}"
|
||||||
|
|
|
||||||
|
|
@ -75,16 +75,6 @@ module.exports = {
|
||||||
vaults = discoverVaults();
|
vaults = discoverVaults();
|
||||||
return vaults;
|
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",
|
demoMode: process.env.DEMO_MODE === "true",
|
||||||
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,
|
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,11 @@ function setupDemo(app) {
|
||||||
// Hide server-side plugins (headless-sync) from the demo UI
|
// Hide server-side plugins (headless-sync) from the demo UI
|
||||||
app.use("/api/plugins", pluginsBlocker);
|
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
|
// Cleanup timer
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
cleanupExpired().catch((e) =>
|
cleanupExpired().catch((e) =>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const compression = require("compression");
|
const compression = require("compression");
|
||||||
const config = require("./config");
|
const config = require("./config");
|
||||||
|
const settings = require("./settings");
|
||||||
const { getVersion } = require("./version");
|
const { getVersion } = require("./version");
|
||||||
const {
|
const {
|
||||||
setupWebSocket,
|
setupWebSocket,
|
||||||
|
|
@ -19,7 +20,7 @@ const {
|
||||||
getBundledPluginDirs,
|
getBundledPluginDirs,
|
||||||
} = require("./plugin-system/manager");
|
} = require("./plugin-system/manager");
|
||||||
const pluginRoutes = require("./routes/plugins");
|
const pluginRoutes = require("./routes/plugins");
|
||||||
writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs });
|
writeCoalescer.configure({ writeCoalesceMs: settings.get("writeCoalesceMs") });
|
||||||
const { flushAll } = writeCoalescer;
|
const { flushAll } = writeCoalescer;
|
||||||
const { setupDemo, wireDemoWebSocket } = require("./demo");
|
const { setupDemo, wireDemoWebSocket } = require("./demo");
|
||||||
|
|
||||||
|
|
@ -32,7 +33,18 @@ const ANSI_RESET = "\x1b[0m";
|
||||||
|
|
||||||
const app = express();
|
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());
|
app.use(compression());
|
||||||
|
|
||||||
// logger middleware
|
// logger middleware
|
||||||
|
|
@ -66,6 +78,7 @@ const fsRoutes = require("./routes/fs");
|
||||||
const vaultRoutes = require("./routes/vault");
|
const vaultRoutes = require("./routes/vault");
|
||||||
const proxyRoutes = require("./routes/proxy");
|
const proxyRoutes = require("./routes/proxy");
|
||||||
const versionRoutes = require("./routes/version");
|
const versionRoutes = require("./routes/version");
|
||||||
|
const settingsRoutes = require("./routes/settings");
|
||||||
const bootstrapRoutes = require("./routes/bootstrap");
|
const bootstrapRoutes = require("./routes/bootstrap");
|
||||||
|
|
||||||
app.use("/assets", express.static(path.join(__dirname, "assets")));
|
app.use("/assets", express.static(path.join(__dirname, "assets")));
|
||||||
|
|
@ -78,6 +91,7 @@ app.use("/api/fs", fsRoutes);
|
||||||
app.use("/api/vault", vaultRoutes);
|
app.use("/api/vault", vaultRoutes);
|
||||||
app.use("/api/proxy", proxyRoutes);
|
app.use("/api/proxy", proxyRoutes);
|
||||||
app.use("/api/version", versionRoutes);
|
app.use("/api/version", versionRoutes);
|
||||||
|
app.use("/api/settings", settingsRoutes);
|
||||||
app.use("/api/plugins", pluginRoutes);
|
app.use("/api/plugins", pluginRoutes);
|
||||||
app.use("/api/bootstrap", bootstrapRoutes);
|
app.use("/api/bootstrap", bootstrapRoutes);
|
||||||
|
|
||||||
|
|
@ -197,7 +211,7 @@ const server = app.listen(config.port, async () => {
|
||||||
|
|
||||||
const wss = setupWebSocket(server, {
|
const wss = setupWebSocket(server, {
|
||||||
getVaultPath: config.getVaultPath,
|
getVaultPath: config.getVaultPath,
|
||||||
originAllowlist: config.wsOrigins,
|
originAllowlist: settings.get("wsOrigins"),
|
||||||
});
|
});
|
||||||
wireDemoWebSocket(server);
|
wireDemoWebSocket(server);
|
||||||
|
|
||||||
|
|
|
||||||
11
apps/ignis-server/server/routes/bootstrap.js
vendored
11
apps/ignis-server/server/routes/bootstrap.js
vendored
|
|
@ -14,6 +14,7 @@ const {
|
||||||
getVirtualPluginsForVault,
|
getVirtualPluginsForVault,
|
||||||
} = require("../plugin-system/manager");
|
} = require("../plugin-system/manager");
|
||||||
const { getVersion } = require("../version");
|
const { getVersion } = require("../version");
|
||||||
|
const settings = require("../settings");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -140,6 +141,11 @@ async function buildEntry(vaultId) {
|
||||||
// In demo mode, hide server-side plugins from the client.
|
// In demo mode, hide server-side plugins from the client.
|
||||||
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
|
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
|
||||||
virtualPlugins: getVirtualPluginsForVault(vaultId, getVersion()),
|
virtualPlugins: getVirtualPluginsForVault(vaultId, getVersion()),
|
||||||
|
settings: {
|
||||||
|
contentCacheBytes: settings.get("contentCacheBytes"),
|
||||||
|
inputCacheBytes: settings.get("inputCacheBytes"),
|
||||||
|
inputCacheTtlMs: settings.get("inputCacheTtlMs"),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsonBuf = Buffer.from(JSON.stringify(response));
|
const jsonBuf = Buffer.from(JSON.stringify(response));
|
||||||
|
|
@ -185,6 +191,10 @@ function invalidateVault(vaultId) {
|
||||||
cache.delete(vaultId);
|
cache.delete(vaultId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invalidateAll() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
async function warmUp() {
|
async function warmUp() {
|
||||||
const ids = Object.keys(config.vaults);
|
const ids = Object.keys(config.vaults);
|
||||||
|
|
||||||
|
|
@ -251,4 +261,5 @@ router.get("/", async (req, res) => {
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.invalidateVault = invalidateVault;
|
module.exports.invalidateVault = invalidateVault;
|
||||||
|
module.exports.invalidateAll = invalidateAll;
|
||||||
module.exports.warmUp = warmUp;
|
module.exports.warmUp = warmUp;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,98 @@
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
|
const dns = require("dns").promises;
|
||||||
|
const net = require("net");
|
||||||
|
const settings = require("../settings");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// POST /api/proxy - forward a request to an external URL to bypass CORS
|
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
|
||||||
// Used by the requestUrl shim for plugin installation, etc.
|
|
||||||
|
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) => {
|
router.post("/", async (req, res) => {
|
||||||
const { url, method, headers, body, binary } = req.body;
|
const { url, method, headers, body, binary } = req.body;
|
||||||
|
|
||||||
|
|
@ -11,7 +100,31 @@ router.post("/", async (req, res) => {
|
||||||
return res.status(400).json({ error: "Missing url" });
|
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 {
|
try {
|
||||||
|
await assertPublicUrl(url);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(e.statusCode || 400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyMode === "allowlist") {
|
||||||
|
const allowlist = settings.get("proxyAllowlist");
|
||||||
|
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 = {
|
const fetchOpts = {
|
||||||
method: method || "GET",
|
method: method || "GET",
|
||||||
headers: headers || {},
|
headers: headers || {},
|
||||||
|
|
@ -26,10 +139,25 @@ router.post("/", async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const upstream = await fetch(url, fetchOpts);
|
const upstream = await fetch(url, fetchOpts);
|
||||||
const respBody = Buffer.from(await upstream.arrayBuffer());
|
|
||||||
|
|
||||||
// Forward response headers, stripping hop-by-hop / encoding headers
|
const declaredLength = Number(upstream.headers.get("content-length"));
|
||||||
// since the body is already decompressed by Node's fetch
|
|
||||||
|
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([
|
const skipHeaders = new Set([
|
||||||
"content-encoding",
|
"content-encoding",
|
||||||
"transfer-encoding",
|
"transfer-encoding",
|
||||||
|
|
@ -37,6 +165,7 @@ router.post("/", async (req, res) => {
|
||||||
"connection",
|
"connection",
|
||||||
]);
|
]);
|
||||||
const respHeaders = {};
|
const respHeaders = {};
|
||||||
|
|
||||||
upstream.headers.forEach((val, key) => {
|
upstream.headers.forEach((val, key) => {
|
||||||
if (!skipHeaders.has(key)) {
|
if (!skipHeaders.has(key)) {
|
||||||
respHeaders[key] = val;
|
respHeaders[key] = val;
|
||||||
|
|
@ -54,3 +183,4 @@ router.post("/", async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
module.exports.isPrivateIp = isPrivateIp;
|
||||||
|
|
|
||||||
62
apps/ignis-server/server/routes/proxy.test.mjs
Normal file
62
apps/ignis-server/server/routes/proxy.test.mjs
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
97
apps/ignis-server/server/routes/settings.js
Normal file
97
apps/ignis-server/server/routes/settings.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
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"];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
writeCoalescer.configure({ writeCoalesceMs: effective.writeCoalesceMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
module.exports.validate = validate;
|
||||||
47
apps/ignis-server/server/routes/settings.test.mjs
Normal file
47
apps/ignis-server/server/routes/settings.test.mjs
Normal file
|
|
@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
114
apps/ignis-server/server/settings.js
Normal file
114
apps/ignis-server/server/settings.js
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
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,
|
||||||
|
// "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;
|
||||||
|
|
||||||
|
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 (ENV_ONLY_KEYS.includes(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) && !ENV_ONLY_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,
|
||||||
|
ENV_ONLY_KEYS,
|
||||||
|
PROXY_MODES,
|
||||||
|
MAX_BODY_BACKSTOP,
|
||||||
|
getAll,
|
||||||
|
get,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
"name": "@ignis/bridge",
|
"name": "@ignis/bridge",
|
||||||
"version": "0.0.0-internal",
|
"version": "0.0.0-internal",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"main": "src/main.js"
|
"main": "src/main.js"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,4 @@ function stopDemoGuards() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startDemoGuards, stopDemoGuards };
|
export { startDemoGuards, stopDemoGuards, isDemoMode };
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const { Notice, TFile, TFolder } = require("obsidian");
|
import { Notice, TFile, TFolder } from "obsidian";
|
||||||
|
|
||||||
function getVaultId() {
|
function getVaultId() {
|
||||||
return window.__currentVaultId || "";
|
return window.__currentVaultId || "";
|
||||||
|
|
@ -92,4 +92,4 @@ function addFolderMenuItems(menu, folder, app) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { showFilePicker, addFileMenuItems, addFolderMenuItems };
|
export { showFilePicker, addFileMenuItems, addFolderMenuItems };
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
const { Plugin, TFile, TFolder } = require("obsidian");
|
import { Plugin, TFile, TFolder } from "obsidian";
|
||||||
const {
|
import {
|
||||||
showFilePicker,
|
showFilePicker,
|
||||||
addFileMenuItems,
|
addFileMenuItems,
|
||||||
addFolderMenuItems,
|
addFolderMenuItems,
|
||||||
} = require("./file-actions");
|
} from "./file-actions.js";
|
||||||
const {
|
import {
|
||||||
patchSettingsModal,
|
patchSettingsModal,
|
||||||
unpatchSettingsModal,
|
unpatchSettingsModal,
|
||||||
} = require("./settings/inject");
|
} from "./settings/inject.js";
|
||||||
const pluginRegistry = require("./plugin-registry");
|
import * as pluginRegistry from "./plugin-registry.js";
|
||||||
const { initStatusBar } = require("./status-bar");
|
import { initStatusBar } from "./status-bar.js";
|
||||||
const { WorkspacePickerModal } = require("./workspace-picker");
|
import { WorkspacePickerModal } from "./workspace-picker.js";
|
||||||
const { startDemoGuards, stopDemoGuards } = require("./demo-guards");
|
import { startDemoGuards, stopDemoGuards } from "./demo-guards.js";
|
||||||
|
|
||||||
class IgnisBridgePlugin extends Plugin {
|
class IgnisBridgePlugin extends Plugin {
|
||||||
async onload() {
|
async onload() {
|
||||||
|
|
@ -65,4 +65,4 @@ class IgnisBridgePlugin extends Plugin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = IgnisBridgePlugin;
|
export default IgnisBridgePlugin;
|
||||||
|
|
|
||||||
|
|
@ -34,4 +34,4 @@ function getKnownIds() {
|
||||||
return knownIds;
|
return knownIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { refresh, isIgnisPlugin, addId, getKnownIds };
|
export { refresh, isIgnisPlugin, addId, getKnownIds };
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
const { Setting } = require("obsidian");
|
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_URL = "https://github.com/Nystik-gh/ignis";
|
||||||
const GITHUB_API_LATEST =
|
const GITHUB_API_LATEST =
|
||||||
|
|
@ -8,11 +11,6 @@ function getVersion() {
|
||||||
return window.__ignis?.version || "unknown";
|
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) {
|
async function checkForUpdate(currentVersion) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(GITHUB_API_LATEST);
|
const res = await fetch(GITHUB_API_LATEST);
|
||||||
|
|
@ -25,7 +23,7 @@ async function checkForUpdate(currentVersion) {
|
||||||
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
|
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
|
||||||
const current = stripBuildMetadata(currentVersion);
|
const current = stripBuildMetadata(currentVersion);
|
||||||
|
|
||||||
if (latest && latest !== current) {
|
if (isNewer(latest, current)) {
|
||||||
return { version: latest, url: data.html_url };
|
return { version: latest, url: data.html_url };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,6 +86,7 @@ function display(containerEl, app) {
|
||||||
});
|
});
|
||||||
|
|
||||||
addServerStatus(containerEl);
|
addServerStatus(containerEl);
|
||||||
|
addServerSettings(containerEl, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
|
|
@ -102,10 +101,26 @@ const STATUS_DOT_CLASSES = {
|
||||||
closed: "ignis-status-disconnected",
|
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) {
|
function addServerStatus(containerEl) {
|
||||||
const ws = window.__ignis.ws;
|
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", {
|
const dotEl = setting.controlEl.createEl("span", {
|
||||||
cls: "ignis-status-dot",
|
cls: "ignis-status-dot",
|
||||||
|
|
@ -138,4 +153,204 @@ function addServerStatus(containerEl) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { display };
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { display };
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
const generalTab = require("./general-tab");
|
import * as generalTab from "./general-tab.js";
|
||||||
const serverPluginsTab = require("./server-plugins-tab");
|
import * as serverPluginsTab from "./server-plugins-tab.js";
|
||||||
const { createNavEl, createTab, createGroup } = require("./settings-ui");
|
import { createNavEl, createTab, createGroup } from "./settings-ui.js";
|
||||||
const {
|
import {
|
||||||
allIgnisNavEls,
|
allIgnisNavEls,
|
||||||
setupPluginTabs,
|
setupPluginTabs,
|
||||||
reconcilePluginTabs,
|
reconcilePluginTabs,
|
||||||
hideIgnisFromCommunityPlugins,
|
hideIgnisFromCommunityPlugins,
|
||||||
restoreCommunityPlugins,
|
restoreCommunityPlugins,
|
||||||
clearOwnedPluginIds,
|
clearOwnedPluginIds,
|
||||||
} = require("./plugin-tabs");
|
} from "./plugin-tabs.js";
|
||||||
|
|
||||||
function removeExistingIgnisGroups(tabHeadersEl) {
|
function removeExistingIgnisGroups(tabHeadersEl) {
|
||||||
const groups = tabHeadersEl.querySelectorAll(".vertical-tab-header-group");
|
const groups = tabHeadersEl.querySelectorAll(".vertical-tab-header-group");
|
||||||
|
|
@ -139,4 +139,4 @@ function unpatchSettingsModal(plugin) {
|
||||||
clearOwnedPluginIds();
|
clearOwnedPluginIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };
|
export { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };
|
||||||
|
|
|
||||||
134
packages/bridge/src/settings/list-editor-modal.js
Normal file
134
packages/bridge/src/settings/list-editor-modal.js
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { Modal, Setting, Notice } from "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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ListEditorModal };
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const { setIcon } = require("obsidian");
|
import { setIcon } from "obsidian";
|
||||||
const { findGroupByTitle } = require("./settings-ui");
|
import { findGroupByTitle } from "./settings-ui.js";
|
||||||
const { isIgnisPlugin } = require("../plugin-registry");
|
import { isIgnisPlugin } from "../plugin-registry.js";
|
||||||
|
|
||||||
// All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group).
|
// 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.
|
// Shared with inject.js so the openTab patch can manage is-active across all of them.
|
||||||
|
|
@ -232,7 +232,7 @@ function clearOwnedPluginIds() {
|
||||||
ownedPluginIds.clear();
|
ownedPluginIds.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
allIgnisNavEls,
|
allIgnisNavEls,
|
||||||
setupPluginTabs,
|
setupPluginTabs,
|
||||||
reconcilePluginTabs,
|
reconcilePluginTabs,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const { Setting, Notice } = require("obsidian");
|
import { Setting, Notice } from "obsidian";
|
||||||
const { reconcilePluginTabs } = require("./plugin-tabs");
|
import { reconcilePluginTabs } from "./plugin-tabs.js";
|
||||||
|
|
||||||
function getVaultId() {
|
function getVaultId() {
|
||||||
return window.__currentVaultId || "";
|
return window.__currentVaultId || "";
|
||||||
|
|
@ -94,4 +94,4 @@ function display(containerEl, app) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { display };
|
export { display };
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const { setIcon } = require("obsidian");
|
import { setIcon } from "obsidian";
|
||||||
|
|
||||||
function createNavEl(tab, setting) {
|
function createNavEl(tab, setting) {
|
||||||
const nav = document.createElement("div");
|
const nav = document.createElement("div");
|
||||||
|
|
@ -86,4 +86,4 @@ function findGroupByTitle(tabHeadersEl, title) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createNavEl, createTab, createGroup, findGroupByTitle };
|
export { createNavEl, createTab, createGroup, findGroupByTitle };
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,4 @@ function initStatusBar(plugin) {
|
||||||
return ws.onStateChange(render);
|
return ws.onStateChange(render);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { initStatusBar };
|
export { initStatusBar };
|
||||||
|
|
|
||||||
39
packages/bridge/src/util/version.js
Normal file
39
packages/bridge/src/util/version.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { stripBuildMetadata, parseSemver, isNewer };
|
||||||
28
packages/bridge/src/util/version.test.js
Normal file
28
packages/bridge/src/util/version.test.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { stripBuildMetadata, isNewer } from "./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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const { FuzzySuggestModal } = require("obsidian");
|
import { FuzzySuggestModal } from "obsidian";
|
||||||
|
|
||||||
class WorkspacePickerModal extends FuzzySuggestModal {
|
class WorkspacePickerModal extends FuzzySuggestModal {
|
||||||
constructor(app) {
|
constructor(app) {
|
||||||
|
|
@ -29,4 +29,4 @@ class WorkspacePickerModal extends FuzzySuggestModal {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { WorkspacePickerModal };
|
export { WorkspacePickerModal };
|
||||||
|
|
|
||||||
|
|
@ -141,3 +141,18 @@
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
margin-bottom: 16px;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ const { WebSocketServer } = require("ws");
|
||||||
const url = require("url");
|
const url = require("url");
|
||||||
const watcher = require("./watcher");
|
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 = {}) {
|
function setupWebSocket(server, opts = {}) {
|
||||||
const { getVaultPath, originAllowlist } = opts;
|
const { getVaultPath, originAllowlist } = opts;
|
||||||
|
|
||||||
|
|
@ -9,11 +14,7 @@ function setupWebSocket(server, opts = {}) {
|
||||||
throw new Error("setupWebSocket: opts.getVaultPath is required");
|
throw new Error("setupWebSocket: opts.getVaultPath is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Null / undefined / empty array = no Origin check.
|
const originSet = toOriginSet(originAllowlist);
|
||||||
const originSet =
|
|
||||||
Array.isArray(originAllowlist) && originAllowlist.length > 0
|
|
||||||
? new Set(originAllowlist)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { showVaultManager } from "../ui-registry.js";
|
import { showVaultManager } from "../ui-registry.js";
|
||||||
import { vaultService } from "@ignis/services";
|
import { vaultService } from "@ignis/services";
|
||||||
import { arrayBufferToBase64, base64ToArrayBuffer } from "../util/base64.js";
|
import { proxyFetch } from "../util/proxy.js";
|
||||||
|
|
||||||
const listeners = new Map();
|
const listeners = new Map();
|
||||||
|
|
||||||
|
|
@ -88,41 +88,19 @@ const syncHandlers = {
|
||||||
|
|
||||||
async function handleRequestUrl(requestId, request) {
|
async function handleRequestUrl(requestId, request) {
|
||||||
try {
|
try {
|
||||||
let body = request.body;
|
const result = await proxyFetch({
|
||||||
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,
|
url: request.url,
|
||||||
method: request.method || "GET",
|
method: request.method,
|
||||||
headers: request.headers || {},
|
headers: request.headers,
|
||||||
|
body: request.body,
|
||||||
contentType: request.contentType,
|
contentType: request.contentType,
|
||||||
body,
|
|
||||||
binary,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
// Electron's e.reply(requestId, data) sends on the requestId channel
|
||||||
ipcRenderer._emit(requestId, {
|
ipcRenderer._emit(requestId, {
|
||||||
status: proxyResult.status,
|
status: result.status,
|
||||||
headers: proxyResult.headers,
|
headers: result.headers,
|
||||||
body: base64ToArrayBuffer(proxyResult.body),
|
body: result.body,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ipcRenderer._emit(requestId, {
|
ipcRenderer._emit(requestId, {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ export class ContentCache {
|
||||||
this._maxSize = maxSize;
|
this._maxSize = maxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMaxSize(maxSize) {
|
||||||
|
this._maxSize = maxSize;
|
||||||
|
|
||||||
|
while (this._currentSize > this._maxSize && this._cache.size > 0) {
|
||||||
|
this._evictOne();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
has(path) {
|
has(path) {
|
||||||
return this._cache.has(this._normalize(path));
|
return this._cache.has(this._normalize(path));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
|
|
||||||
import { normalize } from "../util/path.js";
|
import { normalize } from "../util/path.js";
|
||||||
|
|
||||||
const MAX_SIZE = 200 * 1024 * 1024;
|
let MAX_SIZE = 200 * 1024 * 1024;
|
||||||
const TTL_MS = 5 * 60 * 1000;
|
let TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
const cache = new Map(); // path -> { data, size, createdAt }
|
const cache = new Map(); // path -> { data, size, createdAt }
|
||||||
let currentSize = 0;
|
let currentSize = 0;
|
||||||
|
|
@ -112,6 +112,20 @@ export function inputCacheClear() {
|
||||||
currentSize = 0;
|
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) {
|
export function isInputCachePath(path) {
|
||||||
const norm = normalize(path);
|
const norm = normalize(path);
|
||||||
return norm.startsWith(".obsidian/imports/");
|
return norm.startsWith(".obsidian/imports/");
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import {
|
||||||
unregisterPopupWindow,
|
unregisterPopupWindow,
|
||||||
} from "./electron/remote/window.js";
|
} from "./electron/remote/window.js";
|
||||||
import { showVaultManager } from "./ui-registry.js";
|
import { showVaultManager } from "./ui-registry.js";
|
||||||
import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js";
|
|
||||||
import { isSameOrigin } from "./util/url.js";
|
import { isSameOrigin } from "./util/url.js";
|
||||||
|
import { proxyFetch } from "./util/proxy.js";
|
||||||
|
|
||||||
function installProcess() {
|
function installProcess() {
|
||||||
window.process = processShim;
|
window.process = processShim;
|
||||||
|
|
@ -167,17 +167,15 @@ function installFetchShim() {
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = null;
|
let body = null;
|
||||||
let binary = false;
|
|
||||||
|
|
||||||
if (init?.body && method !== "GET" && method !== "HEAD") {
|
if (init?.body && method !== "GET" && method !== "HEAD") {
|
||||||
if (typeof init.body === "string") {
|
if (typeof init.body === "string") {
|
||||||
body = init.body;
|
body = init.body;
|
||||||
} else if (init.body instanceof ArrayBuffer) {
|
} else if (
|
||||||
body = arrayBufferToBase64(init.body);
|
init.body instanceof ArrayBuffer ||
|
||||||
binary = true;
|
init.body instanceof Uint8Array
|
||||||
} else if (init.body instanceof Uint8Array) {
|
) {
|
||||||
body = arrayBufferToBase64(init.body.buffer);
|
body = init.body;
|
||||||
binary = true;
|
|
||||||
} else if (typeof init.body === "object") {
|
} else if (typeof init.body === "object") {
|
||||||
body = JSON.stringify(init.body);
|
body = JSON.stringify(init.body);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -187,23 +185,15 @@ function installFetchShim() {
|
||||||
|
|
||||||
console.log("[shim:fetch] Proxying cross-origin:", method, url);
|
console.log("[shim:fetch] Proxying cross-origin:", method, url);
|
||||||
|
|
||||||
const proxyRes = await originalFetch("/api/proxy", {
|
let result;
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ url, method, headers, body, binary }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!proxyRes.ok) {
|
try {
|
||||||
const err = await proxyRes
|
result = await proxyFetch({ url, method, headers, body });
|
||||||
.json()
|
} catch (e) {
|
||||||
.catch(() => ({ error: "Proxy request failed" }));
|
throw new TypeError(e.message || "Failed to fetch");
|
||||||
throw new TypeError(err.error || "Failed to fetch");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await proxyRes.json();
|
return new Response(result.body, {
|
||||||
const respBody = base64ToArrayBuffer(result.body);
|
|
||||||
|
|
||||||
return new Response(respBody, {
|
|
||||||
status: result.status,
|
status: result.status,
|
||||||
headers: result.headers,
|
headers: result.headers,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,26 @@ import {
|
||||||
initWorkspacePatch,
|
initWorkspacePatch,
|
||||||
} from "./workspace.js";
|
} from "./workspace.js";
|
||||||
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
||||||
|
import { setInputCacheLimits } from "./fs/input-cache.js";
|
||||||
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
|
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
|
||||||
import { initNativeMenuGuard } from "./native-menu-guard.js";
|
import { initNativeMenuGuard } from "./native-menu-guard.js";
|
||||||
|
|
||||||
let bootstrapVirtualPlugins = [];
|
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() {
|
export function getBootstrapVirtualPlugins() {
|
||||||
return bootstrapVirtualPlugins;
|
return bootstrapVirtualPlugins;
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +227,7 @@ export function initialize() {
|
||||||
applyTree(bootstrap.tree);
|
applyTree(bootstrap.tree);
|
||||||
applyCoreSyncGuard(bootstrap.plugins);
|
applyCoreSyncGuard(bootstrap.plugins);
|
||||||
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
|
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
|
||||||
|
applyServerSettings(bootstrap.settings);
|
||||||
|
|
||||||
// Race the indexer: batch-fetch text content into ContentCache so
|
// Race the indexer: batch-fetch text content into ContentCache so
|
||||||
// Obsidian's startup indexing reads hit the cache instead of the network.
|
// Obsidian's startup indexing reads hit the cache instead of the network.
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ if (window.__currentVaultId) {
|
||||||
|
|
||||||
extractObsidianModule()
|
extractObsidianModule()
|
||||||
.then(async () => {
|
.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 mod = await import("@ignis/bridge");
|
||||||
const IgnisBridgePlugin = mod.default || mod;
|
const IgnisBridgePlugin = mod.default || mod;
|
||||||
const bridge = new IgnisBridgePlugin(window.app, BRIDGE_MANIFEST);
|
const bridge = new IgnisBridgePlugin(window.app, BRIDGE_MANIFEST);
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,8 @@ function readTransform(data) {
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(text);
|
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;
|
obj.nativeMenus = false;
|
||||||
return JSON.stringify(obj);
|
return JSON.stringify(obj);
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +101,9 @@ function patchSetConfig() {
|
||||||
};
|
};
|
||||||
vault.__ignisNativeMenuGuarded = true;
|
vault.__ignisNativeMenuGuarded = true;
|
||||||
|
|
||||||
|
// set to false to override any platform default (like macOS).
|
||||||
|
vault.setConfig("nativeMenus", false);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// Obsidian sets window.requestUrl in app.js, so we override it after app.js loads.
|
// Obsidian sets window.requestUrl in app.js, so we override it after app.js loads.
|
||||||
|
|
||||||
import { isSameOrigin } from "./util/url.js";
|
import { isSameOrigin } from "./util/url.js";
|
||||||
import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js";
|
import { proxyFetch } from "./util/proxy.js";
|
||||||
|
|
||||||
async function proxyRequestUrl(request) {
|
async function proxyRequestUrl(request) {
|
||||||
if (typeof request === "string") {
|
if (typeof request === "string") {
|
||||||
|
|
@ -28,42 +28,14 @@ async function proxyRequestUrl(request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-origin: route through server proxy
|
// Cross-origin: route through server proxy
|
||||||
let body = request.body;
|
const result = await proxyFetch({
|
||||||
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,
|
url: request.url,
|
||||||
method: request.method || "GET",
|
method: request.method,
|
||||||
headers: request.headers || {},
|
headers: request.headers,
|
||||||
body,
|
body: request.body,
|
||||||
binary,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
return makeResponse(request, result.status, result.headers, result.body);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeResponse(request, status, headers, arrayBuf) {
|
function makeResponse(request, status, headers, arrayBuf) {
|
||||||
|
|
|
||||||
54
packages/shim/src/util/proxy.js
Normal file
54
packages/shim/src/util/proxy.js
Normal file
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue