font-proxy

This commit is contained in:
Vitali sharp8n 2026-08-27 01:42:41 +03:00
parent c6a65c4878
commit b9b04fb28d
4 changed files with 181 additions and 0 deletions

View file

@ -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 PORT = Number(process.env.OPENPENCIL_WEB_PORT ?? 3100)
const HOST = process.env.OPENPENCIL_WEB_HOST ?? '0.0.0.0' 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 = { const MIME = {
'.html': 'text/html; charset=utf-8', '.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
@ -44,6 +95,10 @@ const server = createServer(async (req, res) => {
} catch { } catch {
pathname = '/' pathname = '/'
} }
if (pathname === FONT_PROXY_PREFIX) {
await handleFontProxy(req, res, req.headers.host)
return
}
if (pathname === '/') pathname = '/index.html' if (pathname === '/') pathname = '/index.html'
// Prevent path traversal outside ROOT. // Prevent path traversal outside ROOT.

View file

@ -71,7 +71,48 @@ function configureTauriFontCache() {
fontManager.setHostFontLoader(loadSystemFont) 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<Response> {
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() configureTauriFontCache()
configureBrowserFontProxy()
interface TauriFontFamily { interface TauriFontFamily {
family: string family: string

View file

@ -11,6 +11,7 @@ import packageJson from './package.json'
import { createOpenPencilAliases } from './vite/aliases' import { createOpenPencilAliases } from './vite/aliases'
import { localAutomationToken, openPencilAutomationPlugin } from './vite/automation' import { localAutomationToken, openPencilAutomationPlugin } from './vite/automation'
import { copyCanvasKitAssetsPlugin } from './vite/canvaskit-assets' import { copyCanvasKitAssetsPlugin } from './vite/canvaskit-assets'
import { createDevFontProxyPlugin } from './vite/font-proxy'
import { openPencilPwaPlugin } from './vite/pwa' import { openPencilPwaPlugin } from './vite/pwa'
import { rawMarkdownPlugin } from './vite/raw-markdown' import { rawMarkdownPlugin } from './vite/raw-markdown'
import { createDevServerOptions } from './vite/server' import { createDevServerOptions } from './vite/server'
@ -33,6 +34,7 @@ export default defineConfig(async ({ command }) => ({
Components({ resolvers: [IconsResolver({ prefix: 'icon' })] }), Components({ resolvers: [IconsResolver({ prefix: 'icon' })] }),
openPencilAutomationPlugin(command, host), openPencilAutomationPlugin(command, host),
vue(), vue(),
createDevFontProxyPlugin(),
openPencilPwaPlugin() openPencilPwaPlugin()
], ],
clearScreen: false, clearScreen: false,

83
vite/font-proxy.ts Normal file
View file

@ -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))
}
})
},
}
}