Browse Source

bug-fix

imwald
Silberengel 2 weeks ago
parent
commit
7978c60f11
  1. 97
      electron/main.cjs
  2. 7
      electron/preload.cjs
  3. 4
      package-lock.json
  4. 2
      package.json
  5. 60
      src/lib/electron-aware-fetch.ts
  6. 3
      src/lib/languagetool-client.ts
  7. 5
      src/lib/translate-client.ts
  8. 14
      src/vite-env.d.ts

97
electron/main.cjs

@ -3,6 +3,7 @@ @@ -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() { @@ -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() { @@ -290,6 +386,7 @@ function createWindow() {
app.whenReady().then(() => {
relaxCorsForRendererSubresources()
registerImwaldBackendRequestIpc()
ipcMain.handle('imwald:reload-app', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender)

7
electron/preload.cjs

@ -4,5 +4,10 @@ const { contextBridge, ipcRenderer } = require('electron') @@ -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)
})

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -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",

2
package.json

@ -1,6 +1,6 @@ @@ -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",

60
src/lib/electron-aware-fetch.ts

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
import { isImwaldElectron } from '@/lib/client-platform'
export type ImwaldBackendResponse = {
status: number
statusText: string
headers: Record<string, string>
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<Response> {
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<string, string> = {}
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
})
}

3
src/lib/languagetool-client.ts

@ -1,4 +1,5 @@ @@ -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( @@ -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(),

5
src/lib/translate-client.ts

@ -1,4 +1,5 @@ @@ -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<TranslateLanguageOption @@ -82,7 +83,7 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption
return languagesCache.list
}
const url = `${base}/languages`
const res = await fetch(url)
const res = await electronAwareFetch(url)
if (!res.ok) {
logger.warn('[Translate] /languages failed', { status: res.status })
languagesCache = null
@ -130,7 +131,7 @@ export async function translatePlainText( @@ -130,7 +131,7 @@ export async function translatePlainText(
}
const url = `${base}/translate`
const res = await fetch(url, {
const res = await electronAwareFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

14
src/vite-env.d.ts vendored

@ -14,6 +14,20 @@ declare global { @@ -14,6 +14,20 @@ declare global {
isElectron: true
/** Ask Electron main to reload index safely (avoids file:// history path reload issues). */
reloadApp?: () => Promise<boolean>
/**
* Allowlisted HTTP(S) from main (translate + LanguageTool). See `electronAwareFetch`.
*/
backendRequest?: (payload: {
url: string
method: string
headers: Record<string, string>
body: string | null
}) => Promise<{
status: number
statusText: string
headers: Record<string, string>
body: string
}>
}
}
}

Loading…
Cancel
Save