From 78445589dee5c1a536607d8df2133a3a08b7bc36 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Wed, 30 Sep 2026 17:57:26 +0200 Subject: [PATCH] =?UTF-8?q?Fichiers=20envoy=C3=A9s=20:=20type=20lu=20dans?= =?UTF-8?q?=20les=20octets,=20photos=20sans=20GPS,=20diffusion=20sans=20sc?= =?UTF-8?q?ript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un SVG ou du HTML déclaré « photo » passait et s'exécutait sur app.parenthese.io à l'ouverture (vol de session du propriétaire). Le type vient maintenant des premiers octets, les photos sont réencodées par sharp (rotation appliquée, EXIF et GPS retirés), les vidéos de téléphone perdent leur position, et chaque fichier est servi avec nosniff et une CSP sandbox (téléchargement pour tout ce qui n'est pas image, son, vidéo ou PDF). Fichiers supprimés effacés du disque, quota par arbre, limite de débit sur les envois. Co-Authored-By: Claude Opus 5.5 --- backend/package-lock.json | 559 +++++++++++++++++++++++++++++++-- backend/package.json | 1 + backend/src/lib/media-files.ts | 220 +++++++++++++ backend/src/routes/media.ts | 490 ++++++++++------------------- 4 files changed, 919 insertions(+), 351 deletions(-) create mode 100644 backend/src/lib/media-files.ts 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) })