font-proxy
This commit is contained in:
parent
c6a65c4878
commit
b9b04fb28d
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
83
vite/font-proxy.ts
Normal 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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue