perf: store real image previews in MinIO, generated at creation and on demand
Gallery tiles are 56px but were downloading the full ~280kB original. Previews are now rendered with sharp and stored beside the originals in the same MinIO bucket under a thumbs/ prefix, so nothing about credentials, lifecycle or backup changes. Measured on live assets: 216-294kB originals become 13-19kB at 256px, about 16x smaller; 640px is about 4x. Both paths, as asked: - Rendered when a job completes, so the first viewer never waits for a resize. A preview failure never unmakes a finished job. - Rendered on demand for anything that has none — the existing 26 images work immediately with no backfill required, and the result is stored for next time. Boundaries that matter more than the speed: - Only 256 and 640 are honoured. An open width parameter would let a caller drive arbitrary resizes. - Permission is checked against the ORIGINAL before a preview is served, so a preview can never widen who can see an image. - Previews carry their own SHA-256 and owner headers, because the client verifies both on every asset; sending the original's checksum would be rejected as tampering, which is that check working correctly. - Still private, no-store. The client asset pattern was widened to exactly ?w=256 and ?w=640 and nothing else. Client-side downscaling stays as the fallback when a preview cannot be produced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e1PLqrKgAM9jQhFKRnbLd
This commit is contained in:
parent
2096057ebd
commit
2f7233f317
10 changed files with 771 additions and 19 deletions
575
package-lock.json
generated
575
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
43
test/assistant-image-done.test.js
Normal file
43
test/assistant-image-done.test.js
Normal file
|
|
@ -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: '/);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue