diff --git a/server/index.js b/server/index.js index 352f804..81f08f7 100644 --- a/server/index.js +++ b/server/index.js @@ -30,9 +30,11 @@ app.use((req, res, next) => { const fsRoutes = require("./routes/fs"); const vaultRoutes = require("./routes/vault"); +const proxyRoutes = require("./routes/proxy"); app.use("/api/fs", fsRoutes); app.use("/api/vault", vaultRoutes); +app.use("/api/proxy", proxyRoutes); // Serve vault files for resource URLs (images, attachments, etc.) // Vault ID is the first path segment: /vault-files//path/to/file diff --git a/server/routes/proxy.js b/server/routes/proxy.js new file mode 100644 index 0000000..425b969 --- /dev/null +++ b/server/routes/proxy.js @@ -0,0 +1,45 @@ +const express = require("express"); + +const router = express.Router(); + +// POST /api/proxy - forward a request to an external URL (bypasses browser CORS) +// Used by the requestUrl shim for plugin installation, update checks, etc. +router.post("/", async (req, res) => { + const { url, method, headers, body, binary } = req.body; + if (!url) { + return res.status(400).json({ error: "Missing url" }); + } + + try { + const fetchOpts = { + method: method || "GET", + headers: headers || {}, + }; + if (body && method !== "GET" && method !== "HEAD") { + if (binary && typeof body === "string") { + fetchOpts.body = Buffer.from(body, "base64"); + } else { + fetchOpts.body = body; + } + } + + const upstream = await fetch(url, fetchOpts); + const respBody = Buffer.from(await upstream.arrayBuffer()); + + // Forward response headers + const respHeaders = {}; + upstream.headers.forEach((val, key) => { + respHeaders[key] = val; + }); + + res.json({ + status: upstream.status, + headers: respHeaders, + body: respBody.toString("base64"), + }); + } catch (e) { + res.status(502).json({ error: e.message }); + } +}); + +module.exports = router; diff --git a/shims/electron/ipc-renderer.js b/shims/electron/ipc-renderer.js index 0c75327..44ce92b 100644 --- a/shims/electron/ipc-renderer.js +++ b/shims/electron/ipc-renderer.js @@ -76,6 +76,68 @@ const syncHandlers = { resources: () => "", }; +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; +} + +async function handleRequestUrl(requestId, request) { + try { + let body = request.body; + let binary = false; + if (body instanceof ArrayBuffer) { + body = arrayBufferToBase64(body); + binary = true; + } + + const res = await fetch("/api/proxy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: request.url, + method: request.method || "GET", + headers: request.headers || {}, + contentType: request.contentType, + body, + binary, + }), + }); + + const 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), + }); + } catch (e) { + ipcRenderer._emit(requestId, { + error: e.message, + }); + } +} + export const ipcRenderer = { send(channel, ...args) { console.log("[shim:ipcRenderer] send:", channel, args); @@ -89,6 +151,12 @@ export const ipcRenderer = { ); return; } + + if (channel === "request-url") { + const [requestId, request] = args; + handleRequestUrl(requestId, request); + return; + } }, sendSync(channel, ...args) { diff --git a/shims/loader.js b/shims/loader.js index 29ef564..455ae28 100644 --- a/shims/loader.js +++ b/shims/loader.js @@ -5,6 +5,12 @@ import { pathShim } from "./path.js"; import { urlShim } from "./url.js"; import { cryptoShim } from "./crypto/index.js"; import { processShim } from "./process.js"; +import { installRequestUrlShim } from "./request-url.js"; +import * as childProcessShim from "./node/child_process.js"; +import * as eventsShim from "./node/events.js"; +import * as osShim from "./node/os.js"; +import * as netShim from "./node/net.js"; +import * as httpShim from "./node/http.js"; const DEBUG = true; const _accessLog = new Map(); // "module.property" -> count @@ -53,6 +59,12 @@ const rawRegistry = { path: pathShim, url: urlShim, crypto: cryptoShim, + child_process: childProcessShim, + events: eventsShim, + os: osShim, + net: netShim, + http: httpShim, + https: httpShim, }; const shimRegistry = {}; @@ -182,4 +194,6 @@ window.__currentVaultId = _urlParams.get("vault") || ""; } })(); +installRequestUrlShim(); + console.log("[obsidian-bridge] Shim loader initialized"); diff --git a/shims/node/child_process.js b/shims/node/child_process.js new file mode 100644 index 0000000..960617a --- /dev/null +++ b/shims/node/child_process.js @@ -0,0 +1,15 @@ +function notAvailable(name) { + return function () { + throw new Error( + `child_process.${name}() is not available in the web version.`, + ); + }; +} + +export const exec = notAvailable("exec"); +export const execSync = notAvailable("execSync"); +export const spawn = notAvailable("spawn"); +export const fork = notAvailable("fork"); +export const execFile = notAvailable("execFile"); +export const execFileSync = notAvailable("execFileSync"); +export const spawnSync = notAvailable("spawnSync"); diff --git a/shims/node/events.js b/shims/node/events.js new file mode 100644 index 0000000..6f12da2 --- /dev/null +++ b/shims/node/events.js @@ -0,0 +1,82 @@ +export class EventEmitter { + constructor() { + this._events = {}; + } + + on(event, listener) { + if (!this._events[event]) this._events[event] = []; + this._events[event].push(listener); + return this; + } + + once(event, listener) { + const wrapped = (...args) => { + this.removeListener(event, wrapped); + listener.apply(this, args); + }; + wrapped._original = listener; + return this.on(event, wrapped); + } + + emit(event, ...args) { + const listeners = this._events[event]; + if (!listeners || listeners.length === 0) return false; + for (const fn of [...listeners]) { + fn.apply(this, args); + } + return true; + } + + removeListener(event, listener) { + const arr = this._events[event]; + if (!arr) return this; + const idx = arr.findIndex((fn) => fn === listener || fn._original === listener); + if (idx >= 0) arr.splice(idx, 1); + return this; + } + + off(event, listener) { + return this.removeListener(event, listener); + } + + removeAllListeners(event) { + if (event) { + delete this._events[event]; + } else { + this._events = {}; + } + return this; + } + + listeners(event) { + return (this._events[event] || []).slice(); + } + + listenerCount(event) { + return (this._events[event] || []).length; + } + + addListener(event, listener) { + return this.on(event, listener); + } + + prependListener(event, listener) { + if (!this._events[event]) this._events[event] = []; + this._events[event].unshift(listener); + return this; + } + + eventNames() { + return Object.keys(this._events); + } + + setMaxListeners() { + return this; + } + + getMaxListeners() { + return 10; + } +} + +export default EventEmitter; diff --git a/shims/node/http.js b/shims/node/http.js new file mode 100644 index 0000000..8130407 --- /dev/null +++ b/shims/node/http.js @@ -0,0 +1,47 @@ +// Minimal http/https stub. Plugins needing full http.request won't work, +// but this prevents crashes for plugins that just import the module. + +import { EventEmitter } from "./events.js"; + +export class IncomingMessage extends EventEmitter { + constructor() { + super(); + this.headers = {}; + this.statusCode = 0; + } +} + +export class ClientRequest extends EventEmitter { + constructor() { + super(); + } + end() {} + write() {} + abort() {} + destroy() {} +} + +export function request(options, callback) { + const req = new ClientRequest(); + if (callback) { + req.once("response", callback); + } + // Immediately error - real HTTP requests need fetch or the proxy + setTimeout(() => { + req.emit("error", new Error("http.request is not available in the web version. Use requestUrl() instead.")); + }, 0); + return req; +} + +export function get(options, callback) { + const req = request(options, callback); + req.end(); + return req; +} + +export function createServer() { + throw new Error("http.createServer is not available in the web version."); +} + +export const Agent = class {}; +export const globalAgent = new Agent(); diff --git a/shims/node/net.js b/shims/node/net.js new file mode 100644 index 0000000..b1b9b44 --- /dev/null +++ b/shims/node/net.js @@ -0,0 +1,19 @@ +function notAvailable(name) { + return function () { + throw new Error(`net.${name}() is not available in the web version.`); + }; +} + +export const createServer = notAvailable("createServer"); +export const createConnection = notAvailable("createConnection"); +export const connect = notAvailable("connect"); +export class Socket { + constructor() { + throw new Error("net.Socket is not available in the web version."); + } +} +export class Server { + constructor() { + throw new Error("net.Server is not available in the web version."); + } +} diff --git a/shims/node/os.js b/shims/node/os.js new file mode 100644 index 0000000..6baba02 --- /dev/null +++ b/shims/node/os.js @@ -0,0 +1,49 @@ +export function platform() { + return "linux"; +} + +export function arch() { + return "x64"; +} + +export function homedir() { + return "/"; +} + +export function tmpdir() { + return "/tmp"; +} + +export function hostname() { + return "localhost"; +} + +export function type() { + return "Linux"; +} + +export function release() { + return "0.0.0"; +} + +export function cpus() { + return [{ model: "browser", speed: 0 }]; +} + +export function totalmem() { + return 0; +} + +export function freemem() { + return 0; +} + +export function networkInterfaces() { + return {}; +} + +export function endianness() { + return "LE"; +} + +export const EOL = "\n"; diff --git a/shims/request-url.js b/shims/request-url.js new file mode 100644 index 0000000..57ebedd --- /dev/null +++ b/shims/request-url.js @@ -0,0 +1,111 @@ +// Override window.requestUrl to proxy external requests through our server, +// bypassing browser CORS restrictions. Obsidian sets window.requestUrl = UA +// in app.js, so we override it after app.js loads. + +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 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); +} + +async function proxyRequestUrl(request) { + if (typeof request === "string") { + request = { url: request }; + } + + const isSameOrigin = + request.url.startsWith(window.location.origin) || + request.url.startsWith("/"); + + // Same-origin requests don't need the proxy + if (isSameOrigin) { + const res = await fetch(request.url, { + method: request.method || "GET", + headers: request.headers || {}, + body: request.body, + }); + const arrayBuf = await res.arrayBuffer(); + return makeResponse( + request, + res.status, + Object.fromEntries(res.headers), + arrayBuf, + ); + } + + // Cross-origin: route through server proxy + let body = request.body; + let binary = false; + if (body instanceof ArrayBuffer) { + body = arrayBufferToBase64(body); + binary = true; + } + + const res = await fetch("/api/proxy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: request.url, + method: request.method || "GET", + headers: request.headers || {}, + body, + binary, + }), + }); + + 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, + ); +} + +function makeResponse(request, status, headers, arrayBuf) { + const text = new TextDecoder().decode(arrayBuf); + let json; + try { + json = JSON.parse(text); + } catch { + json = null; + } + return { status, headers, arrayBuffer: arrayBuf, text, json }; +} + +export function installRequestUrlShim() { + // Obsidian sets window.requestUrl in app.js. We override it once the page loads. + // Use a getter so it intercepts even if app.js sets it later. + let _original = null; + + Object.defineProperty(window, "requestUrl", { + get() { + return proxyRequestUrl; + }, + set(val) { + _original = val; + }, + configurable: true, + }); +}