From 5a22eb8fcb7b7a4d27fe7d4957a5ffb02866a824 Mon Sep 17 00:00:00 2001 From: Antoine Auger Date: Tue, 20 Oct 2026 16:25:35 +0200 Subject: [PATCH] fix: rebase external font URLs onto the bundled stylesheet Since fonts stopped being inlined (#8314), the authored URLs in src/static/css/pad/ are emitted verbatim. They are relative to the importing file, so they only resolve when the bundle is served from the document root; under a subpath prefix the ../.. segments climb out of it and every editor and toolbar font 404s. Rebase external font URLs onto the entry stylesheet's directory, which mirrors the served layout under src/static, and leave absolute URLs alone. Add a regression test that resolves the font URLs against a prefixed stylesheet and fails if any escapes the served static root. --- src/node/utils/MinifyWorker.ts | 38 +++++++++++++++------- src/tests/backend/specs/cssFontInlining.ts | 27 +++++++++++---- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/node/utils/MinifyWorker.ts b/src/node/utils/MinifyWorker.ts index a960500b654..c6829545733 100644 --- a/src/node/utils/MinifyWorker.ts +++ b/src/node/utils/MinifyWorker.ts @@ -3,6 +3,7 @@ * Worker thread to minify JS & CSS files out of the main NodeJS thread */ +import path from 'node:path'; import {build, transform} from 'esbuild'; import type {Plugin} from 'esbuild'; @@ -27,29 +28,44 @@ const fontUrlPattern = /\.(ttf|otf|woff2?|eot)(\?.*)?$/i; * had to download (and re-download on each upgrade) before the pad could * render. Keeping the URLs external lets the browser fetch only the fonts it * actually uses, lazily, and cache them separately from the CSS. - */ -const externalFonts: Plugin = { + * + * External URLs are emitted verbatim, so relative paths are rebased onto the + * directory of the bundled stylesheet: the authored paths are relative to the + * importing file, which only resolves correctly when the bundle happens to be + * served from the document root. Rebased URLs stay correct under a subpath + * prefix because served files mirror their layout under `src/static`. Query + * strings and fragments (`?2`, `#iefix`) are part of the last path segment, + * so rebasing keeps them. + */ +const externalFonts = (entryDir: string): Plugin => ({ name: 'external-fonts', setup: (b) => { - b.onResolve({filter: fontUrlPattern}, (args) => ({ - path: args.path, - external: true, - })); + const root = new URL('http://url.invalid/'); + b.onResolve({filter: fontUrlPattern}, (args) => { + const url = URL.parse(args.path, root); + // Absolute URLs (`http://…`, `//host/…`, `/root-relative`) are emitted as-is. + if (url == null || url.origin !== root.origin || path.isAbsolute(args.path)) { + return {path: args.path, external: true}; + } + const rebased = path + .relative(entryDir, path.resolve(args.resolveDir, args.path)) + .split(path.sep).join('/'); + return {path: rebased, external: true}; + }); }, -}; +}); /* * Minify CSS content * @param {string} filename - name of the file - * @param {string} ROOT_DIR - the root dir of Etherpad */ -export const compressCSS = async (content: string) => { +export const compressCSS = async (filename: string) => { const transformedCSS = await build( { - entryPoints: [content], + entryPoints: [filename], minify: true, bundle: true, - plugins: [externalFonts], + plugins: [externalFonts(path.dirname(path.resolve(filename)))], loader:{ '.jpg': 'dataurl', '.png': 'dataurl', diff --git a/src/tests/backend/specs/cssFontInlining.ts b/src/tests/backend/specs/cssFontInlining.ts index 63a48b9d709..f67f3326ee8 100644 --- a/src/tests/backend/specs/cssFontInlining.ts +++ b/src/tests/backend/specs/cssFontInlining.ts @@ -107,12 +107,11 @@ describe(__filename, function () { }); it('points every external font URL at a file that exists', function () { - // The URLs are authored in src/static/css/pad/*.css (fonts.css, - // icons.css) and esbuild emits them verbatim. Browsers resolve them from - // the served location /static/css/pad.css to /static/font/..., which the - // server maps back to src/static/font/... — the same file this resolves - // to from the authoring directory. - const cssDir = path.join(repoRoot, 'src/static/css/pad'); + // The URLs are rebased onto the directory of src/static/css/pad.css, the + // bundled stylesheet, and esbuild emits them as-is. Browsers resolve + // them from the served location /static/css/pad.css to /static/font/..., + // which the server maps back to src/static/font/.... + const cssDir = path.join(repoRoot, 'src/static/css'); const urls = [...css.matchAll(externalFontPattern)].map((m) => m[1]); assert.ok(urls.length > 0, 'expected at least one external font URL'); for (const url of urls) { @@ -122,5 +121,21 @@ describe(__filename, function () { `font URL ${url} does not resolve to an existing file (${resolved})`); } }); + + it('keeps every external font URL under the served static root', function () { + // A deployment behind a subpath (x-proxy-path / X-Forwarded-Prefix) + // serves the stylesheet at e.g. /etherpad/static/css/pad.css. Only URLs + // relative to the stylesheet stay under that prefix; the authored + // ../../../static/font URLs escaped it and 404ed every font. + const stylesheet = 'http://localhost/etherpad/static/css/pad.css'; + const urls = [...css.matchAll(externalFontPattern)].map((m) => m[1]); + assert.ok(urls.length > 0, 'expected at least one external font URL'); + for (const url of urls) { + const {pathname} = new URL(url, stylesheet); + assert.ok( + pathname.startsWith('/etherpad/static/'), + `font URL ${url} resolves to ${pathname}, outside the served static root`); + } + }); }); });