diff --git a/docker/static-server.mjs b/docker/static-server.mjs index 505ba5850..9e44a96b1 100644 --- a/docker/static-server.mjs +++ b/docker/static-server.mjs @@ -15,6 +15,57 @@ const ROOT = process.env.OPENPENCIL_WEB_ROOT ?? join(import.meta.dir, 'dist') const PORT = Number(process.env.OPENPENCIL_WEB_PORT ?? 3100) const HOST = process.env.OPENPENCIL_WEB_HOST ?? '0.0.0.0' +// W4C fork delta: same-origin proxy for the CORS-hostile online-font catalog +// (https://fonts.google.com/metadata/fonts). The browser app (which is a W4C +// fork) rewrites those requests to /__font-proxy?url=... so they stay +// same-origin; we forward them upstream here. Mirrors vite/font-proxy.ts. +const FONT_PROXY_PREFIX = '/__font-proxy' +const PROXIED_FONT_HOSTS = ['fonts.google.com'] +const FONT_PROXY_USER_AGENT = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36' + +async function handleFontProxy(req, res, hostHeader) { + const target = new URL(req.url ?? '/', `http://${hostHeader}`).searchParams.get('url') + if (!target) { + res.writeHead(400) + res.end('missing url') + return + } + let targetUrl + try { + targetUrl = new URL(decodeURIComponent(target)) + } catch { + res.writeHead(400) + res.end('invalid url') + return + } + if (targetUrl.protocol !== 'https:' || !PROXIED_FONT_HOSTS.includes(targetUrl.host)) { + res.writeHead(403) + res.end('proxied host denied') + return + } + try { + const upstream = await fetch(targetUrl, { + headers: { + 'user-agent': FONT_PROXY_USER_AGENT, + accept: 'application/json, text/plain, */*' + }, + redirect: 'follow' + }) + const body = Buffer.from(await upstream.arrayBuffer()) + const type = upstream.headers.get('content-type') + res.writeHead(upstream.status, { + 'content-type': type ?? 'application/octet-stream', + 'cache-control': upstream.status === 200 ? 'public, max-age=86400' : 'no-store', + 'access-control-allow-origin': '*' + }) + res.end(body) + } catch (err) { + res.writeHead(502) + res.end(String(err)) + } +} + const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', @@ -44,6 +95,10 @@ const server = createServer(async (req, res) => { } catch { pathname = '/' } + if (pathname === FONT_PROXY_PREFIX) { + await handleFontProxy(req, res, req.headers.host) + return + } if (pathname === '/') pathname = '/index.html' // Prevent path traversal outside ROOT. diff --git a/src/app/editor/fonts/index.ts b/src/app/editor/fonts/index.ts index 754b05714..175ad1c5c 100644 --- a/src/app/editor/fonts/index.ts +++ b/src/app/editor/fonts/index.ts @@ -71,7 +71,48 @@ function configureTauriFontCache() { fontManager.setHostFontLoader(loadSystemFont) } +// W4C fork delta: relay CORS-hostile online-font catalog requests through a +// same-origin endpoint so the browser app can build the Google Fonts catalog. +// unifont's `google` provider reads `https://fonts.google.com/metadata/fonts`, +// which never sends `Access-Control-Allow-Origin` and sets `cross-origin-resource-policy: +// same-site`, so it cannot be read from any non-Google origin. The browser here +// rewrites those to `/__font-proxy?url=...` (served by a Vite dev plugin and by +// docker/static-server.mjs), which keeps the request same-origin while the server +// forwards it upstream. Glyph files (fonts.gstatic.com) already send CORS headers +// and are fetched directly. +const FONT_PROXY_PREFIX = '/__font-proxy' +const PROXIED_FONT_HOSTS = new Set(['fonts.google.com']) + +// Capture the genuine native fetch once, at module load. `withFetchProxy` in +// the core font resolver temporarily swaps `globalThis.fetch` for a wrapper +// that routes every http(s) request through this fetcher, so calling the +// global `fetch` here would recurse into ourselves (stack overflow). The +// captured reference bypasses that wrapper. +const nativeFetch = globalThis.fetch + +function createBrowserFontFetch(): (url: string, init?: RequestInit) => Promise { + return (url, init) => { + let host = '' + try { + host = new URL(url).host + } catch { + // Leave invalid URLs to native fetch. + console.warn(`[fonts] invalid URL skipped for font proxy: ${url}`) + } + if (PROXIED_FONT_HOSTS.has(host)) { + return nativeFetch(`${FONT_PROXY_PREFIX}?url=${encodeURIComponent(url)}`, init) + } + return nativeFetch(url, init) + } +} + +function configureBrowserFontProxy() { + if (isTauri()) return + fontManager.setWebFontFetch(createBrowserFontFetch()) +} + configureTauriFontCache() +configureBrowserFontProxy() interface TauriFontFamily { family: string diff --git a/vite.config.ts b/vite.config.ts index 67f965485..9d37083c9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ import packageJson from './package.json' import { createOpenPencilAliases } from './vite/aliases' import { localAutomationToken, openPencilAutomationPlugin } from './vite/automation' import { copyCanvasKitAssetsPlugin } from './vite/canvaskit-assets' +import { createDevFontProxyPlugin } from './vite/font-proxy' import { openPencilPwaPlugin } from './vite/pwa' import { rawMarkdownPlugin } from './vite/raw-markdown' import { createDevServerOptions } from './vite/server' @@ -33,6 +34,7 @@ export default defineConfig(async ({ command }) => ({ Components({ resolvers: [IconsResolver({ prefix: 'icon' })] }), openPencilAutomationPlugin(command, host), vue(), + createDevFontProxyPlugin(), openPencilPwaPlugin() ], clearScreen: false, diff --git a/vite/font-proxy.ts b/vite/font-proxy.ts new file mode 100644 index 000000000..8ab591931 --- /dev/null +++ b/vite/font-proxy.ts @@ -0,0 +1,83 @@ +import type { Plugin } from 'vite' + +// Dev-only same-origin proxy for CORS-hostile online-font catalog endpoints. +// +// unifont's `google` provider reads `https://fonts.google.com/metadata/fonts` to +// build its family catalog. That endpoint never sends `Access-Control-Allow-Origin` +// and sets `cross-origin-resource-policy: same-site`, so a browser app served on +// its own origin (any origin that is not Google) cannot read it directly — the +// provider fails to initialize with "Could not initialize provider `google`". +// +// The browser relays such requests here instead (see `createFontProxyFetch()` in +// src/app/editor/fonts), which keeps the request same-origin and forwards it +// server-side to Google. This is a W4C fork delta; upstream openpencil does not +// ship it because Tauri/goes through the Rust command that is not CORS-bound. + +export const FONT_PROXY_PREFIX = '/__font-proxy' + +// Only these hosts are CORS-hostile from the browser; everything else +// (notably fonts.gstatic.com glyph files) already sends CORS headers and is +// fetched directly by the browser. +const PROXIED_HOSTS = new Set(['fonts.google.com']) + +export function createDevFontProxyPlugin(): Plugin { + return { + name: 'open-pencil-dev-font-proxy', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + let pathname: string + try { + pathname = new URL(req.url ?? '/', 'http://dev').pathname + } catch { + next() + return + } + if (pathname !== FONT_PROXY_PREFIX) { + next() + return + } + + const target = new URL(req.url ?? '/', 'http://dev').searchParams.get('url') + if (!target) { + res.statusCode = 400 + res.end('missing url') + return + } + + let targetURL: URL + try { + targetURL = new URL(decodeURIComponent(target)) + } catch { + res.statusCode = 400 + res.end('invalid url') + return + } + if (targetURL.protocol !== 'https:' || !PROXIED_HOSTS.has(targetURL.host)) { + res.statusCode = 403 + res.end('proxied host denied') + return + } + + try { + const upstream = await fetch(targetURL, { + headers: { + 'user-agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36', + accept: 'application/json, text/plain, */*', + }, + redirect: 'follow', + }) + const body = Buffer.from(await upstream.arrayBuffer()) + res.statusCode = upstream.status + const type = upstream.headers.get('content-type') + if (type) res.setHeader('content-type', type) + res.setHeader('access-control-allow-origin', '*') + res.end(body) + } catch (e) { + res.statusCode = 502 + res.end(String(e)) + } + }) + }, + } +}