diff --git a/electron/main.cjs b/electron/main.cjs index b80febdf..22464c5a 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -3,6 +3,7 @@ const { app, BrowserWindow, ipcMain, shell, Menu, session } = require('electron') const fs = require('fs') const http = require('http') +const https = require('https') const path = require('path') /** True when running from source (`electron .`); false when packaged. */ @@ -228,6 +229,101 @@ function relaxCorsForRendererSubresources() { }) } +/** Hostnames allowed for main-process translate / LanguageTool proxy (HTTPS only, except loopback HTTP for dev). */ +function parseImwaldBackendHosts() { + const raw = process.env.IMWALD_ELECTRON_BACKEND_HOSTS || 'jumble.imwald.eu' + return new Set( + raw + .split(/[,;\s]+/u) + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) + ) +} + +const imwaldBackendHosts = parseImwaldBackendHosts() + +function isAllowedImwaldBackendUrl(urlString) { + let u + try { + u = new URL(urlString) + } catch { + return false + } + const path = u.pathname + if (!path.startsWith('/api/translate') && !path.startsWith('/api/languagetool')) { + return false + } + const host = u.hostname.toLowerCase() + if (u.protocol === 'https:' && imwaldBackendHosts.has(host)) return true + if (u.protocol === 'http:' && (host === '127.0.0.1' || host === 'localhost')) return true + return false +} + +const STRIP_OUTBOUND_REQUEST_HEADERS = new Set([ + 'host', + 'connection', + 'content-length', + 'transfer-encoding', + 'keep-alive' +]) + +function requestImwaldBackend(urlString, { method, headers, body }) { + return new Promise((resolve, reject) => { + const u = new URL(urlString) + const useTls = u.protocol === 'https:' + const lib = useTls ? https : http + const port = u.port || (useTls ? 443 : 80) + const safeHeaders = {} + if (headers && typeof headers === 'object') { + for (const [k, v] of Object.entries(headers)) { + if (STRIP_OUTBOUND_REQUEST_HEADERS.has(k.toLowerCase())) continue + if (typeof v === 'string') safeHeaders[k] = v + } + } + const opts = { + hostname: u.hostname, + port, + path: `${u.pathname}${u.search}`, + method: (method || 'GET').toUpperCase(), + headers: safeHeaders + } + const req = lib.request(opts, (res) => { + const chunks = [] + res.on('data', (chunk) => chunks.push(chunk)) + res.on('end', () => { + const buf = Buffer.concat(chunks) + const outHeaders = {} + for (const [k, v] of Object.entries(res.headers)) { + if (v === undefined) continue + outHeaders[k] = Array.isArray(v) ? v.join(', ') : String(v) + } + resolve({ + status: res.statusCode || 0, + statusText: res.statusMessage || '', + headers: outHeaders, + body: buf.toString('utf8') + }) + }) + }) + req.on('error', reject) + if (body) req.write(body, 'utf8') + req.end() + }) +} + +function registerImwaldBackendRequestIpc() { + ipcMain.handle('imwald:backend-request', async (_event, payload) => { + const url = payload && typeof payload.url === 'string' ? payload.url : '' + if (!isAllowedImwaldBackendUrl(url)) { + throw new Error('imwald:backend-request: URL not allowed') + } + const method = payload && typeof payload.method === 'string' ? payload.method : 'GET' + const headers = payload && payload.headers && typeof payload.headers === 'object' ? payload.headers : {} + const body = payload && typeof payload.body === 'string' ? payload.body : null + return requestImwaldBackend(url, { method, headers, body }) + }) +} + function createWindow() { const win = new BrowserWindow({ width: 1280, @@ -290,6 +386,7 @@ function createWindow() { app.whenReady().then(() => { relaxCorsForRendererSubresources() + registerImwaldBackendRequestIpc() ipcMain.handle('imwald:reload-app', async (event) => { const win = BrowserWindow.fromWebContents(event.sender) diff --git a/electron/preload.cjs b/electron/preload.cjs index d5235c35..f3ff2286 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -4,5 +4,10 @@ const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('imwaldElectron', { isElectron: true, - reloadApp: () => ipcRenderer.invoke('imwald:reload-app') + reloadApp: () => ipcRenderer.invoke('imwald:reload-app'), + /** + * Same-origin translate / LanguageTool from the renderer hits CORS when the shell is loopback. + * Main process performs the HTTP(S) request (allowlisted host + path only). + */ + backendRequest: (payload) => ipcRenderer.invoke('imwald:backend-request', payload) }) diff --git a/package-lock.json b/package-lock.json index 860efc8f..2a2bb241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.0.8", + "version": "23.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.0.8", + "version": "23.0.9", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index a50892e3..026c218d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.0.8", + "version": "23.0.9", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/lib/electron-aware-fetch.ts b/src/lib/electron-aware-fetch.ts new file mode 100644 index 00000000..08947cbd --- /dev/null +++ b/src/lib/electron-aware-fetch.ts @@ -0,0 +1,60 @@ +import { isImwaldElectron } from '@/lib/client-platform' + +export type ImwaldBackendResponse = { + status: number + statusText: string + headers: Record + body: string +} + +/** + * In Electron, translate / LanguageTool hit `https://…` from a loopback document origin and Chromium + * CORS blocks them. {@link window.imwaldElectron.backendRequest} runs the same HTTP(S) call in main. + */ +export async function electronAwareFetch(input: string, init?: RequestInit): Promise { + if (init?.signal?.aborted) { + throw new DOMException('Aborted', 'AbortError') + } + const bridge = typeof window !== 'undefined' ? window.imwaldElectron : undefined + if (!isImwaldElectron() || typeof bridge?.backendRequest !== 'function') { + return fetch(input, init) + } + + const method = (init?.method ?? 'GET').toUpperCase() + const headers: Record = {} + if (init?.headers) { + new Headers(init.headers as HeadersInit).forEach((value, key) => { + headers[key] = value + }) + } + + let body: string | null = null + if (init?.body != null) { + const b = init.body + if (typeof b === 'string') body = b + else if (b instanceof URLSearchParams) body = b.toString() + else if (b instanceof ArrayBuffer) body = new TextDecoder().decode(b) + else if (typeof Blob !== 'undefined' && b instanceof Blob) body = await b.text() + else body = String(b) + } + + const raw = (await bridge.backendRequest({ + url: input, + method, + headers, + body + })) as ImwaldBackendResponse + + const hdrs = new Headers() + if (raw.headers && typeof raw.headers === 'object') { + for (const [k, v] of Object.entries(raw.headers)) { + if (typeof v === 'string') hdrs.set(k, v) + } + } + + return new Response(raw.body ?? '', { + status: raw.status ?? 0, + statusText: raw.statusText ?? '', + headers: hdrs + }) +} diff --git a/src/lib/languagetool-client.ts b/src/lib/languagetool-client.ts index 2be6d2ae..21e4ee85 100644 --- a/src/lib/languagetool-client.ts +++ b/src/lib/languagetool-client.ts @@ -1,4 +1,5 @@ import { LANGUAGE_TOOL_URL } from '@/constants' +import { electronAwareFetch } from '@/lib/electron-aware-fetch' import logger from '@/lib/logger' export type LanguageToolMatch = { @@ -35,7 +36,7 @@ export async function languageToolCheck( body.set('language', language) body.set('enabledOnly', 'false') - const res = await fetch(url, { + const res = await electronAwareFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), diff --git a/src/lib/translate-client.ts b/src/lib/translate-client.ts index 33ddc76a..71c7385c 100644 --- a/src/lib/translate-client.ts +++ b/src/lib/translate-client.ts @@ -1,4 +1,5 @@ import { TRANSLATE_URL } from '@/constants' +import { electronAwareFetch } from '@/lib/electron-aware-fetch' import logger from '@/lib/logger' import { sha256 } from '@noble/hashes/sha256' import { bytesToHex } from '@noble/hashes/utils' @@ -82,7 +83,7 @@ export async function fetchTranslateLanguages(): Promise Promise + /** + * Allowlisted HTTP(S) from main (translate + LanguageTool). See `electronAwareFetch`. + */ + backendRequest?: (payload: { + url: string + method: string + headers: Record + body: string | null + }) => Promise<{ + status: number + statusText: string + headers: Record + body: string + }> } } }