diff --git a/backend/package-lock.json b/backend/package-lock.json index bffe26c..4184357 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "bcryptjs": "^3.0.2", "dotenv": "^16.6.1", "fastify": "^5.6.1", + "sharp": "^0.34.5", "zod": "^4.1.5" }, "devDependencies": { @@ -25,6 +26,16 @@ "vitest": "^5.0.1" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -642,6 +653,471 @@ "toad-cache": "^3.7.0" } }, + "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.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/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -891,9 +1367,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -912,9 +1385,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -933,9 +1403,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -954,9 +1421,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -975,9 +1439,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -996,9 +1457,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1362,9 +1820,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8" } @@ -1990,9 +2446,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2015,9 +2468,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2040,9 +2490,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2065,9 +2512,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2587,6 +3031,50 @@ "version": "2.7.2", "license": "MIT" }, + "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/siginfo": { "version": "2.0.0", "dev": true, @@ -2694,6 +3182,13 @@ "node": ">=12" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.23.15", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", diff --git a/backend/package.json b/backend/package.json index 5681177..27d4f9f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "bcryptjs": "^3.0.2", "dotenv": "^16.6.1", "fastify": "^5.6.1", + "sharp": "^0.34.5", "zod": "^4.1.5" }, "devDependencies": { diff --git a/backend/src/lib/media-files.ts b/backend/src/lib/media-files.ts new file mode 100644 index 0000000..fe1dde3 --- /dev/null +++ b/backend/src/lib/media-files.ts @@ -0,0 +1,220 @@ +import { unlink } from 'node:fs/promises' +import path from 'node:path' + +import type { FastifyReply } from 'fastify' +import sharp from 'sharp' + +// Fichiers envoyés : le type vient des premiers octets, jamais de ce que déclare le navigateur. +// Un SVG, du HTML ou du XML servi depuis l'origine de l'application exécute du script (vol de session). + +export type BinaryMediaType = 'photo' | 'video' | 'audio' | 'document' | 'geojson' | 'gpx' + +export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const + +const ALLOWED_BY_TYPE: Record = { + photo: IMAGE_MIME_TYPES, + video: ['video/mp4', 'video/quicktime', 'video/webm'], + audio: ['audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/wav', 'audio/ogg', 'audio/webm', 'audio/flac'], + document: [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain', + ], + geojson: ['application/geo+json'], + gpx: ['application/gpx+xml'], +} + +// Affichés tels quels par le navigateur ; tout le reste part en téléchargement +const INLINE_MIME_TYPES = new Set([ + ...IMAGE_MIME_TYPES, + ...ALLOWED_BY_TYPE.video, + ...ALLOWED_BY_TYPE.audio, + 'application/pdf', +]) + +// Texte que l'application lit elle-même (tracés, citations) : servi avec son type, mais en pièce jointe +const TEXT_MIME_TYPES = new Set(['text/plain', 'application/geo+json', 'application/gpx+xml']) + +const EXTENSION_BY_MIME: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'video/mp4': '.mp4', + 'video/quicktime': '.mov', + 'video/webm': '.webm', + 'audio/mpeg': '.mp3', + 'audio/mp4': '.m4a', + 'audio/aac': '.aac', + 'audio/wav': '.wav', + 'audio/ogg': '.ogg', + 'audio/webm': '.weba', + 'audio/flac': '.flac', + 'application/pdf': '.pdf', + 'application/msword': '.doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', + 'text/plain': '.txt', + 'application/geo+json': '.geojson', + 'application/gpx+xml': '.gpx', +} + +function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean { + if (buf.length < offset + bytes.length) return false + return bytes.every((byte, index) => buf[offset + index] === byte) +} + +function ascii(buf: Buffer, start: number, end: number): string { + return buf.subarray(start, end).toString('latin1') +} + +function isUtf8Text(buf: Buffer): boolean { + if (buf.includes(0)) return false + try { + new TextDecoder('utf-8', { fatal: true }).decode(buf) + return true + } catch { + return false + } +} + +// Type réel d'après la signature binaire. `declaredType` départage les conteneurs communs (MP4 audio ou vidéo). +export function sniffMimeType(buf: Buffer, declaredType: BinaryMediaType, fileName = ''): string | null { + if (startsWith(buf, [0xff, 0xd8, 0xff])) return 'image/jpeg' + if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png' + if (ascii(buf, 0, 6) === 'GIF87a' || ascii(buf, 0, 6) === 'GIF89a') return 'image/gif' + if (ascii(buf, 0, 4) === 'RIFF' && ascii(buf, 8, 12) === 'WEBP') return 'image/webp' + if (ascii(buf, 0, 4) === 'RIFF' && ascii(buf, 8, 12) === 'WAVE') return 'audio/wav' + if (ascii(buf, 0, 5) === '%PDF-') return 'application/pdf' + if (ascii(buf, 4, 8) === 'ftyp') { + const brand = ascii(buf, 8, 12) + if (brand === 'qt ') return 'video/quicktime' + if (declaredType === 'audio' || brand === 'M4A ' || brand === 'M4B ') return 'audio/mp4' + return 'video/mp4' + } + if (startsWith(buf, [0x1a, 0x45, 0xdf, 0xa3])) return declaredType === 'audio' ? 'audio/webm' : 'video/webm' + if (ascii(buf, 0, 4) === 'OggS') return 'audio/ogg' + if (ascii(buf, 0, 4) === 'fLaC') return 'audio/flac' + if (ascii(buf, 0, 3) === 'ID3') return 'audio/mpeg' + if (buf.length > 1 && buf[0] === 0xff && (buf[1] === 0xf1 || buf[1] === 0xf9)) return 'audio/aac' + if (buf.length > 1 && buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0) return 'audio/mpeg' + if (startsWith(buf, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])) return 'application/msword' + if (startsWith(buf, [0x50, 0x4b, 0x03, 0x04]) && fileName.toLowerCase().endsWith('.docx')) { + return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + } + + if (!isUtf8Text(buf)) return null + const text = buf.toString('utf8').replace(/^/, '').trimStart() + + if (declaredType === 'geojson') { + try { + const parsed = JSON.parse(text) + return parsed && typeof parsed === 'object' ? 'application/geo+json' : null + } catch { + return null + } + } + + if (declaredType === 'gpx') { + return /^(<\?xml[^>]*>\s*)?(\s*)*]/.test(text) ? 'application/gpx+xml' : null + } + + // Texte brut accepté comme document, sauf s'il ressemble à une page ou un balisage + if (declaredType === 'document' && !/^ { + if (mimeType === 'image/gif') return buf + + try { + const image = sharp(buf, { limitInputPixels: 50_000_000, failOn: 'error' }).rotate() + if (mimeType === 'image/jpeg') return await image.jpeg({ quality: 90, mozjpeg: true }).toBuffer() + if (mimeType === 'image/png') return await image.png().toBuffer() + if (mimeType === 'image/webp') return await image.webp({ quality: 90 }).toBuffer() + } catch { + throw new InvalidMediaError('invalid_image') + } + + throw new InvalidMediaError('invalid_image') +} + +// Vidéos et sons MP4/MOV de téléphone : la position (ISO 6709, ex. « +50.6300+003.0600/ ») vit dans +// la boîte `moov`. On remet ses chiffres à zéro sur place, sans toucher à la taille ni aux images. +export function scrubMp4Location(buf: Buffer): Buffer { + let offset = 0 + while (offset + 8 <= buf.length) { + let size = buf.readUInt32BE(offset) + const type = ascii(buf, offset + 4, offset + 8) + let header = 8 + if (size === 1) { + if (offset + 16 > buf.length) break + size = Number(buf.readBigUInt64BE(offset + 8)) + header = 16 + } else if (size === 0) { + size = buf.length - offset + } + if (size < header) break + + if (type === 'moov') { + const end = Math.min(offset + size, buf.length) + const region = buf.subarray(offset + header, end) + const text = region.toString('latin1') + const pattern = /[+-]\d{1,3}(?:\.\d+)?[+-]\d{1,3}(?:\.\d+)?(?:[+-]\d+(?:\.\d+)?)?(?:CRS[^/]*)?\//g + for (const match of text.matchAll(pattern)) { + const zeroed = match[0].replace(/\d/g, '0') + region.write(zeroed, match.index ?? 0, 'latin1') + } + } + + offset += size + } + return buf +} + +// En-têtes de diffusion d'un fichier envoyé, décidés d'après le type enregistré (y compris pour +// les fichiers envoyés avant ces contrôles, dont le type venait du navigateur). +export function applyMediaResponseHeaders(reply: FastifyReply, storedMimeType: string | null | undefined, downloadName: string): void { + const mimeType = (storedMimeType ?? '').toLowerCase() + reply.header('X-Content-Type-Options', 'nosniff') + reply.header('Cache-Control', 'private, max-age=300') + + if (INLINE_MIME_TYPES.has(mimeType)) { + reply.type(mimeType) + // Le lecteur PDF des navigateurs ne s'affiche pas dans un bac à sable + if (mimeType !== 'application/pdf') { + reply.header('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; sandbox") + } + return + } + + reply.type(TEXT_MIME_TYPES.has(mimeType) ? `${mimeType}; charset=utf-8` : 'application/octet-stream') + reply.header('Content-Security-Policy', "default-src 'none'; sandbox") + const safeName = downloadName.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 100) || 'fichier' + reply.header('Content-Disposition', `attachment; filename="${safeName}"`) +} + +// Efface un fichier du stockage, sans jamais sortir du dossier racine +export async function removeStoredFile(storageRoot: string, relativePath: string | null | undefined): Promise { + if (!relativePath || /^https?:\/\//.test(relativePath)) return + const absolutePath = path.resolve(storageRoot, relativePath) + if (!absolutePath.startsWith(storageRoot + path.sep)) return + try { + await unlink(absolutePath) + } catch { + // déjà absent + } +} diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 7c316a3..a387303 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -6,9 +6,20 @@ import type { FastifyPluginAsync, FastifyRequest } from 'fastify' import { z } from 'zod' import { env } from '../config/env.js' -import { isUserTokenRevoked } from '../lib/token-revocation.js' +import { + applyMediaResponseHeaders, + extensionForMime, + InvalidMediaError, + isMimeAllowedForType, + removeStoredFile, + sanitizeImage, + scrubMp4Location, + sniffMimeType, + type BinaryMediaType, +} from '../lib/media-files.js' import { syncPersonMediaOrder } from '../lib/media-order.js' -import type { AnyJwtPayload, MembershipRole } from '../types/auth.js' +import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js' +import type { MembershipRole } from '../types/auth.js' import { ContributionRouteError, resolveSubmissionContext } from './contributions.js' const MAX_IMAGE_BYTES = 5 * 1024 * 1024 @@ -102,76 +113,52 @@ function normalizeOptionalText(value: string | null | undefined): string | null return normalized.length > 0 ? normalized : null } -function inferFileExtension(fileName: string, mimeType: string): string { - const rawExt = path.extname(fileName).toLowerCase().replace(/[^.a-z0-9]/g, '') - if (rawExt.length >= 2 && rawExt.length <= 8) { - return rawExt +// Fichier décodé, reconnu à ses octets et nettoyé (photos sans métadonnées, vidéos sans position). +// Le type déclaré par le navigateur n'est plus qu'un indice pour les conteneurs ambigus. +async function prepareUploadedFile( + buffer: Buffer, + type: BinaryMediaType, + fileName: string, +): Promise<{ buffer: Buffer; mimeType: string; extension: string }> { + const mimeType = sniffMimeType(buffer, type, fileName) + if (!mimeType || !isMimeAllowedForType(type, mimeType)) { + throw new InvalidMediaError('invalid_media_mime_type') } - const map: Record = { - 'image/jpeg': '.jpg', - 'image/png': '.png', - 'image/webp': '.webp', - 'image/gif': '.gif', - 'image/svg+xml': '.svg', - 'video/mp4': '.mp4', - 'audio/mpeg': '.mp3', - 'audio/wav': '.wav', - 'application/pdf': '.pdf', - 'application/geo+json': '.geojson', - 'application/gpx+xml': '.gpx', - 'application/xml': '.xml', - 'text/xml': '.xml', - 'application/json': '.json', - 'text/plain': '.txt', - } - - return map[mimeType] || '.bin' -} - -function isMimeTypeAllowedForMediaType(type: 'photo' | 'video' | 'audio' | 'document' | 'geojson' | 'gpx', mimeType: string): boolean { - const normalizedMimeType = mimeType.toLowerCase() - + let cleaned = buffer if (type === 'photo') { - return normalizedMimeType.startsWith('image/') + cleaned = await sanitizeImage(buffer, mimeType) + } else if (mimeType === 'video/mp4' || mimeType === 'video/quicktime' || mimeType === 'audio/mp4') { + cleaned = scrubMp4Location(Buffer.from(buffer)) } - if (type === 'video') { - return normalizedMimeType.startsWith('video/') - } - - if (type === 'audio') { - return normalizedMimeType.startsWith('audio/') - } - - if (type === 'geojson') { - return ( - normalizedMimeType === 'application/geo+json' || - normalizedMimeType === 'application/json' || - normalizedMimeType === 'text/geojson' || - normalizedMimeType === 'text/plain' - ) - } - - if (type === 'gpx') { - return ( - normalizedMimeType === 'application/gpx+xml' || - normalizedMimeType === 'application/xml' || - normalizedMimeType === 'text/xml' || - normalizedMimeType === 'text/plain' || - normalizedMimeType === 'application/octet-stream' - ) - } - - return ( - normalizedMimeType === 'application/pdf' || - normalizedMimeType === 'application/msword' || - normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || - normalizedMimeType === 'text/plain' || - normalizedMimeType === 'application/octet-stream' - ) + return { buffer: cleaned, mimeType, extension: extensionForMime(mimeType) } } +// Place disque d'un arbre : total, et part des envois en attente de relecture (limite l'envoi en boucle) +async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string, incomingBytes: number, isPending: boolean): Promise { + const MB = 1024 * 1024 + const total = await app.prisma.mediaItem.aggregate({ + where: { treeId, deletedAt: null }, + _sum: { sizeBytes: true }, + }) + if ((total._sum.sizeBytes ?? 0) + incomingBytes > env.MEDIA_QUOTA_PER_TREE_MB * MB) { + return true + } + + if (!isPending) { + return false + } + + const pending = await app.prisma.mediaItem.aggregate({ + where: { treeId, status: 'pending', deletedAt: null }, + _sum: { sizeBytes: true }, + }) + return (pending._sum.sizeBytes ?? 0) + incomingBytes > env.MEDIA_PENDING_QUOTA_PER_TREE_MB * MB +} + +const UPLOAD_RATE_LIMIT = { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } } + async function requireAdminMembership(app: FastifyRequest['server'], userId: string, treeId: string) { const membership = await app.prisma.treeMembership.findUnique({ where: { @@ -196,148 +183,17 @@ async function requireAdminMembership(app: FastifyRequest['server'], userId: str return membership } -async function assertTreeReadableByActor(app: FastifyRequest['server'], treeId: string, actor: FastifyRequest['actor']): Promise { - if (!actor) { - return false +// Session (en-tête) ou jeton médias (adresse d'une image). La session du compte n'est plus acceptée +// dans l'adresse : elle finissait dans les journaux, l'historique et les liens copiés. +async function canReadMedia(app: FastifyRequest['server'], treeId: string, request: FastifyRequest, token: string | undefined): Promise { + if (await canReadTree(app, treeId, request.actor)) { + return true } - - if (actor.kind === 'user') { - const membership = await app.prisma.treeMembership.findUnique({ - where: { - treeId_userId: { - treeId, - userId: actor.userId, - }, - }, - include: { - tree: { - select: { - deletedAt: true, - }, - }, - }, - }) - - if (membership && !membership.tree.deletedAt) { - return true - } - - const shared = await app.prisma.userTreeAccess.findUnique({ - where: { - treeId_userId: { - treeId, - userId: actor.userId, - }, - }, - include: { - tree: { - select: { - deletedAt: true, - }, - }, - }, - }) - - return Boolean(shared && !shared.tree.deletedAt) - } - - if (actor.treeId !== treeId) { - return false - } - - const tree = await app.prisma.tree.findFirst({ - where: { - id: treeId, - deletedAt: null, - }, - select: { - id: true, - }, - }) - - return Boolean(tree) -} - -async function assertTreeReadableByQueryToken(app: FastifyRequest['server'], treeId: string, token: string): Promise { - let payload: AnyJwtPayload - - try { - payload = await app.jwt.verify(token) - } catch { - return false - } - - if (payload.kind === 'user') { - if (isUserTokenRevoked(payload.jti)) { - return false - } - - const membership = await app.prisma.treeMembership.findUnique({ - where: { - treeId_userId: { - treeId, - userId: payload.userId, - }, - }, - include: { - tree: { - select: { - deletedAt: true, - }, - }, - }, - }) - - if (membership && !membership.tree.deletedAt) { - return true - } - - const shared = await app.prisma.userTreeAccess.findUnique({ - where: { - treeId_userId: { - treeId, - userId: payload.userId, - }, - }, - include: { - tree: { - select: { - deletedAt: true, - }, - }, - }, - }) - - return Boolean(shared && !shared.tree.deletedAt) - } - - if (payload.treeId !== treeId) { - return false - } - - const accessState = await app.prisma.treeAccessPasswords.findUnique({ - where: { - treeId, - }, - select: { - updatedAt: true, - tree: { - select: { - deletedAt: true, - }, - }, - }, - }) - - if (!accessState || accessState.tree.deletedAt) { - return false - } - - return accessState.updatedAt.getTime() === payload.accessVersion + return token ? canReadTreeWithMediaToken(app, treeId, token) : false } export const mediaRoutes: FastifyPluginAsync = async (app) => { - app.post('/trees/:id/persons/:personId/media', async (request, reply) => { + app.post('/trees/:id/persons/:personId/media', UPLOAD_RATE_LIMIT, async (request, reply) => { const actor = request.actor if (!actor) { return reply.code(401).send({ error: 'authentication_required' }) @@ -386,6 +242,48 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: 'person_not_found' }) } + // Fichier reconnu, nettoyé et compté AVANT toute écriture : un envoi refusé ne laisse ni contribution + // vide en attente ni fichier sur le disque + let prepared: { buffer: Buffer; mimeType: string; extension: string } | null = null + let uploadFileName = '' + let uploadCaption: string | null = null + if (!('youtubeUrl' in payload.data)) { + if (payload.data.type === 'citation') { + const citationText = payload.data.caption.trim() + if (!citationText) { + return reply.code(400).send({ error: 'citation_text_required' }) + } + prepared = { buffer: Buffer.from(citationText, 'utf8'), mimeType: 'text/plain', extension: '.txt' } + uploadFileName = 'citation.txt' + uploadCaption = citationText + } else { + const decoded = Buffer.from(stripDataUrlPrefix(payload.data.dataBase64), 'base64') + if (!decoded.length) { + return reply.code(400).send({ error: 'empty_media_file' }) + } + if (decoded.length !== payload.data.sizeBytes) { + return reply.code(400).send({ error: 'size_mismatch' }) + } + if (payload.data.type === 'photo' && decoded.length > MAX_IMAGE_BYTES) { + return reply.code(400).send({ error: 'image_too_large' }) + } + try { + prepared = await prepareUploadedFile(decoded, payload.data.type, payload.data.fileName) + } catch (error) { + if (error instanceof InvalidMediaError) { + return reply.code(400).send({ error: 'invalid_media_mime_type' }) + } + throw error + } + uploadFileName = payload.data.fileName + uploadCaption = payload.data.caption ?? null + } + + if (await exceedsStorageQuota(app, params.data.id, prepared.buffer.length, isPending)) { + return reply.code(413).send({ error: 'storage_quota_exceeded' }) + } + } + // Une contribution d'une ligne, créée avant le média pour qu'il la référence const now = new Date() const contributionSession = contribution @@ -465,66 +363,22 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(201).send({ media, status: media.status }) } - let fileBuffer: Buffer - let normalizedMimeType: string - let normalizedFileName: string - let normalizedSizeBytes: number - let normalizedCaption: string | null - const normalizedSource = normalizeOptionalText(payload.data.source) - - if (payload.data.type === 'citation') { - const citationText = payload.data.caption.trim() - if (!citationText) { - return reply.code(400).send({ error: 'citation_text_required' }) - } - - fileBuffer = Buffer.from(citationText, 'utf8') - normalizedMimeType = 'text/plain' - normalizedFileName = 'citation.txt' - normalizedSizeBytes = fileBuffer.length - normalizedCaption = citationText - } else { - if (!isMimeTypeAllowedForMediaType(payload.data.type, payload.data.mimeType)) { - return reply.code(400).send({ error: 'invalid_media_mime_type' }) - } - - const encoded = stripDataUrlPrefix(payload.data.dataBase64) - try { - fileBuffer = Buffer.from(encoded, 'base64') - } catch { - return reply.code(400).send({ error: 'invalid_base64_data' }) - } - - if (!fileBuffer.length) { - return reply.code(400).send({ error: 'empty_media_file' }) - } - - if (fileBuffer.length !== payload.data.sizeBytes) { - return reply.code(400).send({ error: 'size_mismatch' }) - } - - if (payload.data.type === 'photo' && fileBuffer.length > MAX_IMAGE_BYTES) { - return reply.code(400).send({ error: 'image_too_large' }) - } - - normalizedMimeType = payload.data.mimeType - normalizedFileName = payload.data.fileName - normalizedSizeBytes = payload.data.sizeBytes - normalizedCaption = payload.data.caption ?? null + if (!prepared) { + return reply.code(400).send({ error: 'invalid_payload' }) } - const extension = inferFileExtension(normalizedFileName, normalizedMimeType) + const normalizedSource = normalizeOptionalText(payload.data.source) const mediaId = randomUUID() - const relativePath = path.posix.join(params.data.id, params.data.personId, `${mediaId}${extension}`) + const relativePath = path.posix.join(params.data.id, params.data.personId, `${mediaId}${prepared.extension}`) const storageRoot = getStorageRoot() const absolutePath = path.resolve(storageRoot, relativePath) - if (!absolutePath.startsWith(storageRoot)) { + if (!absolutePath.startsWith(storageRoot + path.sep)) { return reply.code(400).send({ error: 'invalid_media_path' }) } await mkdir(path.dirname(absolutePath), { recursive: true }) - await writeFile(absolutePath, fileBuffer) + await writeFile(absolutePath, prepared.buffer) const mediaCount = await app.prisma.mediaItem.count({ where: { @@ -541,11 +395,11 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { treeId: params.data.id, personId: params.data.personId, type: payload.data.type, - mimeType: normalizedMimeType, + mimeType: prepared.mimeType, filePath: relativePath, - caption: normalizedCaption, + caption: uploadCaption, source: normalizedSource, - sizeBytes: normalizedSizeBytes, + sizeBytes: prepared.buffer.length, displayOrder: isPending ? 0 : mediaCount + 1, isFeatured: !isPending && mediaCount < 3, uploadedBy: uploaderUserId, @@ -558,7 +412,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { await syncPersonMediaOrder(app.prisma, params.data.id, params.data.personId) } - await recordContributionChange(mediaId, { type: payload.data.type, mimeType: normalizedMimeType, caption: normalizedCaption }) + await recordContributionChange(mediaId, { type: payload.data.type, mimeType: prepared.mimeType, caption: uploadCaption }) await app.prisma.auditLog.create({ data: { @@ -570,8 +424,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { payloadJson: { personId: params.data.personId, type: payload.data.type, - fileName: normalizedFileName, - sizeBytes: normalizedSizeBytes, + fileName: uploadFileName, + sizeBytes: prepared.buffer.length, }, }, }) @@ -607,6 +461,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { }, select: { id: true, + avatarPath: true, }, }) @@ -614,18 +469,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: 'person_not_found' }) } - if (!payload.data.mimeType.startsWith('image/')) { - return reply.code(400).send({ error: 'invalid_avatar_mime_type' }) - } - - const encoded = stripDataUrlPrefix(payload.data.dataBase64) - let fileBuffer: Buffer - - try { - fileBuffer = Buffer.from(encoded, 'base64') - } catch { - return reply.code(400).send({ error: 'invalid_base64_data' }) - } + const fileBuffer = Buffer.from(stripDataUrlPrefix(payload.data.dataBase64), 'base64') if (!fileBuffer.length) { return reply.code(400).send({ error: 'empty_media_file' }) @@ -639,21 +483,30 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'image_too_large' }) } - const extension = inferFileExtension(payload.data.fileName, payload.data.mimeType) + let prepared: Awaited> + try { + prepared = await prepareUploadedFile(fileBuffer, 'photo', payload.data.fileName) + } catch (error) { + if (error instanceof InvalidMediaError) { + return reply.code(400).send({ error: 'invalid_avatar_mime_type' }) + } + throw error + } + const relativePath = path.posix.join( params.data.id, params.data.personId, - `avatar-${randomUUID()}${extension}`, + `avatar-${randomUUID()}${prepared.extension}`, ) const storageRoot = getStorageRoot() const absolutePath = path.resolve(storageRoot, relativePath) - if (!absolutePath.startsWith(storageRoot)) { + if (!absolutePath.startsWith(storageRoot + path.sep)) { return reply.code(400).send({ error: 'invalid_media_path' }) } await mkdir(path.dirname(absolutePath), { recursive: true }) - await writeFile(absolutePath, fileBuffer) + await writeFile(absolutePath, prepared.buffer) await app.prisma.person.updateMany({ where: { @@ -663,11 +516,14 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { }, data: { avatarPath: relativePath, - avatarMimeType: payload.data.mimeType, + avatarMimeType: prepared.mimeType, updatedBy: request.actor.userId, }, }) + // L'ancien portrait ne reste pas sur le disque + await removeStoredFile(storageRoot, person.avatarPath) + await app.prisma.auditLog.create({ data: { treeId: params.data.id, @@ -697,6 +553,11 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(403).send({ error: 'forbidden' }) } + const person = await app.prisma.person.findFirst({ + where: { id: params.data.personId, treeId: params.data.id, deletedAt: null }, + select: { avatarPath: true }, + }) + const updated = await app.prisma.person.updateMany({ where: { id: params.data.personId, @@ -714,6 +575,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: 'person_not_found' }) } + await removeStoredFile(getStorageRoot(), person?.avatarPath) + await app.prisma.auditLog.create({ data: { treeId: params.data.id, @@ -829,6 +692,11 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(403).send({ error: 'forbidden' }) } + const existingMedia = await app.prisma.mediaItem.findFirst({ + where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null }, + select: { filePath: true }, + }) + const updateResult = await app.prisma.mediaItem.updateMany({ where: { id: params.data.mediaId, @@ -845,6 +713,9 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: 'media_not_found' }) } + // Supprimé = effacé du disque (la ligne reste pour le journal) + await removeStoredFile(getStorageRoot(), existingMedia?.filePath) + await syncPersonMediaOrder(app.prisma, params.data.id, params.data.personId) await app.prisma.auditLog.create({ @@ -875,12 +746,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_query' }) } - const readableByActor = await assertTreeReadableByActor(app, params.data.id, request.actor) - const readableByQueryToken = query.data.token - ? await assertTreeReadableByQueryToken(app, params.data.id, query.data.token) - : false - - if (!readableByActor && !readableByQueryToken) { + if (!(await canReadMedia(app, params.data.id, request, query.data.token))) { return reply.code(401).send({ error: 'authentication_required' }) } @@ -902,7 +768,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { const storageRoot = getStorageRoot() const absolutePath = path.resolve(storageRoot, person.avatarPath) - if (!absolutePath.startsWith(storageRoot)) { + if (!absolutePath.startsWith(storageRoot + path.sep)) { return reply.code(400).send({ error: 'invalid_media_path' }) } @@ -913,8 +779,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: 'media_file_missing' }) } - reply.header('Cache-Control', 'private, max-age=300') - reply.type(person.avatarMimeType || 'application/octet-stream') + applyMediaResponseHeaders(reply, person.avatarMimeType, 'portrait') return reply.send(fileBuffer) }) @@ -930,12 +795,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_query' }) } - const readableByActor = await assertTreeReadableByActor(app, params.data.id, request.actor) - const readableByQueryToken = query.data.token - ? await assertTreeReadableByQueryToken(app, params.data.id, query.data.token) - : false - - if (!readableByActor && !readableByQueryToken) { + if (!(await canReadMedia(app, params.data.id, request, query.data.token))) { return reply.code(401).send({ error: 'authentication_required' }) } @@ -962,7 +822,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { const storageRoot = getStorageRoot() const absolutePath = path.resolve(storageRoot, media.filePath) - if (!absolutePath.startsWith(storageRoot)) { + if (!absolutePath.startsWith(storageRoot + path.sep)) { return reply.code(400).send({ error: 'invalid_media_path' }) } @@ -973,8 +833,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: 'media_file_missing' }) } - reply.header('Cache-Control', 'private, max-age=300') - reply.type(media.mimeType) + applyMediaResponseHeaders(reply, media.mimeType, path.basename(media.filePath)) return reply.send(fileBuffer) }) @@ -994,7 +853,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { photoId: z.string().min(1), }) - app.post('/trees/:id/annotation-photos', async (request, reply) => { + app.post('/trees/:id/annotation-photos', UPLOAD_RATE_LIMIT, async (request, reply) => { if (!request.actor || request.actor.kind !== 'user') { return reply.code(401).send({ error: 'authentication_required' }) } @@ -1014,17 +873,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(403).send({ error: 'forbidden' }) } - if (!payload.data.mimeType.startsWith('image/')) { - return reply.code(400).send({ error: 'invalid_annotation_photo_mime_type' }) - } - - const encoded = stripDataUrlPrefix(payload.data.dataBase64) - let fileBuffer: Buffer - try { - fileBuffer = Buffer.from(encoded, 'base64') - } catch { - return reply.code(400).send({ error: 'invalid_base64_data' }) - } + const fileBuffer = Buffer.from(stripDataUrlPrefix(payload.data.dataBase64), 'base64') if (!fileBuffer.length) { return reply.code(400).send({ error: 'empty_photo_file' }) @@ -1038,18 +887,27 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'photo_too_large' }) } + let prepared: Awaited> + try { + prepared = await prepareUploadedFile(fileBuffer, 'photo', payload.data.fileName) + } catch (error) { + if (error instanceof InvalidMediaError) { + return reply.code(400).send({ error: 'invalid_annotation_photo_mime_type' }) + } + throw error + } + const photoId = randomUUID() - const extension = inferFileExtension(payload.data.fileName, payload.data.mimeType) - const relativePath = path.posix.join(params.data.id, 'annotation-photos', `${photoId}${extension}`) + const relativePath = path.posix.join(params.data.id, 'annotation-photos', `${photoId}${prepared.extension}`) const storageRoot = getStorageRoot() const absolutePath = path.resolve(storageRoot, relativePath) - if (!absolutePath.startsWith(storageRoot)) { + if (!absolutePath.startsWith(storageRoot + path.sep)) { return reply.code(400).send({ error: 'invalid_photo_path' }) } await mkdir(path.dirname(absolutePath), { recursive: true }) - await writeFile(absolutePath, fileBuffer) + await writeFile(absolutePath, prepared.buffer) return reply.code(201).send({ photoId, @@ -1068,12 +926,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_query' }) } - const readableByActor = await assertTreeReadableByActor(app, params.data.id, request.actor) - const readableByQueryToken = query.data.token - ? await assertTreeReadableByQueryToken(app, params.data.id, query.data.token) - : false - - if (!readableByActor && !readableByQueryToken) { + if (!(await canReadMedia(app, params.data.id, request, query.data.token))) { return reply.code(401).send({ error: 'authentication_required' }) } @@ -1096,7 +949,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { for (const ext of extensions) { const candidate = path.resolve(photoDir, `${photoIdSafe}${ext}`) - if (!candidate.startsWith(storageRoot)) continue + if (!candidate.startsWith(storageRoot + path.sep)) continue try { await readFile(candidate) // existence check foundPath = candidate @@ -1116,8 +969,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: 'photo_file_missing' }) } - reply.header('Cache-Control', 'private, max-age=300') - reply.type(foundMime) + applyMediaResponseHeaders(reply, foundMime, path.basename(foundPath)) return reply.send(fileBuffer) })