From d5027795e992527081f27cdba60b4a01c21d42f4 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Tue, 24 Mar 2026 01:06:30 +0100 Subject: [PATCH] add fetch() shim to proxy cross-origin requests through server --- CHANGELOG.md | 13 ++++ package.json | 2 +- server/routes/proxy.js | 13 +++- src/shims/globals.js | 139 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9dec65..b027f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. +## [0.6.1] - Slifer (2026-03-24) + +### Added + +- `fetch()` shim that proxies cross-origin requests through `/api/proxy` to bypass CORS restrictions +- Automatic `Origin: app://obsidian.md` header injection for cross-origin requests to match Obsidian desktop app +- User-Agent forwarding from browser to proxy for cross-origin requests + +### Fixed + +- Obsidian Sync API authentication now works in browser (was blocked by CORS) +- Proxy response headers cleaned to exclude hop-by-hop headers (`content-encoding`, `transfer-encoding`, `content-length`, `connection`) + ## [0.6.0] - Slifer (2026-03-23) ### Added diff --git a/package.json b/package.json index 71a15f4..d1a7c04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ignis", - "version": "0.6.0", + "version": "0.6.1", "private": true, "description": "An Electron shim and server bridge for running Obsidian in a browser.", "scripts": { diff --git a/server/routes/proxy.js b/server/routes/proxy.js index d976c19..57eba76 100644 --- a/server/routes/proxy.js +++ b/server/routes/proxy.js @@ -28,10 +28,19 @@ router.post("/", async (req, res) => { const upstream = await fetch(url, fetchOpts); const respBody = Buffer.from(await upstream.arrayBuffer()); - // Forward response headers + // Forward response headers, stripping hop-by-hop / encoding headers + // since the body is already decompressed by Node's fetch + const skipHeaders = new Set([ + "content-encoding", + "transfer-encoding", + "content-length", + "connection", + ]); const respHeaders = {}; upstream.headers.forEach((val, key) => { - respHeaders[key] = val; + if (!skipHeaders.has(key)) { + respHeaders[key] = val; + } }); res.json({ diff --git a/src/shims/globals.js b/src/shims/globals.js index befdd64..b1e2957 100644 --- a/src/shims/globals.js +++ b/src/shims/globals.js @@ -115,6 +115,144 @@ function installWindowOpen() { }; } +function arrayBufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ""; + const chunk = 8192; + + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + } + + return btoa(binary); +} + +function base64ToArrayBuffer(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes.buffer; +} + +function isSameOrigin(url) { + if ( + !url || + url.startsWith("/") || + url.startsWith("./") || + url.startsWith("../") + ) { + return true; + } + + if (url.startsWith("data:") || url.startsWith("blob:")) { + return true; + } + + try { + const parsed = new URL(url, window.location.origin); + return parsed.origin === window.location.origin; + } catch { + return true; + } +} + +function installFetchShim() { + const originalFetch = window.fetch.bind(window); + window.__originalFetch = originalFetch; + + window.fetch = async function (input, init) { + let url; + + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.href; + } else if (input instanceof Request) { + url = input.url; + } else { + url = String(input); + } + + if (isSameOrigin(url)) { + return originalFetch(input, init); + } + + // Cross-origin - route through server proxy + const method = ( + init?.method || (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + const headers = {}; + + if (init?.headers) { + const h = + init.headers instanceof Headers + ? init.headers + : new Headers(init.headers); + h.forEach((val, key) => { + headers[key] = val; + }); + } else if (input instanceof Request) { + input.headers.forEach((val, key) => { + headers[key] = val; + }); + } + + // Mimic the real Obsidian desktop app headers for cross-origin requests + if (!headers["user-agent"] && !headers["User-Agent"]) { + headers["user-agent"] = navigator.userAgent; + } + if (!headers["origin"] && !headers["Origin"]) { + headers["origin"] = "app://obsidian.md"; + } + + 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 (typeof init.body === "object") { + body = JSON.stringify(init.body); + } else { + body = String(init.body); + } + } + + 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 }), + }); + + if (!proxyRes.ok) { + const err = await proxyRes + .json() + .catch(() => ({ error: "Proxy request failed" })); + throw new TypeError(err.error || "Failed to fetch"); + } + + const result = await proxyRes.json(); + const respBody = base64ToArrayBuffer(result.body); + + return new Response(respBody, { + status: result.status, + headers: result.headers, + }); + }; +} + function installContextMenuFix() { // hacky fix to prevent browser from showing context menu while allowing obsidian context menu window.addEventListener( @@ -130,6 +268,7 @@ function installContextMenuFix() { export function installGlobals() { installProcess(); installBuffer(); + installFetchShim(); installWindowClose(); installWindowOpen(); installContextMenuFix();