feat: image upload optimization with sharp, thumbnails, and structured error codes (#304)
- Add sharp for server-side image processing (WebP conversion + thumbnails) - New shared backend utility for image upload, optimization, and cleanup - Return structured error codes from upload endpoints (IMAGE_TOO_LARGE, INVALID_TYPE, etc.) - Frontend error code mapping with i18n support (EN + DE) - MedicationAvatar tries thumbnail first, falls back to full image - Error display in MedicationsPage, MobileEditModal, and Auth avatar upload Closes #302
This commit is contained in:
Generated
+536
@@ -23,6 +23,7 @@
|
||||
"fastify": "^5.7.4",
|
||||
"nodemailer": "^8.0.1",
|
||||
"openid-client": "^6.8.2",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -268,6 +269,16 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
@@ -1500,6 +1511,471 @@
|
||||
"glob": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"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.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"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.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"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.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"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.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"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.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"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.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"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.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"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.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"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.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"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.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.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",
|
||||
@@ -4912,6 +5388,59 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/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/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -5240,6 +5769,13 @@
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"fastify": "^5.7.4",
|
||||
"nodemailer": "^8.0.1",
|
||||
"openid-client": "^6.8.2",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+30
-34
@@ -1,4 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import argon2 from "argon2";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
@@ -8,6 +9,12 @@ import { getDataDir } from "../db/db-utils.js";
|
||||
import { refreshTokens, users } from "../db/schema.js";
|
||||
import { getAuthState, requireAuth } from "../plugins/auth.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
removeImageFiles,
|
||||
streamToBuffer,
|
||||
writeOptimizedImageSet,
|
||||
} from "../utils/image-upload.js";
|
||||
|
||||
// =============================================================================
|
||||
// Argon2id Configuration - State of the Art Password Hashing
|
||||
@@ -82,6 +89,8 @@ const updateProfileSchema = z.object({
|
||||
// Auth Routes
|
||||
// =============================================================================
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
|
||||
// Token TTLs
|
||||
const accessTtlMinutes = 15;
|
||||
const refreshTtlDays = 14;
|
||||
@@ -462,36 +471,35 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
return reply.status(400).send({ error: "No file uploaded" });
|
||||
return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
if (!allowedTypes.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
|
||||
if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = data.filename.split(".").pop() || "jpg";
|
||||
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
|
||||
let uploadBuffer: Buffer;
|
||||
try {
|
||||
uploadBuffer = await streamToBuffer(data.file);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") {
|
||||
return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Save file
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const imagesDir = path.join(getDataDir(), "images");
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
const buffer = await data.toBuffer();
|
||||
await fs.writeFile(path.join(imagesDir, filename), buffer);
|
||||
let filename: string;
|
||||
try {
|
||||
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `avatar_${authUser.id}`, uploadBuffer));
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
|
||||
}
|
||||
|
||||
// Delete old avatar if exists
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (user?.avatarUrl) {
|
||||
try {
|
||||
await fs.unlink(path.join(imagesDir, user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||
}
|
||||
|
||||
// Update user
|
||||
@@ -522,13 +530,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
// Delete file
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
try {
|
||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||
|
||||
// Update user
|
||||
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||
@@ -555,13 +557,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// Delete avatar file if exists
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (user?.avatarUrl) {
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
try {
|
||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||
}
|
||||
|
||||
// Delete user - cascade delete handles all related data
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createWriteStream, existsSync, unlinkSync } from "node:fs";
|
||||
import { extname, resolve } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { and, eq, like } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
@@ -10,6 +8,12 @@ import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
removeImageFiles,
|
||||
streamToBuffer,
|
||||
writeOptimizedImageSet,
|
||||
} from "../utils/image-upload.js";
|
||||
import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||
|
||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
@@ -693,10 +697,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
if (existing.imageUrl) {
|
||||
const imagePath = resolve(IMAGES_DIR, existing.imageUrl);
|
||||
if (existsSync(imagePath)) unlinkSync(imagePath);
|
||||
}
|
||||
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||
|
||||
const deleted = await db
|
||||
.delete(medications)
|
||||
@@ -719,24 +720,31 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const data = await req.file();
|
||||
if (!data) return reply.badRequest("No file uploaded");
|
||||
if (!data) return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" });
|
||||
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
if (!allowedTypes.includes(data.mimetype)) {
|
||||
return reply.badRequest("Invalid file type. Allowed: JPEG, PNG, WebP, GIF");
|
||||
if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
|
||||
}
|
||||
|
||||
const ext = extname(data.filename) || ".jpg";
|
||||
const filename = `med-${idNum}-${Date.now()}${ext}`;
|
||||
const filepath = resolve(IMAGES_DIR, filename);
|
||||
let uploadBuffer: Buffer;
|
||||
try {
|
||||
uploadBuffer = await streamToBuffer(data.file);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") {
|
||||
return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await pipeline(data.file, createWriteStream(filepath));
|
||||
let filename: string;
|
||||
try {
|
||||
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `med-${idNum}`, uploadBuffer));
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
|
||||
}
|
||||
|
||||
// Delete old image if exists
|
||||
if (existing.imageUrl) {
|
||||
const oldPath = resolve(IMAGES_DIR, existing.imageUrl);
|
||||
if (existsSync(oldPath)) unlinkSync(oldPath);
|
||||
}
|
||||
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||
|
||||
await db
|
||||
.update(medications)
|
||||
@@ -758,10 +766,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
if (existing.imageUrl) {
|
||||
const filepath = resolve(IMAGES_DIR, existing.imageUrl);
|
||||
if (existsSync(filepath)) unlinkSync(filepath);
|
||||
}
|
||||
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||
|
||||
await db
|
||||
.update(medications)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { extname, resolve } from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function getThumbFilename(imageFilename: string): string {
|
||||
const ext = extname(imageFilename);
|
||||
const base = ext ? imageFilename.slice(0, -ext.length) : imageFilename;
|
||||
return `${base}-thumb.webp`;
|
||||
}
|
||||
|
||||
export function removeImageFiles(imagesDir: string, imageFilename: string): void {
|
||||
const fullPath = resolve(imagesDir, imageFilename);
|
||||
if (existsSync(fullPath)) unlinkSync(fullPath);
|
||||
|
||||
const thumbFilename = getThumbFilename(imageFilename);
|
||||
if (thumbFilename !== imageFilename) {
|
||||
const thumbPath = resolve(imagesDir, thumbFilename);
|
||||
if (existsSync(thumbPath)) unlinkSync(thumbPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalSize += buffer.length;
|
||||
if (totalSize > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
throw new Error("IMAGE_TOO_LARGE");
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export async function writeOptimizedImageSet(
|
||||
imagesDir: string,
|
||||
filePrefix: string,
|
||||
uploadBuffer: Buffer,
|
||||
options?: {
|
||||
maxEdgePx?: number;
|
||||
thumbSizePx?: number;
|
||||
fullQuality?: number;
|
||||
thumbQuality?: number;
|
||||
}
|
||||
): Promise<{ filename: string; thumbFilename: string }> {
|
||||
const maxEdgePx = options?.maxEdgePx ?? 1600;
|
||||
const thumbSizePx = options?.thumbSizePx ?? 96;
|
||||
const fullQuality = options?.fullQuality ?? 82;
|
||||
const thumbQuality = options?.thumbQuality ?? 76;
|
||||
|
||||
const filename = `${filePrefix}-${Date.now()}.webp`;
|
||||
const thumbFilename = getThumbFilename(filename);
|
||||
|
||||
const filepath = resolve(imagesDir, filename);
|
||||
const thumbFilepath = resolve(imagesDir, thumbFilename);
|
||||
|
||||
const optimizedBuffer = await sharp(uploadBuffer, { failOn: "error" })
|
||||
.rotate()
|
||||
.resize({ width: maxEdgePx, height: maxEdgePx, fit: "inside", withoutEnlargement: true })
|
||||
.webp({ quality: fullQuality })
|
||||
.toBuffer();
|
||||
|
||||
const thumbBuffer = await sharp(uploadBuffer, { failOn: "error" })
|
||||
.rotate()
|
||||
.resize({ width: thumbSizePx, height: thumbSizePx, fit: "cover", position: "attention" })
|
||||
.webp({ quality: thumbQuality })
|
||||
.toBuffer();
|
||||
|
||||
await writeFile(filepath, optimizedBuffer);
|
||||
await writeFile(thumbFilepath, thumbBuffer);
|
||||
|
||||
return { filename, thumbFilename };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useR
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { withCorrelation } from "../utils/correlation";
|
||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||
import { log } from "../utils/logger";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { PasswordInput } from "./PasswordInput";
|
||||
@@ -275,8 +276,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Upload failed" }));
|
||||
throw new Error(err.error || "Upload failed");
|
||||
let code = "UNKNOWN";
|
||||
try {
|
||||
const body = (await res.json()) as { code?: string };
|
||||
if (typeof body?.code === "string" && body.code.trim().length > 0) {
|
||||
code = body.code;
|
||||
}
|
||||
} catch {
|
||||
// No JSON body
|
||||
}
|
||||
throw new Error(code);
|
||||
}
|
||||
|
||||
await refreshUser();
|
||||
@@ -613,6 +622,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [avatarError, setAvatarError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -624,14 +634,20 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
setAvatarError(t("form.imageUploadErrors.tooLarge"));
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarLoading(true);
|
||||
setError("");
|
||||
setAvatarError("");
|
||||
try {
|
||||
await uploadAvatar(file);
|
||||
setSuccess(t("auth.avatarUpdated", "Avatar updated"));
|
||||
setAvatarError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload failed");
|
||||
const code = err instanceof Error ? err.message : "UNKNOWN";
|
||||
setAvatarError(resolveImageUploadError(code, t));
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
@@ -640,12 +656,13 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
async function handleAvatarDelete() {
|
||||
setAvatarLoading(true);
|
||||
setError("");
|
||||
setAvatarError("");
|
||||
try {
|
||||
await deleteAvatar();
|
||||
setSuccess(t("auth.avatarRemoved", "Avatar removed"));
|
||||
setAvatarError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Delete failed");
|
||||
const code = err instanceof Error ? err.message : "UNKNOWN";
|
||||
setAvatarError(resolveImageUploadError(code, t));
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
}
|
||||
@@ -740,6 +757,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
<span className="profile-username">{user.username}</span>
|
||||
{avatarError && <span className="field-error">{avatarError}</span>}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpdate} className="profile-form">
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// MedicationAvatar Component
|
||||
// =============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type MedicationAvatarProps = {
|
||||
name: string;
|
||||
imageUrl?: string | null;
|
||||
@@ -9,6 +11,12 @@ export type MedicationAvatarProps = {
|
||||
};
|
||||
|
||||
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
|
||||
const [thumbFailed, setThumbFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setThumbFailed(false);
|
||||
}, [imageUrl]);
|
||||
|
||||
const initials =
|
||||
name
|
||||
.split(" ")
|
||||
@@ -19,7 +27,26 @@ export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvat
|
||||
const sizeClass = `med-avatar med-avatar-${size}`;
|
||||
|
||||
if (imageUrl) {
|
||||
return <img src={`/api/images/${imageUrl}`} alt={name} className={sizeClass} />;
|
||||
const normalizedImageUrl = imageUrl.toLowerCase();
|
||||
const shouldUseThumbFirst = normalizedImageUrl.endsWith(".webp");
|
||||
const extIndex = imageUrl.lastIndexOf(".");
|
||||
const baseName = extIndex > 0 ? imageUrl.slice(0, extIndex) : imageUrl;
|
||||
const thumbSrc = `/api/images/${baseName}-thumb.webp`;
|
||||
const fullSrc = `/api/images/${imageUrl}`;
|
||||
const resolvedSrc = shouldUseThumbFirst && !thumbFailed ? thumbSrc : fullSrc;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={name}
|
||||
className={sizeClass}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => {
|
||||
if (shouldUseThumbFirst && !thumbFailed) setThumbFailed(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface MobileEditModalProps {
|
||||
meds: Medication[];
|
||||
onUploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||
onDeleteMedImage: (medId: number) => Promise<void>;
|
||||
imageUploadError: string | null;
|
||||
// Actions
|
||||
onClose: () => void;
|
||||
onResetForm: () => void;
|
||||
@@ -105,6 +106,7 @@ export function MobileEditModal({
|
||||
meds,
|
||||
onUploadMedImage,
|
||||
onDeleteMedImage,
|
||||
imageUploadError,
|
||||
onClose,
|
||||
_onResetForm,
|
||||
onSaveMedication,
|
||||
@@ -454,9 +456,14 @@ export function MobileEditModal({
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void onUploadMedImage(editingId, file);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{imageUploadError && <span className="field-error">{imageUploadError}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -50,13 +50,33 @@ export function useMedications(): UseMedicationsReturn {
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.ok) {
|
||||
loadMeds();
|
||||
if (!res.ok) {
|
||||
let code = "UNKNOWN";
|
||||
try {
|
||||
const errorBody = (await res.json()) as { code?: string };
|
||||
if (typeof errorBody?.code === "string" && errorBody.code.trim().length > 0) {
|
||||
code = errorBody.code;
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback code when backend response has no JSON body.
|
||||
}
|
||||
throw new Error(code);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
loadMeds();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
// Network failures (fetch itself throws) produce browser-specific messages.
|
||||
// Normalise to NETWORK_ERROR code so the UI can map to a translated string.
|
||||
if (error.message === "Failed to fetch" || error.message.startsWith("NetworkError")) {
|
||||
throw new Error("NETWORK_ERROR");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error("UNKNOWN");
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
setUploadingImage(false);
|
||||
},
|
||||
[loadMeds]
|
||||
);
|
||||
|
||||
@@ -183,6 +183,13 @@
|
||||
"notes": "Notizen",
|
||||
"medicationImage": "Medikamentenbild",
|
||||
"removeImage": "Bild entfernen",
|
||||
"imageUploadErrors": {
|
||||
"tooLarge": "Das Bild ist zu groß. Die maximale Upload-Größe beträgt 10 MB.",
|
||||
"invalidType": "Ungültiger Dateityp. Erlaubte Formate: JPEG, PNG, WebP, GIF.",
|
||||
"invalidImage": "Ungültige oder nicht unterstützte Bilddatei.",
|
||||
"noFile": "Es wurde keine Datei zum Hochladen ausgewählt.",
|
||||
"generic": "Bild-Upload fehlgeschlagen. Bitte versuche es erneut."
|
||||
},
|
||||
"placeholders": {
|
||||
"commercial": "z.B. Ozempic",
|
||||
"generic": "z.B. Semaglutid (optional)",
|
||||
|
||||
@@ -183,6 +183,13 @@
|
||||
"notes": "Notes",
|
||||
"medicationImage": "Medication Image",
|
||||
"removeImage": "Remove Image",
|
||||
"imageUploadErrors": {
|
||||
"tooLarge": "Image is too large. Maximum upload size is 10 MB.",
|
||||
"invalidType": "Invalid file type. Allowed formats: JPEG, PNG, WebP, GIF.",
|
||||
"invalidImage": "Invalid or unsupported image file.",
|
||||
"noFile": "No file was selected for upload.",
|
||||
"generic": "Image upload failed. Please try again."
|
||||
},
|
||||
"placeholders": {
|
||||
"commercial": "e.g. Ozempic",
|
||||
"generic": "e.g. Semaglutide (optional)",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "..
|
||||
import type { DoseUnit, Medication } from "../types";
|
||||
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
|
||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||
import { log } from "../utils/logger";
|
||||
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
@@ -51,6 +52,7 @@ export function MedicationsPage() {
|
||||
setForm,
|
||||
setOriginalForm,
|
||||
editingId,
|
||||
setEditingId,
|
||||
formSaved,
|
||||
setFormSaved,
|
||||
formChanged,
|
||||
@@ -138,6 +140,32 @@ export function MedicationsPage() {
|
||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<Medication | null>(null);
|
||||
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
|
||||
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
|
||||
|
||||
const handlePendingMedicationImageSelection = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
setImageUploadError(t("form.imageUploadErrors.tooLarge"));
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setImageUploadError(null);
|
||||
setPendingImage(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setImageUploadError(null);
|
||||
}, [editingId]);
|
||||
const [showObsolete, setShowObsolete] = useState(true);
|
||||
const [readOnlyView, setReadOnlyView] = useState(false);
|
||||
const [showReportModal, setShowReportModal] = useState(false);
|
||||
@@ -173,6 +201,42 @@ export function MedicationsPage() {
|
||||
void loadAllMeds();
|
||||
}, [loadAllMeds]);
|
||||
|
||||
const tryUploadMedImage = useCallback(
|
||||
async (medId: number, file: File) => {
|
||||
setImageUploadError(null);
|
||||
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
setImageUploadError(t("form.imageUploadErrors.tooLarge"));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await uploadMedImage(medId, file);
|
||||
void loadAllMeds();
|
||||
setImageUploadError(null);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "UNKNOWN";
|
||||
setImageUploadError(resolveImageUploadError(code, t));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t, uploadMedImage, loadAllMeds]
|
||||
);
|
||||
|
||||
const handleUploadMedImage = useCallback(
|
||||
async (medId: number, file: File) => {
|
||||
await tryUploadMedImage(medId, file);
|
||||
},
|
||||
[tryUploadMedImage]
|
||||
);
|
||||
|
||||
const handleDeleteMedImage = useCallback(
|
||||
async (medId: number) => {
|
||||
await deleteMedImage(medId);
|
||||
void loadAllMeds();
|
||||
},
|
||||
[deleteMedImage, loadAllMeds]
|
||||
);
|
||||
|
||||
// Calculate total tablets
|
||||
const totalTablets = useMemo(() => {
|
||||
if (form.packageType === "bottle") {
|
||||
@@ -467,7 +531,19 @@ export function MedicationsPage() {
|
||||
|
||||
// Upload image if pending (for new medications)
|
||||
if (!editingId && pendingImage && saved.id) {
|
||||
await uploadMedImage(saved.id, pendingImage);
|
||||
const uploaded = await tryUploadMedImage(saved.id, pendingImage);
|
||||
if (!uploaded) {
|
||||
// Keep user in edit mode so upload error stays visible and retry is immediate.
|
||||
setEditingId(saved.id);
|
||||
setFormSaved(true);
|
||||
setOriginalForm(form);
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
loadMeds();
|
||||
void loadAllMeds();
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
}
|
||||
@@ -608,6 +684,13 @@ export function MedicationsPage() {
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [showEditModal, closeEditModal]);
|
||||
|
||||
function scrollToTopForDesktopEdit() {
|
||||
if (window.innerWidth <= 768) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
}
|
||||
|
||||
function handleEditClick(med: Medication) {
|
||||
if (formChanged) {
|
||||
pendingActionRef.current = () => {
|
||||
@@ -615,6 +698,7 @@ export function MedicationsPage() {
|
||||
setReadOnlyView(false);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
};
|
||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||
setShowUnsavedConfirm(true);
|
||||
@@ -625,6 +709,7 @@ export function MedicationsPage() {
|
||||
setActiveTab("general");
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
}
|
||||
|
||||
function handleViewClick(med: Medication) {
|
||||
@@ -847,8 +932,10 @@ export function MedicationsPage() {
|
||||
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} ·{" "}
|
||||
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
||||
{t("form.blisters.from")} {formatDateTime(s.start)}
|
||||
{"takenBy" in s && s.takenBy && <span className="blister-taken-by"> · {s.takenBy}</span>}
|
||||
{"intakeRemindersEnabled" in s && s.intakeRemindersEnabled && (
|
||||
{"takenBy" in s && (s as import("../types").Intake).takenBy && (
|
||||
<span className="blister-taken-by"> · {(s as import("../types").Intake).takenBy}</span>
|
||||
)}
|
||||
{"intakeRemindersEnabled" in s && (s as import("../types").Intake).intakeRemindersEnabled && (
|
||||
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
|
||||
{" "}
|
||||
<Bell size={12} aria-hidden="true" />
|
||||
@@ -1050,7 +1137,9 @@ export function MedicationsPage() {
|
||||
<select
|
||||
className="package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => handleValueChange("packageType", e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleValueChange("packageType", e.target.value as import("../types").PackageType)
|
||||
}
|
||||
>
|
||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||
@@ -1112,7 +1201,7 @@ export function MedicationsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="danger icon-only tooltip-trigger"
|
||||
onClick={() => deleteMedImage(editingId)}
|
||||
onClick={() => handleDeleteMedImage(editingId)}
|
||||
aria-label={t("form.removeImage")}
|
||||
data-tooltip={t("form.removeImage")}
|
||||
>
|
||||
@@ -1125,7 +1214,11 @@ export function MedicationsPage() {
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void tryUploadMedImage(editingId, file);
|
||||
}}
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
);
|
||||
@@ -1153,18 +1246,11 @@ export function MedicationsPage() {
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setPendingImage(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
onChange={handlePendingMedicationImageSelection}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{imageUploadError && <span className="field-error">{imageUploadError}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* end general tab */}
|
||||
@@ -1507,8 +1593,9 @@ export function MedicationsPage() {
|
||||
onRemoveIntake={removeIntake}
|
||||
onHandleValueChange={handleValueChange}
|
||||
meds={allMeds}
|
||||
onUploadMedImage={uploadMedImage}
|
||||
onDeleteMedImage={deleteMedImage}
|
||||
onUploadMedImage={handleUploadMedImage}
|
||||
onDeleteMedImage={handleDeleteMedImage}
|
||||
imageUploadError={imageUploadError}
|
||||
onClose={() => {
|
||||
closeEditModal();
|
||||
}}
|
||||
|
||||
@@ -896,7 +896,7 @@ describe("AuthProvider methods", () => {
|
||||
});
|
||||
|
||||
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
|
||||
await expect(result.current.uploadAvatar(file)).rejects.toThrow("Upload failed");
|
||||
await expect(result.current.uploadAvatar(file)).rejects.toThrow("UNKNOWN");
|
||||
});
|
||||
|
||||
it("deleteAvatar succeeds and refreshes user", async () => {
|
||||
|
||||
@@ -170,9 +170,11 @@ describe("useMedications", () => {
|
||||
const { result } = renderHook(() => useMedications());
|
||||
const file = new File(["test"], "test.jpg", { type: "image/jpeg" });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadMedImage(1, file);
|
||||
});
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.uploadMedImage(1, file);
|
||||
})
|
||||
).rejects.toThrow("Upload failed");
|
||||
|
||||
expect(result.current.uploadingImage).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Error codes returned by the backend image upload endpoints. */
|
||||
const IMAGE_ERROR_CODE_MAP: Record<string, string> = {
|
||||
IMAGE_TOO_LARGE: "form.imageUploadErrors.tooLarge",
|
||||
INVALID_TYPE: "form.imageUploadErrors.invalidType",
|
||||
INVALID_IMAGE: "form.imageUploadErrors.invalidImage",
|
||||
NO_FILE: "form.imageUploadErrors.noFile",
|
||||
NETWORK_ERROR: "common.networkError",
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps a backend image-upload error code to a translated user-facing message.
|
||||
* Falls back to a generic error when the code is unknown.
|
||||
*/
|
||||
export function resolveImageUploadError(code: string, t: TFunction): string {
|
||||
const normalized = normalizeErrorCode(code);
|
||||
const key = IMAGE_ERROR_CODE_MAP[normalized];
|
||||
return key ? t(key) : t("form.imageUploadErrors.generic");
|
||||
}
|
||||
|
||||
/** Browser network errors are not error codes — normalise them. */
|
||||
function normalizeErrorCode(code: string): string {
|
||||
if (code === "Failed to fetch" || code.startsWith("NetworkError")) {
|
||||
return "NETWORK_ERROR";
|
||||
}
|
||||
return code;
|
||||
}
|
||||
Reference in New Issue
Block a user