Compare commits

..

8 commits
main ... v0.8.5

Author SHA1 Message Date
Nystik
a51b2d3ffa convert bridge to ESM 2026-06-06 19:28:17 +02:00
Nystik
04be97e48c add unit tests for ssrf guard, version compare, and settings validation 2026-06-06 18:29:48 +02:00
Nystik
7688de599a fix native menu guard, fix /app/data file permission. 2026-06-06 17:25:12 +02:00
Nystik
a7824ac284 add server settings UI and enforcement 2026-06-06 17:05:26 +02:00
Nystik
b43d12f702 add server settings api 2026-06-06 13:04:34 +02:00
Nystik
938a698795 add server settings store 2026-06-06 01:16:01 +02:00
Nystik
3129ed377c use up to date basic_auth directive in caddy. 2026-06-05 23:57:38 +02:00
Nystik
44bb01f162 consolidate cross-origin proxy and add ssrf guard 2026-06-05 23:56:59 +02:00
38 changed files with 1100 additions and 231 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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");
@ -32,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
@ -66,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")));
@ -78,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);
@ -197,7 +211,7 @@ const server = app.listen(config.port, async () => {
const wss = setupWebSocket(server, {
getVaultPath: config.getVaultPath,
originAllowlist: config.wsOrigins,
originAllowlist: settings.get("wsOrigins"),
});
wireDemoWebSocket(server);

View file

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

View file

@ -1,9 +1,98 @@
const express = require("express");
const dns = require("dns").promises;
const net = require("net");
const settings = require("../settings");
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;
@ -11,7 +100,31 @@ 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 });
}
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 = {
method: method || "GET",
headers: headers || {},
@ -26,10 +139,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 +165,7 @@ router.post("/", async (req, res) => {
"connection",
]);
const respHeaders = {};
upstream.headers.forEach((val, key) => {
if (!skipHeaders.has(key)) {
respHeaders[key] = val;
@ -54,3 +183,4 @@ router.post("/", async (req, res) => {
});
module.exports = router;
module.exports.isPrivateIp = isPrivateIp;

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

View 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;

View 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({});
});
});

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

View file

@ -1,71 +0,0 @@
# =============================================================================
# obsidian-notes : Ignis (real Obsidian, in the browser)
# Lives at the ROOT of your manohar/ignis Forgejo mirror as a SEPARATE compose
# file, so upstream's own docker-compose.yml stays untouched and pull-able.
# In Dokploy: Compose app -> repo manohar/ignis -> Compose Path: docker-compose.dokploy.yml
# Public endpoint: https://notes.manohargupta.com (Traefik basicAuth gate)
# =============================================================================
services:
ignis:
# Official image from Docker Hub. Dockerfile lives at apps/ignis-server/ in
# the source repo but there's no reason to build — nobbe/ignis:latest is the
# canonical published image and avoids a long source build on every deploy.
image: nobbe/ignis:latest
container_name: obsidian-ignis
restart: unless-stopped
environment:
- PORT=8080
# Pin the Obsidian version Ignis downloads. Bump deliberately, since a new
# Obsidian can outrun the shim. Keep in sync with what you run on the Mac.
- OBSIDIAN_VERSION=1.12.4
- PUID=1000
- PGID=1000
volumes:
# BIND mount (not a named volume) so the host backup cron can git-commit
# the plain-markdown vault directly. Each subdir here = one Obsidian vault.
- /opt/obsidian/vaults:/vaults
# Ignis internal state (plugin mgmt, sync state, auth tokens).
- /opt/obsidian/data:/app/data
# Cached Obsidian assets — persisted so redeploys don't re-download.
- obsidian-app:/app/obsidian-app
# Rendering happens in YOUR browser, so the server side is light. Cap anyway.
mem_limit: 512m
networks:
- dokploy-network
labels:
- traefik.enable=true
# --- HTTP router on :80. Gated by the SAME basicAuth as belt-and-suspenders
# so port 80 never serves the vault unauthenticated (in case there's no
# global 80->443 redirect). Also serves the ACME challenge. ---
- traefik.http.routers.obsidian-notes-http.rule=Host(`notes.manohargupta.com`)
- traefik.http.routers.obsidian-notes-http.entrypoints=web
- traefik.http.routers.obsidian-notes-http.middlewares=obsidian-auth
# --- HTTPS router on :443, WITH basicAuth ---
- traefik.http.routers.obsidian-notes.rule=Host(`notes.manohargupta.com`)
- traefik.http.routers.obsidian-notes.entrypoints=websecure
- traefik.http.routers.obsidian-notes.tls=true
- traefik.http.routers.obsidian-notes.tls.certresolver=letsencrypt
- traefik.http.routers.obsidian-notes.middlewares=obsidian-auth
# --- basicAuth middleware. The user:bcrypt hash is injected from Dokploy's
# Environment tab (key BASIC_AUTH_USERS) so it never lands in git.
# IMPORTANT: bcrypt hashes contain $ signs. In the Dokploy env tab you
# MUST double every $ so compose doesn't try to expand them as variables:
# htpasswd -nbB manohar 'YOUR_PASSWORD'
# Take the output e.g. manohar:$2y$05$abc... and replace every $ with $$:
# BASIC_AUTH_USERS=manohar:$$2y$$05$$abc...
- traefik.http.middlewares.obsidian-auth.basicauth.users=${BASIC_AUTH_USERS}
# --- Service: Ignis listens on 8080 ---
- traefik.http.services.obsidian-notes.loadbalancer.server.port=8080
- traefik.docker.network=dokploy-network
networks:
dokploy-network:
external: true
volumes:
obsidian-app:

View file

@ -2,5 +2,6 @@
"name": "@ignis/bridge",
"version": "0.0.0-internal",
"private": true,
"type": "module",
"main": "src/main.js"
}

View file

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

View file

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

View file

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

View file

@ -34,4 +34,4 @@ function getKnownIds() {
return knownIds;
}
module.exports = { refresh, isIgnisPlugin, addId, getKnownIds };
export { refresh, isIgnisPlugin, addId, getKnownIds };

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -32,4 +32,4 @@ function initStatusBar(plugin) {
return ws.onStateChange(render);
}
module.exports = { initStatusBar };
export { initStatusBar };

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;
}
export { stripBuildMetadata, parseSemver, isNewer };

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

View file

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

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

@ -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,11 +14,7 @@ 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;
const originSet = toOriginSet(originAllowlist);
const wss = new WebSocketServer({ server, path: "/ws" });

View file

@ -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({
const result = await proxyFetch({
url: request.url,
method: request.method || "GET",
headers: request.headers || {},
method: request.method,
headers: request.headers,
body: request.body,
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
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, {

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

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

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.

View file

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

View file

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

View file

@ -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({
const result = await proxyFetch({
url: request.url,
method: request.method || "GET",
headers: request.headers || {},
body,
binary,
}),
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) {

View 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),
};
}