diff --git a/package-lock.json b/package-lock.json
index ee766d1..cf02bea 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"date-fns": "^4.4.0",
"openapi-fetch": "^0.17.0",
+ "sharp": "0.35.4",
"sweetalert2": "^11.26.25"
},
"devDependencies": {
@@ -112,6 +113,16 @@
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -814,6 +825,554 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
+ "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
+ "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
+ "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.4"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
+ "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
+ "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
+ "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
+ "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
+ "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
+ "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
+ "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
+ "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
+ "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
+ "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
+ "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
+ "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
+ "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
+ "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
+ "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
+ "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
+ "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
+ "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
+ "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
+ "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.4"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
+ "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
+ "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
+ "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2350,7 +2909,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -3887,7 +4445,6 @@
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -3896,6 +4453,55 @@
"node": ">=10"
}
},
+ "node_modules/sharp": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
+ "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.4",
+ "@img/sharp-darwin-x64": "0.35.4",
+ "@img/sharp-freebsd-wasm32": "0.35.4",
+ "@img/sharp-libvips-darwin-arm64": "1.3.3",
+ "@img/sharp-libvips-darwin-x64": "1.3.3",
+ "@img/sharp-libvips-linux-arm": "1.3.3",
+ "@img/sharp-libvips-linux-arm64": "1.3.3",
+ "@img/sharp-libvips-linux-ppc64": "1.3.3",
+ "@img/sharp-libvips-linux-riscv64": "1.3.3",
+ "@img/sharp-libvips-linux-s390x": "1.3.3",
+ "@img/sharp-libvips-linux-x64": "1.3.3",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.3",
+ "@img/sharp-linux-arm": "0.35.4",
+ "@img/sharp-linux-arm64": "0.35.4",
+ "@img/sharp-linux-ppc64": "0.35.4",
+ "@img/sharp-linux-riscv64": "0.35.4",
+ "@img/sharp-linux-s390x": "0.35.4",
+ "@img/sharp-linux-x64": "0.35.4",
+ "@img/sharp-linuxmusl-arm64": "0.35.4",
+ "@img/sharp-linuxmusl-x64": "0.35.4",
+ "@img/sharp-webcontainers-wasm32": "0.35.4",
+ "@img/sharp-win32-arm64": "0.35.4",
+ "@img/sharp-win32-ia32": "0.35.4",
+ "@img/sharp-win32-x64": "0.35.4"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4239,6 +4845,13 @@
"typescript": ">=4.8.4"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/tsx": {
"version": "4.23.15",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
diff --git a/package.json b/package.json
index f0d8575..3b06b6d 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"date-fns": "^4.4.0",
"openapi-fetch": "^0.17.0",
+ "sharp": "0.35.4",
"sweetalert2": "^11.26.25"
},
"devDependencies": {
diff --git a/src/lib/components/CmsImage.svelte b/src/lib/components/CmsImage.svelte
new file mode 100644
index 0000000..a3c35ed
--- /dev/null
+++ b/src/lib/components/CmsImage.svelte
@@ -0,0 +1,57 @@
+
+
+
diff --git a/src/lib/images/cmsImage.ts b/src/lib/images/cmsImage.ts
new file mode 100644
index 0000000..23267c5
--- /dev/null
+++ b/src/lib/images/cmsImage.ts
@@ -0,0 +1,90 @@
+const DEVICE_SIZES = [640, 750, 828, 1080, 1200, 1920, 2048, 3840] as const;
+const IMAGE_SIZES = [32, 48, 64, 96, 128, 256, 384] as const;
+
+export const NEXT_IMAGE_SIZES = [...IMAGE_SIZES, ...DEVICE_SIZES].sort(
+ (a, b) => a - b,
+);
+
+export interface CmsImageAttributesOptions {
+ src: string;
+ width: number;
+ height: number;
+ alt: string;
+ quality?: 75 | 100;
+ loading?: 'eager' | 'lazy';
+ decoding?: 'async' | 'auto' | 'sync';
+ fetchpriority?: 'high' | 'low' | 'auto';
+ class?: string;
+ style?: string;
+}
+
+export interface CmsImageAttributes {
+ src: string;
+ srcset: string;
+ width: number;
+ height: number;
+ alt: string;
+ loading?: 'eager' | 'lazy';
+ decoding?: 'async' | 'auto' | 'sync';
+ fetchpriority?: 'high' | 'low' | 'auto';
+ class?: string;
+ style?: string;
+}
+
+export function nextFixedWidths(width: number): number[] {
+ return [
+ ...new Set(
+ [width, width * 2].map(
+ (target) =>
+ NEXT_IMAGE_SIZES.find((candidate) => candidate >= target) ??
+ NEXT_IMAGE_SIZES[NEXT_IMAGE_SIZES.length - 1],
+ ),
+ ),
+ ];
+}
+
+export function buildOptimizerUrl(
+ src: string,
+ width: number,
+ quality = 75,
+): string {
+ const params = new URLSearchParams({
+ url: src,
+ w: width.toString(),
+ q: quality.toString(),
+ });
+
+ return `/_image?${params}`;
+}
+
+export function buildCmsImageAttributes({
+ src,
+ width,
+ height,
+ alt,
+ quality = 75,
+ loading,
+ decoding,
+ fetchpriority,
+ class: className,
+ style,
+}: CmsImageAttributesOptions): CmsImageAttributes {
+ const widths = nextFixedWidths(width);
+ const descriptors = widths.map(
+ (candidate, index) =>
+ `${buildOptimizerUrl(src, candidate, quality)} ${index + 1}x`,
+ );
+
+ return {
+ src: buildOptimizerUrl(src, widths[widths.length - 1], quality),
+ srcset: descriptors.join(', '),
+ width,
+ height,
+ alt,
+ loading,
+ decoding,
+ fetchpriority,
+ class: className,
+ style,
+ };
+}
diff --git a/src/lib/server/imageOptimizer.ts b/src/lib/server/imageOptimizer.ts
new file mode 100644
index 0000000..2ce3fcf
--- /dev/null
+++ b/src/lib/server/imageOptimizer.ts
@@ -0,0 +1,430 @@
+import { createHash } from 'node:crypto';
+import sharp from 'sharp';
+import { getUmbracoBaseUrl } from '#lib/server/config.ts';
+
+const ALLOWED_WIDTHS = new Set([
+ 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840,
+]);
+const ALLOWED_QUALITIES = new Set([75, 100]);
+const ALLOWED_CMS_QUERY_KEYS = new Set([
+ 'width',
+ 'height',
+ 'maxwidth',
+ 'maxheight',
+ 'maxsize',
+ 'crop',
+ 'mode',
+ 'anchor',
+ 'center',
+ 'rxy',
+ 'rnd',
+]);
+const REJECTED_EXTENSIONS = new Set(['.svg', '.pdf', '.gif']);
+const SUPPORTED_UPSTREAM_TYPES = new Set([
+ 'image/jpeg',
+ 'image/png',
+ 'image/webp',
+]);
+
+export const IMAGE_UPSTREAM_TIMEOUT_MS = 7_000;
+export const IMAGE_SHARP_TIMEOUT_SECONDS = 7;
+export const IMAGE_MAX_UPSTREAM_BYTES = 15 * 1024 * 1024;
+export const IMAGE_MAX_INPUT_PIXELS = 40_000_000;
+
+export class ImageOptimizerError extends Error {
+ constructor(
+ public readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+export interface ImageOptimizerRequest {
+ source: string;
+ upstreamUrl: URL;
+ width: number;
+ quality: 75 | 100;
+}
+
+export interface OptimizedImage {
+ bytes: Uint8Array;
+ contentType: string;
+ etag: string;
+ cacheControl: string;
+}
+
+function badRequest(message: string): never {
+ throw new ImageOptimizerError(400, message);
+}
+
+function upstreamFailure(message: string): never {
+ throw new ImageOptimizerError(502, message);
+}
+
+function getSingleParam(url: URL, name: string): string {
+ const values = url.searchParams.getAll(name);
+
+ if (values.length !== 1 || values[0] === '') {
+ badRequest(`Expected one ${name} parameter`);
+ }
+
+ return values[0];
+}
+
+function decodeRawUrlParam(url: URL): string {
+ const rawSearch = url.search.startsWith('?')
+ ? url.search.slice(1)
+ : url.search;
+ const values = rawSearch
+ .split('&')
+ .filter(Boolean)
+ .map((part) => {
+ const [key, value = ''] = part.split('=', 2);
+ return { key, value };
+ })
+ .filter(({ key }) => key === 'url');
+
+ if (values.length !== 1 || values[0].value === '') {
+ badRequest('Expected one url parameter');
+ }
+
+ try {
+ return decodeURIComponent(values[0].value.replace(/\+/g, ' '));
+ } catch {
+ badRequest('Invalid url encoding');
+ }
+}
+
+function parseIntegerParam(url: URL, name: string): number {
+ const value = getSingleParam(url, name);
+
+ if (!/^\d+$/.test(value)) {
+ badRequest(`Invalid ${name} parameter`);
+ }
+
+ return Number(value);
+}
+
+function validateCmsQuery(searchParams: URLSearchParams): string {
+ for (const [key, value] of searchParams) {
+ const normalizedKey = key.toLowerCase();
+
+ if (!ALLOWED_CMS_QUERY_KEYS.has(normalizedKey)) {
+ badRequest(`Unsupported CMS image parameter: ${key}`);
+ }
+
+ if (
+ ['width', 'height', 'maxwidth', 'maxheight', 'maxsize'].includes(
+ normalizedKey,
+ ) &&
+ (!/^\d+$/.test(value) ||
+ Number(value) < 1 ||
+ Number(value) > 10_000)
+ ) {
+ badRequest(`Invalid CMS image dimension: ${key}`);
+ }
+ }
+
+ return searchParams.toString();
+}
+
+function getSafeMediaPath(sourcePathname: string): string {
+ if (!sourcePathname.startsWith('/cms-media/media/')) {
+ badRequest('Images must use /cms-media/media/ paths');
+ }
+
+ const extension = sourcePathname
+ .slice(sourcePathname.lastIndexOf('.'))
+ .toLowerCase();
+
+ if (REJECTED_EXTENSIONS.has(extension)) {
+ badRequest('This media type is not optimized by /_image');
+ }
+
+ const mediaPath = sourcePathname.replace(/^\/cms-media\/?/, '');
+ const safeSegments = mediaPath
+ .split('/')
+ .filter(Boolean)
+ .map((segment) => {
+ let decoded: string;
+
+ try {
+ decoded = decodeURIComponent(segment);
+ } catch {
+ badRequest('Invalid media path encoding');
+ }
+
+ if (
+ decoded === '.' ||
+ decoded === '..' ||
+ decoded.includes('/') ||
+ decoded.includes('\\') ||
+ decoded.includes('\0')
+ ) {
+ badRequest('Invalid media path');
+ }
+
+ return encodeURIComponent(decoded);
+ });
+
+ if (safeSegments.length < 2 || safeSegments[0] !== 'media') {
+ badRequest('Images must use /cms-media/media/ paths');
+ }
+
+ return safeSegments.join('/');
+}
+
+export function parseImageOptimizerRequest(url: URL): ImageOptimizerRequest {
+ const source = decodeRawUrlParam(url);
+
+ if (/^[a-z][a-z\d+.-]*:/i.test(source) || source.startsWith('//')) {
+ badRequest('Absolute image URLs are not allowed');
+ }
+
+ if (!source.startsWith('/')) {
+ badRequest('Image URL must be root-relative');
+ }
+
+ const sourceUrl = new URL(source, 'http://methodconf.local');
+
+ if (sourceUrl.origin !== 'http://methodconf.local') {
+ badRequest('Image URL must be local');
+ }
+
+ if (sourceUrl.hash) {
+ badRequest('Image URL fragments are not supported');
+ }
+
+ const width = parseIntegerParam(url, 'w');
+ const quality = parseIntegerParam(url, 'q');
+
+ if (!ALLOWED_WIDTHS.has(width)) {
+ badRequest('Unsupported image width');
+ }
+
+ if (!ALLOWED_QUALITIES.has(quality)) {
+ badRequest('Unsupported image quality');
+ }
+
+ const upstreamUrl = new URL(
+ getSafeMediaPath(sourceUrl.pathname),
+ `${getUmbracoBaseUrl().toString().replace(/\/$/, '')}/`,
+ );
+ const cmsSearch = validateCmsQuery(sourceUrl.searchParams);
+ upstreamUrl.search = cmsSearch;
+
+ return {
+ source,
+ upstreamUrl,
+ width,
+ quality: quality as 75 | 100,
+ };
+}
+
+function acceptsWebp(accept: string | null): boolean {
+ if (!accept?.includes('image/webp')) {
+ return false;
+ }
+
+ return accept.split(',').some((entry) => {
+ const [mediaType, ...parameters] = entry
+ .split(';')
+ .map((part) => part.trim());
+
+ if (mediaType.toLowerCase() !== 'image/webp') {
+ return false;
+ }
+
+ const quality = parameters.find((parameter) =>
+ /^q\s*=/i.test(parameter),
+ );
+
+ if (!quality) {
+ return true;
+ }
+
+ const value = Number.parseFloat(quality.split('=')[1] ?? '');
+
+ return value !== 0;
+ });
+}
+
+function chooseOutputType(
+ upstreamContentType: string,
+ accept: string | null,
+): 'webp' | 'jpeg' | 'png' {
+ if (acceptsWebp(accept)) {
+ return 'webp';
+ }
+
+ if (upstreamContentType === 'image/jpeg') {
+ return 'jpeg';
+ }
+
+ if (upstreamContentType === 'image/png') {
+ return 'png';
+ }
+
+ if (upstreamContentType === 'image/webp') {
+ return 'webp';
+ }
+
+ upstreamFailure('Unsupported upstream image type');
+}
+
+function contentTypeForOutput(outputType: 'webp' | 'jpeg' | 'png'): string {
+ if (outputType === 'jpeg') return 'image/jpeg';
+ if (outputType === 'png') return 'image/png';
+ return 'image/webp';
+}
+
+function responseCacheControl(upstreamHeaders: Headers): string {
+ return (
+ upstreamHeaders.get('cache-control') ??
+ 'public, max-age=604800, must-revalidate'
+ );
+}
+
+async function readLimitedBody(response: Response): Promise {
+ const contentLength = response.headers.get('content-length');
+
+ if (contentLength && Number(contentLength) > IMAGE_MAX_UPSTREAM_BYTES) {
+ upstreamFailure('Upstream image is too large');
+ }
+
+ if (!response.body) {
+ upstreamFailure('Upstream image response has no body');
+ }
+
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) break;
+
+ total += value.byteLength;
+
+ if (total > IMAGE_MAX_UPSTREAM_BYTES) {
+ await reader.cancel();
+ upstreamFailure('Upstream image is too large');
+ }
+
+ chunks.push(value);
+ }
+
+ const bytes = new Uint8Array(total);
+ let offset = 0;
+
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+
+ return bytes;
+}
+
+async function fetchUpstreamImage(upstreamUrl: URL): Promise {
+ try {
+ const response = await fetch(upstreamUrl, {
+ redirect: 'manual',
+ signal: AbortSignal.timeout(IMAGE_UPSTREAM_TIMEOUT_MS),
+ });
+
+ if (response.status === 404) {
+ throw new ImageOptimizerError(404, 'Image not found');
+ }
+
+ if (response.status >= 300 && response.status < 400) {
+ upstreamFailure('Upstream image redirects are not allowed');
+ }
+
+ if (!response.ok) {
+ upstreamFailure('Upstream image request failed');
+ }
+
+ return response;
+ } catch (error) {
+ if (error instanceof ImageOptimizerError) throw error;
+ upstreamFailure('Upstream image request failed');
+ }
+}
+
+export async function optimizeImageBuffer({
+ input,
+ width,
+ quality,
+ upstreamContentType,
+ accept,
+}: {
+ input: Uint8Array;
+ width: number;
+ quality: 75 | 100;
+ upstreamContentType: string;
+ accept: string | null;
+}): Promise<{ bytes: Uint8Array; contentType: string }> {
+ const outputType = chooseOutputType(upstreamContentType, accept);
+ const pipeline = sharp(input, {
+ limitInputPixels: IMAGE_MAX_INPUT_PIXELS,
+ sequentialRead: true,
+ }).timeout({ seconds: IMAGE_SHARP_TIMEOUT_SECONDS });
+ const metadata = await pipeline.metadata();
+
+ if ((metadata.pages ?? 1) > 1) {
+ badRequest('Animated images are not optimized by /_image');
+ }
+
+ let transformed = pipeline.rotate().resize(width, undefined, {
+ withoutEnlargement: true,
+ });
+
+ if (outputType === 'webp') {
+ transformed = transformed.webp({ quality });
+ } else if (outputType === 'jpeg') {
+ transformed = transformed.jpeg({ quality, mozjpeg: true });
+ } else {
+ transformed = transformed.png({ quality });
+ }
+
+ return {
+ bytes: await transformed.toBuffer(),
+ contentType: contentTypeForOutput(outputType),
+ };
+}
+
+export async function optimizeImageRequest(
+ optimizerRequest: ImageOptimizerRequest,
+ accept: string | null,
+): Promise {
+ const upstream = await fetchUpstreamImage(optimizerRequest.upstreamUrl);
+ const upstreamContentType =
+ upstream.headers
+ .get('content-type')
+ ?.split(';')[0]
+ ?.trim()
+ .toLowerCase() ?? '';
+
+ if (!SUPPORTED_UPSTREAM_TYPES.has(upstreamContentType)) {
+ upstreamFailure('Unsupported upstream image type');
+ }
+
+ const input = await readLimitedBody(upstream);
+ const { bytes, contentType } = await optimizeImageBuffer({
+ input,
+ width: optimizerRequest.width,
+ quality: optimizerRequest.quality,
+ upstreamContentType,
+ accept,
+ });
+ const digest = createHash('sha256').update(bytes).digest('base64url');
+
+ return {
+ bytes,
+ contentType,
+ etag: `"${digest}"`,
+ cacheControl: responseCacheControl(upstream.headers),
+ };
+}
diff --git a/src/routes/_image/+server.ts b/src/routes/_image/+server.ts
new file mode 100644
index 0000000..4f4b593
--- /dev/null
+++ b/src/routes/_image/+server.ts
@@ -0,0 +1,66 @@
+import {
+ ImageOptimizerError,
+ optimizeImageRequest,
+ parseImageOptimizerRequest,
+} from '#lib/server/imageOptimizer.ts';
+import { redirectFromTrailingSlash } from '#lib/server/slashRedirect.ts';
+import type { RequestHandler } from './$types';
+
+export const trailingSlash = 'ignore';
+
+function errorResponse(status: number, message: string): Response {
+ return new Response(message, {
+ status,
+ headers: {
+ 'content-type': 'text/plain; charset=utf-8',
+ 'cache-control': 'no-store',
+ },
+ });
+}
+
+async function handleImage({ request, url }: Parameters[0]) {
+ const canonicalRedirect = redirectFromTrailingSlash(url);
+
+ if (canonicalRedirect) {
+ return canonicalRedirect;
+ }
+
+ try {
+ const optimizerRequest = parseImageOptimizerRequest(url);
+ const optimized = await optimizeImageRequest(
+ optimizerRequest,
+ request.headers.get('accept'),
+ );
+ const headers = new Headers({
+ 'cache-control': optimized.cacheControl,
+ 'content-length': optimized.bytes.byteLength.toString(),
+ 'content-type': optimized.contentType,
+ etag: optimized.etag,
+ vary: 'Accept',
+ });
+
+ if (request.headers.get('if-none-match') === optimized.etag) {
+ headers.delete('content-length');
+ return new Response(null, { status: 304, headers });
+ }
+
+ if (request.method === 'HEAD') {
+ return new Response(null, { headers });
+ }
+
+ const body = new ArrayBuffer(optimized.bytes.byteLength);
+ new Uint8Array(body).set(optimized.bytes);
+
+ return new Response(body, { headers });
+ } catch (error) {
+ if (error instanceof ImageOptimizerError) {
+ return errorResponse(error.status, error.message);
+ }
+
+ console.error(error);
+ return errorResponse(502, 'Image optimization failed');
+ }
+}
+
+export const GET: RequestHandler = handleImage;
+export const HEAD: RequestHandler = handleImage;
diff --git a/tests/route-smoke.mjs b/tests/route-smoke.mjs
index 55f3174..458558d 100644
--- a/tests/route-smoke.mjs
+++ b/tests/route-smoke.mjs
@@ -3,6 +3,7 @@
import { spawn } from 'node:child_process';
import { createServer, request as httpRequest } from 'node:http';
import assert from 'node:assert/strict';
+import sharp from 'sharp';
const TEXT_ENCODER = new TextEncoder();
const GOOGLE_PLAY_STORE_LINK =
@@ -20,6 +21,19 @@ const conference = (slug, date, callForSpeakersUrl) => ({
},
});
+const imageFixture = await sharp({
+ create: {
+ width: 20,
+ height: 10,
+ channels: 3,
+ background: { r: 24, g: 88, b: 160 },
+ },
+})
+ .jpeg({ quality: 92 })
+ .toBuffer();
+
+const largeImagePlaceholderBytes = 16 * 1024 * 1024;
+
const state = {
conferences: [
conference('2023', '2023-09-01T09:00:00Z'),
@@ -96,6 +110,65 @@ async function startUpstream() {
return;
}
+ if (url.pathname === '/media/image.jpg') {
+ state.mediaRequests.push({
+ method: request.method,
+ path: url.pathname,
+ search: url.search,
+ accept: request.headers.accept,
+ range: request.headers.range,
+ ifNoneMatch: request.headers['if-none-match'],
+ ifModifiedSince: request.headers['if-modified-since'],
+ });
+ response.writeHead(200, 'OK', {
+ 'content-type': 'image/jpeg',
+ 'content-length': String(imageFixture.byteLength),
+ etag: '"image-etag"',
+ 'cache-control': 'public, max-age=604800, must-revalidate',
+ });
+ if (request.method !== 'HEAD') response.end(imageFixture);
+ else response.end();
+ return;
+ }
+
+ if (url.pathname === '/media/private-image.jpg') {
+ response.writeHead(200, 'OK', {
+ 'content-type': 'image/jpeg',
+ 'content-length': String(imageFixture.byteLength),
+ 'cache-control': 'private, no-store',
+ });
+ response.end(imageFixture);
+ return;
+ }
+
+ if (url.pathname === '/media/broken.jpg') {
+ response.writeHead(200, 'OK', {
+ 'content-type': 'image/jpeg',
+ 'content-length': '12',
+ 'cache-control': 'public, max-age=604800, must-revalidate',
+ });
+ response.end('not an image');
+ return;
+ }
+
+ if (url.pathname === '/media/too-large.jpg') {
+ response.writeHead(200, 'OK', {
+ 'content-type': 'image/jpeg',
+ 'content-length': String(largeImagePlaceholderBytes),
+ 'cache-control': 'public, max-age=604800, must-revalidate',
+ });
+ response.end();
+ return;
+ }
+
+ if (url.pathname === '/media/redirect.jpg') {
+ response.writeHead(302, 'Found', {
+ location: 'https://evil.example/image.jpg',
+ });
+ response.end();
+ return;
+ }
+
if (url.pathname.startsWith('/media/')) {
state.mediaRequests.push({
method: request.method,
@@ -402,6 +475,156 @@ async function runRobotChecks(upstreamUrl) {
assert.equal(state.cmsRequests, before, 'robots should not hit CMS');
}
+async function runImageOptimizerChecks(origin) {
+ state.mediaRequests = [];
+
+ const imagePath = `/_image?url=${encodeURIComponent('/cms-media/media/image.jpg?width=20&height=10')}&w=32&q=75`;
+ await expectRedirect(
+ origin,
+ imagePath.replace('/_image?', '/_image/?'),
+ 308,
+ imagePath,
+ );
+
+ const image = await request(origin, imagePath, {
+ headers: {
+ accept: 'image/webp,image/apng,*/*',
+ },
+ });
+ const imageBytes = new Uint8Array(await image.arrayBuffer());
+ const imageMetadata = await sharp(imageBytes).metadata();
+
+ assert.equal(image.status, 200);
+ assert.equal(image.headers.get('content-type'), 'image/webp');
+ assert.equal(
+ image.headers.get('cache-control'),
+ 'public, max-age=604800, must-revalidate',
+ );
+ assert.equal(image.headers.get('vary'), 'Accept');
+ assert.equal(
+ image.headers.get('content-length'),
+ String(imageBytes.length),
+ );
+ assert.match(image.headers.get('etag') ?? '', /^"[A-Za-z0-9_-]+"$/);
+ assert.equal(imageMetadata.width, 20);
+ assert.equal(imageMetadata.height, 10);
+ assert.deepEqual(state.mediaRequests.at(-1), {
+ method: 'GET',
+ path: '/media/image.jpg',
+ search: '?width=20&height=10',
+ accept: '*/*',
+ range: undefined,
+ ifNoneMatch: undefined,
+ ifModifiedSince: undefined,
+ });
+
+ const cropped = await request(
+ origin,
+ `/_image?url=${encodeURIComponent('/cms-media/media/image.jpg?crop=0,0,1,1&maxsize=50')}&w=32&q=75`,
+ { headers: { accept: 'image/webp' } },
+ );
+ assert.equal(cropped.status, 200);
+ assert.equal(
+ state.mediaRequests.at(-1).search,
+ '?crop=0%2C0%2C1%2C1&maxsize=50',
+ );
+
+ const notModified = await request(origin, imagePath, {
+ headers: {
+ accept: 'image/webp',
+ 'if-none-match': image.headers.get('etag'),
+ },
+ });
+ assert.equal(notModified.status, 304);
+ assert.equal(await notModified.text(), '');
+ assert.equal(notModified.headers.get('etag'), image.headers.get('etag'));
+
+ const head = await request(origin, imagePath, {
+ method: 'HEAD',
+ headers: { accept: 'image/webp' },
+ });
+ assert.equal(head.status, 200);
+ assert.equal(head.headers.get('content-type'), 'image/webp');
+ assert.equal(TEXT_ENCODER.encode(await head.text()).byteLength, 0);
+
+ const jpegFallback = await request(origin, imagePath, {
+ headers: { accept: 'image/jpeg,*/*' },
+ });
+ assert.equal(jpegFallback.status, 200);
+ assert.equal(jpegFallback.headers.get('content-type'), 'image/jpeg');
+
+ const acceptCases = [
+ ['image/webp;q=1,image/jpeg', 'image/webp'],
+ ['image/webp;foo=bar;q=.5,image/jpeg', 'image/webp'],
+ ['image/webp;q=0,image/jpeg,*/*;q=.8', 'image/jpeg'],
+ ['image/*,*/*', 'image/jpeg'],
+ ['*/*', 'image/jpeg'],
+ ['', 'image/jpeg'],
+ ['IMAGE/WEBP,image/jpeg', 'image/jpeg'],
+ ];
+
+ for (const [accept, contentType] of acceptCases) {
+ const response = await request(origin, imagePath, {
+ headers: { accept },
+ });
+ assert.equal(response.status, 200, accept);
+ assert.equal(response.headers.get('content-type'), contentType, accept);
+ }
+
+ const privateCache = await request(
+ origin,
+ `/_image?url=${encodeURIComponent('/cms-media/media/private-image.jpg')}&w=32&q=75`,
+ { headers: { accept: 'image/webp' } },
+ );
+ assert.equal(privateCache.status, 200);
+ assert.equal(
+ privateCache.headers.get('cache-control'),
+ 'private, no-store',
+ );
+
+ const cases = [
+ `/_image?url=${encodeURIComponent('https://example.com/image.jpg')}&w=32&q=75`,
+ `/_image?url=${encodeURIComponent('//example.com/image.jpg')}&w=32&q=75`,
+ '/_image?url=%E0%A4%A&w=32&q=75',
+ `/_image?url=${encodeURIComponent('/cms-media/../secret.jpg')}&w=32&q=75`,
+ `/_image?url=${encodeURIComponent('/cms-media/media/file.pdf')}&w=32&q=75`,
+ `/_image?url=${encodeURIComponent('/cms-media/media/image.jpg#fragment')}&w=32&q=75`,
+ `/_image?url=${encodeURIComponent('/cms-media/media/image.jpg?format=webp')}&w=32&q=75`,
+ `/_image?url=${encodeURIComponent('/cms-media/media/image.jpg?width=0')}&w=32&q=75`,
+ `/_image?url=${encodeURIComponent('/cms-media/media/image.jpg')}&w=33&q=75`,
+ `/_image?url=${encodeURIComponent('/cms-media/media/image.jpg')}&w=32&q=80`,
+ ];
+
+ for (const path of cases) {
+ const response = await request(origin, path, {
+ headers: { accept: 'image/webp' },
+ });
+ assert.equal(response.status, 400, path);
+ }
+
+ const tooLarge = await request(
+ origin,
+ `/_image?url=${encodeURIComponent('/cms-media/media/too-large.jpg')}&w=32&q=75`,
+ { headers: { accept: 'image/webp' } },
+ );
+ assert.equal(tooLarge.status, 502);
+
+ const redirect = await request(
+ origin,
+ `/_image?url=${encodeURIComponent('/cms-media/media/redirect.jpg')}&w=32&q=75`,
+ { headers: { accept: 'image/webp' } },
+ );
+ assert.equal(redirect.status, 502);
+
+ const processingFailure = await request(
+ origin,
+ `/_image?url=${encodeURIComponent('/cms-media/media/broken.jpg')}&w=32&q=75`,
+ { headers: { accept: 'image/webp' } },
+ );
+ assert.equal(processingFailure.status, 502);
+ assert.equal(await processingFailure.text(), 'Image optimization failed');
+}
+
async function runMediaChecks(origin) {
state.mediaRequests = [];
@@ -483,6 +706,7 @@ try {
await runRouteChecks(app.origin);
await runMobileChecks(app.origin);
await runMediaChecks(app.origin);
+ await runImageOptimizerChecks(app.origin);
} finally {
await app.close();
}
diff --git a/tests/unit/cms-image.test.ts b/tests/unit/cms-image.test.ts
new file mode 100644
index 0000000..e87b71b
--- /dev/null
+++ b/tests/unit/cms-image.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest';
+import {
+ buildCmsImageAttributes,
+ buildOptimizerUrl,
+ nextFixedWidths,
+} from '../../src/lib/images/cmsImage.ts';
+
+describe('CmsImage helper', () => {
+ it('matches the fixed-width Next 1x/2x rounding', () => {
+ expect(nextFixedWidths(100)).toEqual([128, 256]);
+ expect(nextFixedWidths(500)).toEqual([640, 1080]);
+ expect(nextFixedWidths(1549)).toEqual([1920, 3840]);
+ });
+
+ it('uses the last fixed-width candidate as src and preserves attrs', () => {
+ const attrs = buildCmsImageAttributes({
+ src: '/cms-media/media/a/photo.jpg?width=100&height=100',
+ width: 100,
+ height: 100,
+ alt: 'Gene Gotimer',
+ class: 'rounded-full',
+ loading: 'lazy',
+ decoding: 'async',
+ });
+
+ expect(attrs).toMatchObject({
+ src: buildOptimizerUrl(
+ '/cms-media/media/a/photo.jpg?width=100&height=100',
+ 256,
+ 75,
+ ),
+ width: 100,
+ height: 100,
+ alt: 'Gene Gotimer',
+ class: 'rounded-full',
+ loading: 'lazy',
+ decoding: 'async',
+ });
+ expect(attrs.srcset).toBe(
+ `${buildOptimizerUrl('/cms-media/media/a/photo.jpg?width=100&height=100', 128, 75)} 1x, ${buildOptimizerUrl('/cms-media/media/a/photo.jpg?width=100&height=100', 256, 75)} 2x`,
+ );
+ });
+
+ it('deduplicates fixed-width srcset candidates', () => {
+ const attrs = buildCmsImageAttributes({
+ src: '/cms-media/media/a/logo.png?height=375',
+ width: 3840,
+ height: 930,
+ alt: 'Sponsor',
+ quality: 100,
+ });
+
+ expect(attrs.srcset).toBe(
+ `${buildOptimizerUrl('/cms-media/media/a/logo.png?height=375', 3840, 100)} 1x`,
+ );
+ });
+});
diff --git a/tests/unit/image-optimizer-pipeline.test.ts b/tests/unit/image-optimizer-pipeline.test.ts
new file mode 100644
index 0000000..7542cf6
--- /dev/null
+++ b/tests/unit/image-optimizer-pipeline.test.ts
@@ -0,0 +1,90 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const sharpMock = vi.hoisted(() => {
+ const order: string[] = [];
+ const pipeline = {
+ timeout: vi.fn(() => {
+ order.push('timeout');
+ return pipeline;
+ }),
+ metadata: vi.fn(() => {
+ order.push('metadata');
+ return Promise.resolve({ pages: 1 });
+ }),
+ rotate: vi.fn(() => {
+ order.push('rotate');
+ return pipeline;
+ }),
+ resize: vi.fn(() => {
+ order.push('resize');
+ return pipeline;
+ }),
+ webp: vi.fn(() => {
+ order.push('webp');
+ return pipeline;
+ }),
+ jpeg: vi.fn(() => {
+ order.push('jpeg');
+ return pipeline;
+ }),
+ png: vi.fn(() => {
+ order.push('png');
+ return pipeline;
+ }),
+ toBuffer: vi.fn(() => {
+ order.push('toBuffer');
+ return Promise.resolve(new Uint8Array([1, 2, 3]));
+ }),
+ };
+ const sharp = vi.fn(() => pipeline);
+
+ return { order, pipeline, sharp };
+});
+
+vi.mock('sharp', () => ({
+ default: sharpMock.sharp,
+}));
+
+import {
+ IMAGE_MAX_INPUT_PIXELS,
+ IMAGE_SHARP_TIMEOUT_SECONDS,
+ optimizeImageBuffer,
+} from '../../src/lib/server/imageOptimizer.ts';
+
+beforeEach(() => {
+ sharpMock.order.length = 0;
+ vi.clearAllMocks();
+});
+
+describe('image optimizer Sharp pipeline', () => {
+ it('sets the processing timeout before metadata and transforms run', async () => {
+ const input = new Uint8Array([10, 20, 30]);
+ const optimized = await optimizeImageBuffer({
+ input,
+ width: 32,
+ quality: 75,
+ upstreamContentType: 'image/jpeg',
+ accept: 'image/webp',
+ });
+
+ expect(sharpMock.sharp).toHaveBeenCalledWith(input, {
+ limitInputPixels: IMAGE_MAX_INPUT_PIXELS,
+ sequentialRead: true,
+ });
+ expect(sharpMock.pipeline.timeout).toHaveBeenCalledWith({
+ seconds: IMAGE_SHARP_TIMEOUT_SECONDS,
+ });
+ expect(sharpMock.order).toEqual([
+ 'timeout',
+ 'metadata',
+ 'rotate',
+ 'resize',
+ 'webp',
+ 'toBuffer',
+ ]);
+ expect(optimized).toEqual({
+ bytes: new Uint8Array([1, 2, 3]),
+ contentType: 'image/webp',
+ });
+ });
+});
diff --git a/tests/unit/image-optimizer.test.ts b/tests/unit/image-optimizer.test.ts
new file mode 100644
index 0000000..dd7dda4
--- /dev/null
+++ b/tests/unit/image-optimizer.test.ts
@@ -0,0 +1,91 @@
+import sharp from 'sharp';
+import { describe, expect, it } from 'vitest';
+import { optimizeImageBuffer } from '../../src/lib/server/imageOptimizer.ts';
+
+async function rgba(bytes: Uint8Array) {
+ const { data, info } = await sharp(bytes)
+ .ensureAlpha()
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+
+ return {
+ data: new Uint8Array(data),
+ width: info.width,
+ height: info.height,
+ };
+}
+
+async function fixturePng() {
+ return sharp({
+ create: {
+ width: 20,
+ height: 10,
+ channels: 3,
+ background: { r: 40, g: 90, b: 140 },
+ },
+ })
+ .png()
+ .toBuffer();
+}
+
+describe('image optimizer transform', () => {
+ it('matches the expected Sharp WebP transform at decoded RGBA level', async () => {
+ const input = await sharp(await fixturePng())
+ .jpeg({ quality: 92 })
+ .toBuffer();
+ const optimized = await optimizeImageBuffer({
+ input,
+ width: 32,
+ quality: 75,
+ upstreamContentType: 'image/jpeg',
+ accept: 'image/webp,image/apng,*/*',
+ });
+ const expected = await sharp(input, {
+ limitInputPixels: 40_000_000,
+ sequentialRead: true,
+ })
+ .rotate()
+ .resize(32, undefined, { withoutEnlargement: true })
+ .webp({ quality: 75 })
+ .toBuffer();
+
+ expect(optimized.contentType).toBe('image/webp');
+ expect(await rgba(optimized.bytes)).toEqual(await rgba(expected));
+ });
+
+ it('does not enlarge natural dimensions and keeps PNG when WebP is not accepted', async () => {
+ const input = await fixturePng();
+ const optimized = await optimizeImageBuffer({
+ input,
+ width: 128,
+ quality: 100,
+ upstreamContentType: 'image/png',
+ accept: 'image/png,*/*',
+ });
+ const metadata = await sharp(optimized.bytes).metadata();
+
+ expect(optimized.contentType).toBe('image/png');
+ expect(metadata.width).toBe(20);
+ expect(metadata.height).toBe(10);
+ });
+
+ it.each([
+ ['image/webp,image/apng,*/*', 'image/webp'],
+ ['image/webp;level=1;q=0.5,image/jpeg', 'image/webp'],
+ ['image/webp;q=0,image/jpeg,*/*;q=.8', 'image/jpeg'],
+ ['image/*,*/*', 'image/jpeg'],
+ ['*/*', 'image/jpeg'],
+ ['', 'image/jpeg'],
+ ['IMAGE/WEBP,image/jpeg', 'image/jpeg'],
+ ])('negotiates %s as %s', async (accept, contentType) => {
+ const optimized = await optimizeImageBuffer({
+ input: await fixturePng(),
+ width: 32,
+ quality: 75,
+ upstreamContentType: 'image/jpeg',
+ accept,
+ });
+
+ expect(optimized.contentType).toBe(contentType);
+ });
+});