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 @@ + + +{attrs.alt} 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); + }); +});