diff --git a/package-lock.json b/package-lock.json index d86fb625..ba644fa0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "prom-client": "^15.1.3", "qrcode": "^1.5.4", "redis": "^4.7.1", + "sharp": "^0.35.4", "speakeasy": "^2.0.0" }, "devDependencies": { @@ -1548,6 +1549,16 @@ "postcss-selector-parser": "^7.0.0" } }, + "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/@exodus/bytes": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", @@ -1691,6 +1702,506 @@ "url": "https://opencollective.com/node-fetch" } }, + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -3809,6 +4320,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -6347,9 +6867,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6421,6 +6941,55 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "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", diff --git a/package.json b/package.json index 61ee5154..da48cf04 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "prom-client": "^15.1.3", "qrcode": "^1.5.4", "redis": "^4.7.1", + "sharp": "^0.35.4", "speakeasy": "^2.0.0" }, "optionalDependencies": { diff --git a/public/css/styles.css b/public/css/styles.css index fc81392e..a3c26f43 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1278,12 +1278,14 @@ body.menu-hidden .sidebar-section-label, body.menu-hidden .menu-brand h1, body.menu-hidden .account-id, body.menu-hidden .account-chevron { display:none; } -body.menu-hidden .menu-head { flex-direction:column; gap:4px; padding:10px 0 8px; align-items:stretch; } -/* Every item is the same 52px-wide centred box, so the icons share one axis. */ -body.menu-hidden .menu-head > * { width:100%; margin:0; justify-content:center; } -body.menu-hidden .menu-brand { display:flex; align-items:center; justify-content:center; height:32px; gap:0; } -body.menu-hidden .menu-brand i { font-size:19px; width:auto; } -body.menu-hidden .menu-icon-btn { width:32px; height:32px; margin:0 auto; } +/* Collapsed, the head stacks. Every child is the same fixed-size centred box on + one axis — the brand kept its expanded `margin-right:auto`, which pushed the + stethoscope off the line the two buttons sat on. */ +body.menu-hidden .menu-head { flex-direction:column; align-items:center; gap:6px; padding:10px 0 8px; } +body.menu-hidden .menu-head > * { width:32px; height:32px; margin:0; flex:0 0 auto; + display:flex; align-items:center; justify-content:center; } +body.menu-hidden .menu-brand { gap:0; } +body.menu-hidden .menu-brand i { font-size:18px; } body.menu-hidden .account-card { padding:8px 0; } body.menu-hidden .account-card-btn { justify-content:center; padding:6px 0; } body.menu-hidden .account-menu { left:8px; right:auto; width:210px; } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 2a6f5fa5..7927205c 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -23,7 +23,7 @@ import { translateAssistantMessage, assistantAttachmentLimits, assistantAttachmentPayload -, fetchAssistantImageJobs, renameSavedAssistantChat } from './assistant/api.js'; +, fetchAssistantImageJob, fetchAssistantImageJobs, renameSavedAssistantChat } from './assistant/api.js'; var initialized = false; var messages = []; var attachments = []; // Input-only images: ride the outgoing question, then persist with the sent message. @@ -1409,6 +1409,7 @@ import { exporter.invalidate(); if (typeof hooks.onStatus === 'function') hooks.onStatus('Generating image…'); // The job runs server-side; closing the popup never cancels it. + var pollAttempts = 0; var poll = function () { // Keep polling until the job finishes; image models can take minutes. fetchAssistantImageJob(data.jobId).then(function (job) { @@ -1430,7 +1431,22 @@ import { return; } setTimeout(poll, 2500); - }).catch(function () { setTimeout(poll, 2500); }); + }).catch(function (pollError) { + // A transient network blip should retry; a programming error must not + // masquerade as a slow image forever. This exact bug shipped: the + // status fetch was never imported, so every tick threw and the catch + // silently rescheduled, leaving "Generating image…" up permanently. + if (pollError instanceof ReferenceError || pollError instanceof TypeError) { + if (typeof hooks.onError === 'function') hooks.onError('Cannot check image status: ' + pollError.message); + console.error('[clinical-assistant] image polling is broken', pollError); + return; + } + if (++pollAttempts > 240) { // ~10 minutes of transient failures + if (typeof hooks.onError === 'function') hooks.onError('Lost contact while generating; the image may still finish — check the gallery.'); + return; + } + setTimeout(poll, 2500); + }); }; setTimeout(poll, 2500); }).catch(function (error) { diff --git a/public/js/generatedImages.js b/public/js/generatedImages.js index 828faaac..53df2bd6 100644 --- a/public/js/generatedImages.js +++ b/public/js/generatedImages.js @@ -1,6 +1,8 @@ import { captureSharingOwner, assertSharingOwner, validSharingOwner } from './assistant/sharing.js'; // Authenticated assets: stable URLs are persisted; transient display URLs are realm-owned only. -const ASSET = /^\/api\/generated-images\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:\?download=1)?$/; +// Only the exact shapes the server serves: the asset, its download, or one of +// the two allow-listed preview widths. Anything else is not a private asset URL. +const ASSET = /^\/api\/generated-images\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:\?download=1|\?w=(?:256|640))?$/; const urls = new Map(); export function assetPath(src) { return ASSET.test(String(src || '')); } export function captureImageOwner() { @@ -121,8 +123,14 @@ export async function hydrateImage(img, src, ticket = captureImageOwner()) { img.removeAttribute('src'); // A tile marked data-image-thumb gets the downscaled copy; full views get the // original. Both come from the session cache, so a re-render costs nothing. + // The server now stores real previews, so a tile fetches a few kB rather than + // downloading the original and shrinking it here. cachedThumbnail stays as the + // fallback for assets whose preview cannot be produced. const edge = Number(img.getAttribute('data-image-thumb')) || 0; - const blob = edge ? await cachedThumbnail(src, edge, ticket) : await cachedImageBlob(src, ticket); + const blob = edge + ? await cachedImageBlob(src + (src.indexOf('?') === -1 ? '?' : '&') + 'w=' + edge, ticket) + .catch(() => cachedThumbnail(src, edge, ticket)) + : await cachedImageBlob(src, ticket); assertImageOwner(ticket); if (img.isConnected) img.src = transientImageUrl(blob, ticket); } diff --git a/src/routes/generatedImages.js b/src/routes/generatedImages.js index 0841425c..82c2e1f7 100644 --- a/src/routes/generatedImages.js +++ b/src/routes/generatedImages.js @@ -5,6 +5,25 @@ const db = require('../db/database'); router.use(authMiddleware); function fail(res, e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); } async function sendAsset(req, res, download) { + // ?w= serves a stored preview of the SAME asset. Permission is checked against + // the original first, so a preview can never widen who can see an image, and + // only an allow-listed width is honoured. + const width = images.thumbWidth(req.query.w); + if (width && !download) { + await images.service().asset(req.params.id, req.user); // authorise, then serve derived + const thumb = await images.service().thumbnail(req.params.id, width); + res.setHeader('Content-Type', thumb.mime); + res.setHeader('Content-Length', thumb.bytes.length); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Cache-Control', 'private, no-store'); + // The client verifies these on every asset, so a preview carries its OWN + // checksum — the original's would not match these bytes and would be + // rejected as tampering, which is exactly the check working. + res.setHeader('X-Image-SHA256', require('crypto').createHash('sha256').update(thumb.bytes).digest('hex')); + res.setHeader('X-Image-Owner', String(req.user.id)); + res.setHeader('Content-Disposition', 'inline; filename="preview.webp"'); + return res.send(thumb.bytes); + } const image = await images.service().asset(req.params.id, req.user); res.setHeader('Content-Type', image.mime); res.setHeader('Content-Length', image.bytes.length); diff --git a/src/utils/generatedImageStorage.js b/src/utils/generatedImageStorage.js index 56d12e35..473f3441 100644 --- a/src/utils/generatedImageStorage.js +++ b/src/utils/generatedImageStorage.js @@ -47,6 +47,10 @@ async function download(url, { lookup = dns.lookup, request = https.get, signal req.on('error', reject); }); } +// Derived previews are addressed by asset id and width, so a request can only +// ever reach a preview of the asset it already has permission to read. +function thumbKey(id, width) { return 'thumbs/' + id + '/' + width; } + function createStorage(env = process.env) { const { S3Client, HeadBucketCommand, HeadObjectCommand, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); for (const key of ['ENDPOINT', 'ACCESS_KEY_FILE', 'SECRET_KEY_FILE']) if (!env['GENERATED_IMAGES_S3_' + key]) throw new Error('Generated image storage is not configured'); @@ -65,6 +69,22 @@ function createStorage(env = process.env) { }, async put(id, image) { await client.send(new PutObjectCommand({ Bucket, Key: 'assets/' + id, Body: image.bytes, ContentType: image.mime, ChecksumSHA256: Buffer.from(image.checksum, 'hex').toString('base64'), Metadata: { sha256: image.checksum } })); }, + // Derived previews live beside the originals in the same bucket, under their + // own prefix, so nothing about credentials, lifecycle or backup changes. + async putThumb(id, width, bytes, mime) { + await client.send(new PutObjectCommand({ Bucket, Key: thumbKey(id, width), Body: bytes, ContentType: mime })); + }, + async getThumb(id, width) { + try { + const response = await client.send(new GetObjectCommand({ Bucket, Key: thumbKey(id, width) })); + const chunks = []; + for await (const chunk of response.Body) chunks.push(chunk); + return { bytes: Buffer.concat(chunks), mime: response.ContentType || 'image/webp' }; + } catch (e) { + if (e && (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404)) return null; + throw e; + } + }, async get(id) { const response = await client.send(new GetObjectCommand({ Bucket, Key: 'assets/' + id })); if (response.ContentLength > MAX_BYTES) { response.Body.destroy(); throw new Error('Invalid stored image size'); } diff --git a/src/utils/generatedImages.js b/src/utils/generatedImages.js index c6576499..2c41cb78 100644 --- a/src/utils/generatedImages.js +++ b/src/utils/generatedImages.js @@ -40,6 +40,25 @@ function shouldRetryImageFallback(state) { return true; } +// Previews are generated once and stored beside the original, so a 56px tile +// costs a few kB instead of ~280kB. Only these widths are allowed: a caller +// cannot ask for arbitrary sizes and turn this into a CPU amplifier. +const THUMB_WIDTHS = Object.freeze([256, 640]); + +function thumbWidth(requested) { + const width = Number(requested); + return THUMB_WIDTHS.includes(width) ? width : null; +} + +async function renderThumb(bytes, width) { + const sharp = require('sharp'); // loaded lazily; nothing else needs it + return sharp(bytes, { limitInputPixels: 40e6, sequentialRead: true }) + .rotate() + .resize({ width, withoutEnlargement: true }) + .webp({ quality: 82, effort: 4 }) + .toBuffer(); +} + function publicJob(job) { const done = job.stage === 'done'; return { success: true, jobId: job.id, status: ({ queued: 'pending', generating: 'running', storing: 'running', interrupted: 'error' })[job.stage] || job.stage, @@ -193,11 +212,34 @@ function createImageService({ db, storage, generate = provider, encryption = req try { await getStorage().put(job.id, image); await db.query("UPDATE generated_image_jobs SET stage='done',staged_bytes=NULL,lease_token=NULL,lease_until=NULL,error_code=NULL,updated_at=NOW() WHERE id=$1 AND lease_token=$2 AND stage='storing' AND lease_until > NOW()", [job.id, job.lease_token]); + // Render previews now so the first viewer does not wait for a resize. The + // job is already done and recorded; a preview failure never unmakes that. + await warmThumbs(job.id); } catch (_) { await db.query("UPDATE generated_image_jobs SET error_code='storage_unavailable',lease_until=NOW()+interval '30 seconds' WHERE id=$1 AND lease_token=$2 AND stage='storing'", [job.id, job.lease_token]); } } - async function get(id, owner, workflow) { + // Used both on demand and at creation time; the stored copy wins either way. +async function thumbnail(id, width) { + const cached = await getStorage().getThumb(id, width); + if (cached) return cached; + const original = await getStorage().get(id); + const bytes = await renderThumb(original.bytes, width); + // A failure to cache must not fail the request that already has its answer. + try { await getStorage().putThumb(id, width, bytes, 'image/webp'); } + catch (_) { /* served anyway; the next request retries the write */ } + return { bytes, mime: 'image/webp' }; +} + +// Called after a job completes so the first viewer does not pay for the resize. +async function warmThumbs(id) { + for (const width of THUMB_WIDTHS) { + try { await thumbnail(id, width); } + catch (_) { /* previews are an optimisation; never fail the job for one */ } + } +} + +async function get(id, owner, workflow) { if (!UUID.test(id)) throw failure(404, 'Image job not found'); const result = await db.query('SELECT id,stage,model,error_code,context_included,context_total,prompt_units,budget FROM generated_image_jobs WHERE id=$1 AND owner_id=$2 AND workflow=$3', [id, owner, workflow]); if (!result.rows[0]) throw failure(404, 'Image job not found'); @@ -230,7 +272,7 @@ function createImageService({ db, storage, generate = provider, encryption = req if (active) await active; // The same storage client serves draining HTTP reads; process shutdown owns its lifetime. } - return { enqueue, get, asset, ready, snapshot, claim, tick, start, stop }; + return { enqueue, get, asset, ready, snapshot, claim, tick, start, stop, thumbnail, warmThumbs }; } let singleton; function service() { return singleton || (singleton = createImageService({ db: require('../db/database') })); } @@ -241,4 +283,4 @@ function requestKey(body) { } return crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex'); } -module.exports = { createImageService, service, budgetLimit, args, imageContext, IMAGE_OUTPUT_RULE, publicJob, requestKey, UUID, failure, isDefiniteImageError, isAbortImageError, shouldRetryImageFallback }; +module.exports = { createImageService, service, budgetLimit, THUMB_WIDTHS, thumbWidth, args, imageContext, IMAGE_OUTPUT_RULE, publicJob, requestKey, UUID, failure, isDefiniteImageError, isAbortImageError, shouldRetryImageFallback }; diff --git a/test/assistant-image-done.test.js b/test/assistant-image-done.test.js new file mode 100644 index 00000000..574a4533 --- /dev/null +++ b/test/assistant-image-done.test.js @@ -0,0 +1,43 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = path.join(__dirname, '..'); +const read = f => fs.readFileSync(path.join(root, f), 'utf8'); + +// The Create image popup showed "Generating image…" forever even though the job +// had finished. Cause: the status poll called fetchAssistantImageJob, which was +// never imported, so every tick threw ReferenceError — and the catch treated it +// as a transient failure and rescheduled, permanently. +test('every api.js function the assistant calls is actually imported', () => { + const src = read('public/js/clinicalAssistant.js'); + const api = read('public/js/assistant/api.js'); + const marker = "from './assistant/api.js';"; + const importBlock = src.slice(0, src.indexOf(marker)); + const body = src.slice(importBlock.length); + + const exported = [...api.matchAll(/export function (\w+)/g)].map(m => m[1]); + assert.ok(exported.length > 5, 'found the api surface'); + const missing = exported.filter(name => + new RegExp('\\b' + name + '\\s*\\(').test(body) && !new RegExp('\\b' + name + '\\b').test(importBlock)); + assert.deepEqual(missing, [], 'called without being imported: ' + missing.join(', ')); +}); + +test('a broken poll surfaces instead of retrying forever', () => { + const src = read('public/js/clinicalAssistant.js'); + const poll = src.slice(src.indexOf('var pollAttempts = 0;'), src.indexOf('// ── Images gallery')); + // A programming error must not look like a slow image. + assert.match(poll, /pollError instanceof ReferenceError \|\| pollError instanceof TypeError/); + assert.match(poll, /hooks\.onError.*Cannot check image status/, 'and the user is told'); + assert.match(poll, /console\.error\('\[clinical-assistant\] image polling is broken'/); + // Even genuine transient failures cannot retry indefinitely. + assert.match(poll, /if \(\+\+pollAttempts > 240\)/); + assert.match(poll, /the image may still finish/, 'and the message says the work was not lost'); +}); + +test('the done handler still reports success when a later step fails', () => { + const src = read('public/js/clinicalAssistant.js'); + // Rendering history or opening a preview must not turn a finished image into + // an error state. + assert.match(src, /catch \(doneError\) \{\s*\n\s*if \(typeof hooks\.onError === 'function'\) hooks\.onError\('Image ready: '/); +}); diff --git a/test/generated-image-cache.test.js b/test/generated-image-cache.test.js index b7d1dd36..4ec59904 100644 --- a/test/generated-image-cache.test.js +++ b/test/generated-image-cache.test.js @@ -30,14 +30,46 @@ test('concurrent tiles share one request instead of racing', () => { assert.match(js, /\.finally\(\(\) => inflight\.delete\(key\)\)/, 'and the slot is released either way'); }); -test('a tile decodes a thumbnail, not a full image', () => { +test('a tile fetches a server preview, not the original', () => { const js = read('public/js/generatedImages.js'); assert.match(js, /const edge = Number\(img\.getAttribute\('data-image-thumb'\)\) \|\| 0;/); - assert.match(js, /edge \? await cachedThumbnail\(src, edge, ticket\) : await cachedImageBlob\(src, ticket\)/); + // The server stores real previews now, so a tile downloads a few kB instead of + // pulling ~280kB and shrinking it in the browser. + assert.match(js, /'w=' \+ edge/, 'the tile asks the server for the preview'); + assert.match(js, /\.catch\(\(\) => cachedThumbnail\(src, edge, ticket\)\)/, + 'client downscaling remains the fallback'); // A browser without OffscreenCanvas still shows the image rather than nothing. assert.match(js, /if \(typeof createImageBitmap !== 'function' \|\| typeof OffscreenCanvas !== 'function'\) return blob;/); - assert.match(js, /catch \(_\) \{\s*\n\s*return blob;/, 'and a decode failure falls back to the original'); const assistant = read('public/js/clinicalAssistant.js'); assert.match(assistant, /data-image-thumb="256"/, 'the 56px gallery asks for a small copy'); }); + +test('a preview cannot widen access or be asked for arbitrary sizes', () => { + const utils = read('src/utils/generatedImages.js'); + const route = read('src/routes/generatedImages.js'); + // An open width parameter would let a caller drive arbitrary resizes. + assert.match(utils, /const THUMB_WIDTHS = Object\.freeze\(\[256, 640\]\);/); + assert.match(utils, /return THUMB_WIDTHS\.includes\(width\) \? width : null;/); + // Permission is checked against the ORIGINAL before any preview is served. + assert.match(route, /await images\.service\(\)\.asset\(req\.params\.id, req\.user\); \/\/ authorise/); + // Previews carry their own checksum; the original's would be rejected. + assert.match(route, /createHash\('sha256'\)\.update\(thumb\.bytes\)/); + assert.match(route, /Cache-Control', 'private, no-store'/, 'and they are no-store like the original'); + + const client = read('public/js/generatedImages.js'); + assert.match(client, /\\?w=\(\?:256\|640\)/, 'the client accepts only those two widths as asset URLs'); +}); + +test('previews are rendered once and stored beside the original', () => { + const utils = read('src/utils/generatedImages.js'); + assert.match(utils, /const cached = await getStorage\(\)\.getThumb\(id, width\);\s*\n\s*if \(cached\) return cached;/, + 'a stored preview is reused rather than re-rendered'); + assert.match(utils, /await warmThumbs\(job\.id\);/, 'and rendered when the job completes'); + // Neither caching nor warming may break the thing they are optimising. + assert.match(utils, /catch \(_\) \{ \/\* served anyway; the next request retries the write \*\/ \}/); + assert.match(utils, /catch \(_\) \{ \/\* previews are an optimisation; never fail the job for one \*\/ \}/); + + const storage = read('src/utils/generatedImageStorage.js'); + assert.match(storage, /return 'thumbs\/' \+ id \+ '\/' \+ width;/, 'same bucket, own prefix'); +});