diff --git a/lib/extension/frontend.ts b/lib/extension/frontend.ts index db0f4206c..576d714ff 100644 --- a/lib/extension/frontend.ts +++ b/lib/extension/frontend.ts @@ -6,12 +6,11 @@ import {createServer as createSecureServer} from "node:https"; import type {Socket} from "node:net"; import {posix} from "node:path"; import bind from "bind-decorator"; -import expressStaticGzip from "express-static-gzip"; -import finalhandler from "finalhandler"; import WebSocket from "ws"; import data from "../util/data"; import logger from "../util/logger"; import * as settings from "../util/settings"; +import {createStaticFileServer, sendNotFound} from "../util/staticFileServer"; import {stringify} from "../util/stringify"; import utils from "../util/utils"; import Extension from "./extension"; @@ -65,46 +64,39 @@ export class Frontend extends Extension { return false; }; - const options: expressStaticGzip.ExpressStaticGzipOptions = { - enableBrotli: true, - serveStatic: { - /* v8 ignore start */ - setHeaders: (res: ServerResponse, path: string): void => { - if (path.endsWith("index.html")) { - res.setHeader("Cache-Control", "no-store"); - } - }, - /* v8 ignore stop */ - }, - }; const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend"); - const fileServer = expressStaticGzip(frontend.default.getPath(), options); - const deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options); + const logError = logger.error.bind(logger); + const fileServer = createStaticFileServer(frontend.default.getPath(), logError); + const deviceIconsFileServer = createStaticFileServer(data.joinPath("device_icons"), logError); const onRequest = (request: IncomingMessage, response: ServerResponse): void => { - const next = finalhandler(request, response); // biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server` - const newUrl = posix.relative(this.baseUrl, request.url!); + const url = request.url!; + const newUrl = posix.relative(this.baseUrl, url); // The request url is not within the frontend base url, so the relative path starts with '..' if (newUrl.startsWith(".")) { - next(); + sendNotFound(request, response); + + return; + } + + // The base url itself is a directory, redirect to its trailing slash form so the browser resolves the + // relative asset paths in `index.html` against the frontend root instead of against its parent. + if (newUrl === "" && !url.endsWith("/")) { + response.writeHead(301, {Location: `${url}/`}); + response.end(); return; } - // Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory. - // This is necessary for the browser to resolve relative assets paths correctly. - request.originalUrl = request.url; request.url = `/${newUrl}`; - request.path = request.url; if (newUrl.startsWith("device_icons/")) { - request.path = request.path.replace("device_icons/", ""); request.url = request.url.replace("/device_icons", ""); - deviceIconsFileServer(request, response, next); + deviceIconsFileServer(request, response); } else { - fileServer(request, response, next); + fileServer(request, response); } }; diff --git a/lib/types/dom.shim.d.ts b/lib/types/dom.shim.d.ts index d11e9b4f8..ce5881fff 100644 --- a/lib/types/dom.shim.d.ts +++ b/lib/types/dom.shim.d.ts @@ -10,6 +10,12 @@ declare global { const removeEventListener: import("node:events").EventEmitter["removeListener"]; /** @deprecated DOM SHIM, DO NOT USE */ const postMessage: import("node:worker_threads").MessagePort["postMessage"]; + /** + * Required by `srvx` <= 0.12.5, remove once a release including https://github.com/h3js/srvx/pull/288 is out. + * + * @deprecated DOM SHIM, DO NOT USE + */ + type HeadersInit = string[][] | Record | Headers; } export {}; diff --git a/lib/types/zigbee2mqtt-frontend.d.ts b/lib/types/zigbee2mqtt-frontend.d.ts index 11be0682a..5f0c5f437 100644 --- a/lib/types/zigbee2mqtt-frontend.d.ts +++ b/lib/types/zigbee2mqtt-frontend.d.ts @@ -5,10 +5,3 @@ declare module "zigbee2mqtt-frontend" { export default frontend; } - -declare module "node:http" { - interface IncomingMessage { - originalUrl?: string; - path?: string; - } -} diff --git a/lib/util/onboarding.ts b/lib/util/onboarding.ts index 120d5b468..bea8cb700 100644 --- a/lib/util/onboarding.ts +++ b/lib/util/onboarding.ts @@ -1,31 +1,15 @@ import {existsSync, mkdirSync, writeFileSync} from "node:fs"; -import type {ServerResponse} from "node:http"; import {createServer} from "node:http"; import path from "node:path"; -import expressStaticGzip from "express-static-gzip"; import {type Unzipped, unzip} from "fflate"; -import finalhandler from "finalhandler"; import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery"; import type {OnboardData, OnboardFailureData, OnboardSubmitResponse, Zigbee2MQTTSettings} from "../types/api"; import {stringify} from "../util/stringify"; import data from "./data"; import * as settings from "./settings"; +import {createStaticFileServer} from "./staticFileServer"; import {YAMLFileException} from "./yaml"; -/** same as extension/frontend */ -const FILE_SERVER_OPTIONS: expressStaticGzip.ExpressStaticGzipOptions = { - enableBrotli: true, - serveStatic: { - /* v8 ignore start */ - setHeaders: (res: ServerResponse, path: string): void => { - if (path.endsWith("index.html")) { - res.setHeader("Cache-Control", "no-store"); - } - }, - /* v8 ignore stop */ - }, -}; - function getServerUrl(): URL { return new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080"); } @@ -72,7 +56,7 @@ async function startOnboardingServer(): Promise { const currentSettings = settings.get(); const serverUrl = getServerUrl(); let server: ReturnType | undefined; - const fileServer = expressStaticGzip((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS); + const fileServer = createStaticFileServer((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), console.error); const success = await new Promise((resolve) => { server = createServer(async (req, res) => { @@ -196,9 +180,7 @@ async function startOnboardingServer(): Promise { } } - const next = finalhandler(req, res); - - fileServer(req, res, next); + fileServer(req, res); }); server.on("error", (error: Error) => { @@ -219,7 +201,7 @@ async function startOnboardingServer(): Promise { async function startFailureServer(errors: string[]): Promise { const serverUrl = getServerUrl(); let server: ReturnType | undefined; - const fileServer = expressStaticGzip((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS); + const fileServer = createStaticFileServer((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), console.error); await new Promise((resolve) => { server = createServer((req, res) => { @@ -244,9 +226,7 @@ async function startFailureServer(errors: string[]): Promise { return; } - const next = finalhandler(req, res); - - fileServer(req, res, next); + fileServer(req, res); }); server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => { diff --git a/lib/util/staticFileServer.ts b/lib/util/staticFileServer.ts new file mode 100644 index 000000000..1481b0b63 --- /dev/null +++ b/lib/util/staticFileServer.ts @@ -0,0 +1,65 @@ +import type {IncomingMessage, ServerResponse} from "node:http"; +import {NodeRequest, sendNodeResponse} from "srvx/node"; +import {staticMiddleware} from "srvx/static"; + +export type StaticFileServer = (request: IncomingMessage, response: ServerResponse) => void; + +const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); + +/** Terminal `404` handler for requests no file matched, mirroring the response `finalhandler` used to produce. */ +export function sendNotFound(request: IncomingMessage, response: ServerResponse): void { + const method = request.method /* v8 ignore next */ ?? "GET"; + const url = request.url /* v8 ignore next */ ?? "/"; + const message = escapeHtml(`Cannot ${method} ${encodeURI(url)}`); + const body = `\n\n\n\nError\n\n\n
${message}
\n\n\n`; + + response.setHeader("Content-Security-Policy", "default-src 'none'"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.setHeader("Content-Length", Buffer.byteLength(body)); + response.writeHead(404); + response.end(body); +} + +/** + * Serves `dir` on top of a plain `node:http` server, preferring the precompressed `.br`/`.gz` variant of a file when the client accepts it. + * + * Requests that match no file are answered by {@link sendNotFound}. + */ +export function createStaticFileServer(dir: string, logError: (message: string) => void): StaticFileServer { + // `compress: false` restricts serving to the precompressed variants shipped on disk, never compressing on the fly + const serveDir = staticMiddleware({dir, encodings: true, compress: false}); + const handle = async (request: IncomingMessage, response: ServerResponse): Promise => { + let matched = true; + const staticResponse = await serveDir(new NodeRequest({req: request, res: response}), () => { + matched = false; + + return new Response(null, {status: 404}); + }); + + if (!matched) { + sendNotFound(request, response); + + return; + } + + // the HTML entry document must never be cached, so a newly installed frontend version is picked up right away + if (staticResponse.headers.get("Content-Type")?.startsWith("text/html")) { + staticResponse.headers.set("Cache-Control", "no-store"); + } + + await sendNodeResponse(response, staticResponse); + }; + + return (request, response) => { + handle(request, response).catch((error) => { + logError(`Failed to serve '${request.url}': ${(error as Error).message}`); + + if (!response.headersSent) { + response.writeHead(500); + } + + response.end(); + }); + }; +} diff --git a/package.json b/package.json index b352f30d5..d3ce200aa 100644 --- a/package.json +++ b/package.json @@ -45,14 +45,13 @@ "ajv": "^8.20.0", "bind-decorator": "^1.0.11", "debounce": "^3.0.0", - "express-static-gzip": "^3.0.1", "fast-deep-equal": "^3.1.3", "fflate": "^0.8.3", - "finalhandler": "^2.1.1", "humanize-duration": "^3.34.0", "js-yaml": "^5.2.2", "mqtt": "^5.15.2", "semver": "^7.8.5", + "srvx": "^0.12.5", "throttleit": "^3.0.0", "winston": "^3.19.0", "winston-syslog": "^2.7.1", @@ -65,11 +64,9 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.3", - "@types/finalhandler": "^1.2.3", "@types/humanize-duration": "^3.27.4", "@types/node": "^26.1.2", "@types/readable-stream": "4.0.24", - "@types/serve-static": "^2.2.0", "@types/ws": "8.18.1", "@vitest/coverage-v8": "^3.1.1", "tmp": "^0.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dee8ce8a9..528404eb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,18 +20,12 @@ importers: debounce: specifier: ^3.0.0 version: 3.0.0 - express-static-gzip: - specifier: ^3.0.1 - version: 3.0.1 fast-deep-equal: specifier: ^3.1.3 version: 3.1.3 fflate: specifier: ^0.8.3 version: 0.8.3 - finalhandler: - specifier: ^2.1.1 - version: 2.1.1 humanize-duration: specifier: ^3.34.0 version: 3.34.0 @@ -44,6 +38,9 @@ importers: semver: specifier: ^7.8.5 version: 7.8.5 + srvx: + specifier: ^0.12.5 + version: 0.12.5 throttleit: specifier: ^3.0.0 version: 3.0.0 @@ -75,9 +72,6 @@ importers: '@biomejs/biome': specifier: ^2.5.3 version: 2.5.3 - '@types/finalhandler': - specifier: ^1.2.3 - version: 1.2.4 '@types/humanize-duration': specifier: ^3.27.4 version: 3.27.4 @@ -87,9 +81,6 @@ importers: '@types/readable-stream': specifier: 4.0.24 version: 4.0.24 - '@types/serve-static': - specifier: ^2.2.0 - version: 2.2.0 '@types/ws': specifier: 8.18.1 version: 8.18.1 @@ -519,12 +510,6 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/finalhandler@1.2.4': - resolution: {integrity: sha512-ojpQ5ywnKZko/+tw8lR4xvUN5Uvfnar4ZtfpoLG1TdxlmiIqcQGUbXmc9iV5ud7J6rRtacNbwTkCE98NmsBPYw==} - - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - '@types/humanize-duration@3.27.4': resolution: {integrity: sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==} @@ -534,9 +519,6 @@ packages: '@types/readable-stream@4.0.24': resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==} - '@types/serve-static@2.2.0': - resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -839,10 +821,6 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - dns-packet@5.6.1: resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} engines: {node: '>=6'} @@ -850,9 +828,6 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -862,10 +837,6 @@ packages: enabled@2.0.0: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -874,16 +845,9 @@ packages: engines: {node: '>=18'} hasBin: true - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -896,9 +860,6 @@ packages: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} - express-static-gzip@3.0.1: - resolution: {integrity: sha512-LMeU/3YjFlFUa4vrPX+RoMMRW5mIpF4Iysgs6gX7A59WCY4BzyF3O28mBr4eMlWuW4DU9wVAVuVcfx29ln1N6g==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -926,10 +887,6 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -937,10 +894,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -965,10 +918,6 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - humanize-duration@3.34.0: resolution: {integrity: sha512-NDxhYxiiRzsST6xULIHuYWRvsCtviJXRdjarhyWyh78S4Ou+MWhM2k4HQ+y7iegdrzreXe11isd0au1N2PB/pQ==} @@ -1052,14 +1001,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -1105,20 +1046,12 @@ packages: number-allocator@1.0.14: resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==} - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - one-time@1.0.0: resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1152,10 +1085,6 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1191,17 +1120,6 @@ packages: engines: {node: '>=10'} hasBin: true - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1236,16 +1154,17 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + srvx@0.12.5: + resolution: {integrity: sha512-IuvtDNQg5EIwv3c6dleyau7u8hCyGQ7D6+V/QM799Aud07z0wCUcurKLTRfyG33C8oUY+UWcVBFkfHMcbtmRLA==} + engines: {node: '>=20.16.0'} + hasBin: true + stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -1315,10 +1234,6 @@ packages: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -1771,12 +1686,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/finalhandler@1.2.4': - dependencies: - '@types/node': 26.1.2 - - '@types/http-errors@2.0.5': {} - '@types/humanize-duration@3.27.4': {} '@types/node@26.1.2': @@ -1787,11 +1696,6 @@ snapshots: dependencies: '@types/node': 26.1.2 - '@types/serve-static@2.2.0': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 26.1.2 - '@types/triple-beam@1.3.5': {} '@types/ws@8.18.1': @@ -2053,24 +1957,18 @@ snapshots: deep-eql@5.0.2: {} - depd@2.0.0: {} - dns-packet@5.6.1: dependencies: '@leichtgewicht/ip-codec': 2.0.5 eastasianwidth@0.2.0: {} - ee-first@1.1.1: {} - emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} enabled@2.0.0: {} - encodeurl@2.0.0: {} - es-module-lexer@1.7.0: {} esbuild@0.25.5: @@ -2101,28 +1999,16 @@ snapshots: '@esbuild/win32-ia32': 0.25.5 '@esbuild/win32-x64': 0.25.5 - escape-html@1.0.3: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 - etag@1.8.1: {} - event-target-shim@5.0.1: {} events@3.3.0: {} expect-type@1.2.1: {} - express-static-gzip@3.0.1: - dependencies: - mime-types: 3.0.2 - parseurl: 1.3.3 - serve-static: 2.2.1 - transitivePeerDependencies: - - supports-color - fast-deep-equal@3.1.3: {} fast-unique-numbers@9.0.27: @@ -2143,17 +2029,6 @@ snapshots: file-uri-to-path@1.0.0: optional: true - finalhandler@2.1.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - fn.name@1.1.0: {} foreground-child@3.3.1: @@ -2161,8 +2036,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fresh@2.0.0: {} - fsevents@2.3.3: optional: true @@ -2183,14 +2056,6 @@ snapshots: html-escaper@2.0.2: {} - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - humanize-duration@3.34.0: {} iconv-lite@0.7.3: @@ -2275,12 +2140,6 @@ snapshots: dependencies: semver: 7.8.5 - mime-db@1.54.0: {} - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - minimatch@9.0.5: dependencies: brace-expansion: 2.1.0 @@ -2343,18 +2202,12 @@ snapshots: transitivePeerDependencies: - supports-color - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - one-time@1.0.0: dependencies: fn.name: 1.1.0 package-json-from-dist@1.0.1: {} - parseurl@1.3.3: {} - path-key@3.1.1: {} path-scurry@1.11.1: @@ -2380,8 +2233,6 @@ snapshots: process@0.11.10: {} - range-parser@1.2.1: {} - readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -2434,33 +2285,6 @@ snapshots: semver@7.8.5: {} - send@1.2.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@2.2.1: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2484,12 +2308,12 @@ snapshots: split2@4.2.0: {} + srvx@0.12.5: {} + stack-trace@0.0.10: {} stackback@0.0.2: {} - statuses@2.0.2: {} - std-env@3.9.0: {} string-width@4.2.3: @@ -2553,8 +2377,6 @@ snapshots: tmp@0.2.7: {} - toidentifier@1.0.1: {} - triple-beam@1.4.1: {} tslib@2.8.1: {} diff --git a/test/extensions/frontend.test.ts b/test/extensions/frontend.test.ts index 9a98901f8..fb448d40e 100644 --- a/test/extensions/frontend.test.ts +++ b/test/extensions/frontend.test.ts @@ -13,7 +13,12 @@ import ws from "ws"; import {Controller} from "../../lib/controller"; import * as settings from "../../lib/util/settings"; -let mockHTTPOnRequest: (request: {url: string}, response: number) => void; +const mockRedirectResponse = { + writeHead: vi.fn<(statusCode: number, headers: Record) => void>(), + end: vi.fn<() => void>(), +}; + +let mockHTTPOnRequest: (request: {url: string}, response: number | typeof mockRedirectResponse) => void; const mockHTTPEvents: Record = {}; const mockHTTP = { listen: vi.fn(), @@ -64,7 +69,7 @@ const frontendPath = "frontend-path"; const deviceIconsPath = path.join(data.mockDir, "device_icons"); let mockNodeStatic: {[s: string]: Mock} = {}; -const mockFinalHandler = vi.fn(); +const mockSendNotFound = vi.fn(); vi.mock("node:http", () => ({ createServer: vi.fn().mockImplementation((onRequest) => { @@ -79,11 +84,12 @@ vi.mock("node:https", () => ({ Agent: vi.fn(), })); -vi.mock("express-static-gzip", () => ({ - default: vi.fn().mockImplementation((path: string) => { +vi.mock("../../lib/util/staticFileServer", () => ({ + createStaticFileServer: vi.fn().mockImplementation((path: string) => { mockNodeStatic[path] = vi.fn(); return mockNodeStatic[path]; }), + sendNotFound: vi.fn().mockImplementation((...args: unknown[]) => mockSendNotFound(...args)), })); vi.mock("zigbee2mqtt-windfront", () => ({ @@ -101,12 +107,6 @@ vi.mock("ws", () => ({ }, })); -vi.mock("finalhandler", () => ({ - default: vi.fn().mockImplementation(() => { - return mockFinalHandler; - }), -})); - const mocksClear = [ mockHTTP.close, mockHTTP.listen, @@ -118,7 +118,9 @@ const mocksClear = [ mockWS.emit, mockWSClient.send, mockWSClient.terminate, - mockFinalHandler, + mockSendNotFound, + mockRedirectResponse.writeHead, + mockRedirectResponse.end, mockMQTTPublishAsync, mockLogger.error, ]; @@ -340,11 +342,7 @@ describe("Extension: Frontend", () => { mockHTTPOnRequest({url: "/file.txt"}, 2); expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(0); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); - expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( - {originalUrl: "/file.txt", path: "/file.txt", url: "/file.txt"}, - 2, - expect.any(Function), - ); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2); }); it("Should serve device icons", async () => { @@ -354,11 +352,7 @@ describe("Extension: Frontend", () => { mockHTTPOnRequest({url: "/device_icons/my_device.png"}, 2); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(0); expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1); - expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith( - {originalUrl: "/device_icons/my_device.png", path: "/my_device.png", url: "/my_device.png"}, - 2, - expect.any(Function), - ); + expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith({url: "/my_device.png"}, 2); }); it("Static server", async () => { @@ -402,34 +396,33 @@ describe("Extension: Frontend", () => { expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m/api"}); - mockHTTPOnRequest({url: "/z2m"}, 2); + // the base url without trailing slash points at a directory, redirect so relative asset paths resolve against it + mockHTTPOnRequest({url: "/z2m"}, mockRedirectResponse); + expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled(); + expect(mockRedirectResponse.writeHead).toHaveBeenCalledWith(301, {Location: "/z2m/"}); + expect(mockRedirectResponse.end).toHaveBeenCalledTimes(1); + expect(mockSendNotFound).not.toHaveBeenCalled(); + + mockHTTPOnRequest({url: "/z2m/"}, 2); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); - expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({originalUrl: "/z2m", path: "/", url: "/"}, 2, expect.any(Function)); - expect(mockFinalHandler).not.toHaveBeenCalledWith(); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/"}, 2); + expect(mockSendNotFound).not.toHaveBeenCalledWith(); mockNodeStatic[frontendPath].mockReset(); - expect(mockFinalHandler).not.toHaveBeenCalledWith(); + expect(mockSendNotFound).not.toHaveBeenCalledWith(); mockHTTPOnRequest({url: "/z2m/file.txt"}, 2); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); - expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( - {originalUrl: "/z2m/file.txt", path: "/file.txt", url: "/file.txt"}, - 2, - expect.any(Function), - ); - expect(mockFinalHandler).not.toHaveBeenCalledWith(); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2); + expect(mockSendNotFound).not.toHaveBeenCalledWith(); mockNodeStatic[frontendPath].mockReset(); mockHTTPOnRequest({url: "/z/file.txt"}, 2); expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled(); - expect(mockFinalHandler).toHaveBeenCalled(); + expect(mockSendNotFound).toHaveBeenCalled(); mockHTTPOnRequest({url: "/z2m/device_icons/my-device.png"}, 2); expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1); - expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith( - {originalUrl: "/z2m/device_icons/my-device.png", path: "/my-device.png", url: "/my-device.png"}, - 2, - expect.any(Function), - ); + expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith({url: "/my-device.png"}, 2); }); it("Works with non-default complex base url", async () => { @@ -440,30 +433,28 @@ describe("Extension: Frontend", () => { expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m-more++/c0mplex.url/api"}); - mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url"}, 2); + mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url"}, mockRedirectResponse); + expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled(); + expect(mockRedirectResponse.writeHead).toHaveBeenCalledWith(301, {Location: "/z2m-more++/c0mplex.url/"}); + expect(mockRedirectResponse.end).toHaveBeenCalledTimes(1); + expect(mockSendNotFound).not.toHaveBeenCalled(); + + mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url/"}, 2); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); - expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( - {originalUrl: "/z2m-more++/c0mplex.url", path: "/", url: "/"}, - 2, - expect.any(Function), - ); - expect(mockFinalHandler).not.toHaveBeenCalledWith(); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/"}, 2); + expect(mockSendNotFound).not.toHaveBeenCalledWith(); mockNodeStatic[frontendPath].mockReset(); - expect(mockFinalHandler).not.toHaveBeenCalledWith(); + expect(mockSendNotFound).not.toHaveBeenCalledWith(); mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url/file.txt"}, 2); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); - expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( - {originalUrl: "/z2m-more++/c0mplex.url/file.txt", path: "/file.txt", url: "/file.txt"}, - 2, - expect.any(Function), - ); - expect(mockFinalHandler).not.toHaveBeenCalledWith(); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2); + expect(mockSendNotFound).not.toHaveBeenCalledWith(); mockNodeStatic[frontendPath].mockReset(); mockHTTPOnRequest({url: "/z/file.txt"}, 2); expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled(); - expect(mockFinalHandler).toHaveBeenCalled(); + expect(mockSendNotFound).toHaveBeenCalled(); }); it("prevents mismatching setting/extension state", async () => { diff --git a/test/onboarding.test.ts b/test/onboarding.test.ts index 49261c380..45dbd9e15 100644 --- a/test/onboarding.test.ts +++ b/test/onboarding.test.ts @@ -31,16 +31,10 @@ const mockHttpClose = vi.fn( }, ); const mockFindAllDevices = vi.fn(async () => []); -const mockStaticFileServer = vi.fn((_req, res, next) => { - if (typeof next === "function") { - next(); - } - +const mockStaticFileServer = vi.fn((_req, res) => { res.end(); }); -const mockExpressStaticGzip = vi.fn((_path: unknown, _options: unknown) => mockStaticFileServer); -const mockFinalHandlerNext = vi.fn(); -const mockFinalhandler = vi.fn((_req: unknown, _res: unknown) => mockFinalHandlerNext); +const mockCreateStaticFileServer = vi.fn((_dir: unknown, _logError: unknown) => mockStaticFileServer); vi.mock("node:fs", {spy: true}); vi.mock("node:http", () => ({ @@ -62,11 +56,8 @@ vi.mock("node:http", () => ({ }; }), })); -vi.mock("express-static-gzip", () => ({ - default: vi.fn((path, options) => mockExpressStaticGzip(path, options)), -})); -vi.mock("finalhandler", () => ({ - default: vi.fn((req, res) => mockFinalhandler(req, res)), +vi.mock("../lib/util/staticFileServer", () => ({ + createStaticFileServer: vi.fn((dir, logError) => mockCreateStaticFileServer(dir, logError)), })); vi.mock("zigbee-herdsman/dist/adapter/adapterDiscovery", () => ({ findAllDevices: vi.fn(() => mockFindAllDevices()), @@ -194,10 +185,7 @@ describe("Onboarding", () => { mockFindAllDevices.mockClear(); mockHttpErrorListener = undefined; mockStaticFileServer.mockClear(); - mockExpressStaticGzip.mockClear(); - mockFinalHandlerNext.mockClear(); - mockFinalhandler.mockClear(); - mockStaticFileServer.mockClear(); + mockCreateStaticFileServer.mockClear(); settings.reRead(); }); @@ -735,7 +723,6 @@ describe("Onboarding", () => { }); await expect(p).resolves.toStrictEqual(true); - expect(mockFinalhandler).toHaveBeenCalled(); expect(mockStaticFileServer).toHaveBeenCalled(); }); @@ -757,7 +744,6 @@ describe("Onboarding", () => { }); await expect(p).resolves.toStrictEqual(false); - expect(mockFinalhandler).toHaveBeenCalled(); expect(mockStaticFileServer).toHaveBeenCalled(); }); diff --git a/test/staticFileServer.test.ts b/test/staticFileServer.test.ts new file mode 100644 index 000000000..1a22a1361 --- /dev/null +++ b/test/staticFileServer.test.ts @@ -0,0 +1,185 @@ +import {mkdirSync, writeFileSync} from "node:fs"; +import {createServer, type Server} from "node:http"; +import {type AddressInfo, connect} from "node:net"; +import {join} from "node:path"; +import {brotliCompressSync, gzipSync} from "node:zlib"; +import tmp from "tmp"; +import {afterAll, beforeAll, describe, expect, it, vi} from "vitest"; +import {createStaticFileServer, type sendNotFound} from "../lib/util/staticFileServer"; + +const INDEX_HTML = "index"; +const APP_JS = `console.log("${"x".repeat(2048)}");`; +/** Written next to the served directory, never inside it, so a traversal that succeeds is actually observable. */ +const SECRET = "topsecret-must-never-be-served"; + +const mockLogError = vi.fn<(message: string) => void>(); + +let dir: string; +let server: Server; +let baseUrl: string; + +/** Starts a `node:http` server serving `dir`, mirroring how the frontend/onboarding extensions wire it up. */ +function listen(handler: (request: Parameters[0], response: Parameters[1]) => void): Promise { + server = createServer(handler); + + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + resolve(); + }); + }); +} + +/** Writes a request line verbatim, bypassing the path normalization `fetch` applies before sending. */ +function rawRequest(target: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect((server.address() as AddressInfo).port, "127.0.0.1", () => { + socket.write(`GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`); + }); + let received = ""; + + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + received += chunk; + }); + socket.on("end", () => resolve(received)); + socket.on("error", reject); + }); +} + +describe("StaticFileServer", () => { + beforeAll(async () => { + const root = tmp.dirSync().name; + + dir = join(root, "public"); + + // outside the served directory: only a working traversal could reach it + writeFileSync(join(root, "secret.txt"), SECRET); + mkdirSync(join(dir, "sub"), {recursive: true}); + writeFileSync(join(dir, "index.html"), INDEX_HTML); + writeFileSync(join(dir, "app.js"), APP_JS); + // precompressed variants, as shipped by the frontend packages + writeFileSync(join(dir, "app.js.gz"), gzipSync(APP_JS)); + writeFileSync(join(dir, "app.js.br"), brotliCompressSync(APP_JS)); + writeFileSync(join(dir, "sub", "icon.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + await listen(createStaticFileServer(dir, mockLogError)); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it("serves a file with its content type", async () => { + const response = await fetch(`${baseUrl}/sub/icon.png`); + + expect(response.status).toStrictEqual(200); + expect(response.headers.get("content-type")).toStrictEqual("image/png"); + expect(response.headers.get("content-encoding")).toBeNull(); + }); + + it("serves index.html for the root, never cached", async () => { + const response = await fetch(`${baseUrl}/`); + + expect(response.status).toStrictEqual(200); + expect(response.headers.get("content-type")).toStrictEqual("text/html; charset=utf-8"); + expect(response.headers.get("cache-control")).toStrictEqual("no-store"); + await expect(response.text()).resolves.toStrictEqual(INDEX_HTML); + }); + + it("serves the precompressed brotli variant", async () => { + const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "br"}}); + + expect(response.status).toStrictEqual(200); + expect(response.headers.get("content-encoding")).toStrictEqual("br"); + expect(response.headers.get("content-type")).toStrictEqual("text/javascript; charset=utf-8"); + expect(response.headers.get("vary")).toStrictEqual("Accept-Encoding"); + // decoded by fetch, so the served bytes must be the brotli variant of the original file + await expect(response.text()).resolves.toStrictEqual(APP_JS); + }); + + it("serves the precompressed gzip variant", async () => { + const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "gzip"}}); + + expect(response.status).toStrictEqual(200); + expect(response.headers.get("content-encoding")).toStrictEqual("gzip"); + await expect(response.text()).resolves.toStrictEqual(APP_JS); + }); + + it("serves the identity file when no encoding is accepted", async () => { + const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity"}}); + + expect(response.status).toStrictEqual(200); + expect(response.headers.get("content-encoding")).toBeNull(); + expect(response.headers.get("content-length")).toStrictEqual(String(Buffer.byteLength(APP_JS))); + await expect(response.text()).resolves.toStrictEqual(APP_JS); + }); + + it("revalidates with an etag", async () => { + const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity"}}); + const etag = response.headers.get("etag"); + + expect(etag).toBeTruthy(); + + const revalidated = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity", "If-None-Match": etag as string}}); + + expect(revalidated.status).toStrictEqual(304); + }); + + it("returns 404 for an unknown file", async () => { + const response = await fetch(`${baseUrl}/nope.js`); + + expect(response.status).toStrictEqual(404); + expect(response.headers.get("content-type")).toStrictEqual("text/html; charset=utf-8"); + expect(response.headers.get("content-security-policy")).toStrictEqual("default-src 'none'"); + expect(response.headers.get("x-content-type-options")).toStrictEqual("nosniff"); + await expect(response.text()).resolves.toContain("Cannot GET /nope.js"); + }); + + it("escapes the url in the 404 body", async () => { + const response = await fetch(`${baseUrl}/%3Cscript%3E`); + + expect(response.status).toStrictEqual(404); + await expect(response.text()).resolves.not.toContain("