Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions src/node/utils/MinifyWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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',
Expand Down
27 changes: 21 additions & 6 deletions src/tests/backend/specs/cssFontInlining.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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`);
}
});
});
});
Loading