Fichiers envoyés : type lu dans les octets, photos sans GPS, diffusion sans script

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 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-09-30 17:57:26 +02:00
1 parent cdcff20e22
commit 78445589de
4 files changed
+919 -351

No files matched your search

+527 -32
View File
@@ -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",
+1
View File
@@ -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": {
+220
View File
@@ -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<BinaryMediaType, readonly string[]> = {
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<string>([
...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<string>(['text/plain', 'application/geo+json', 'application/gpx+xml'])
const EXTENSION_BY_MIME: Record<string, string> = {
'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\S]*?-->\s*)*<gpx[\s>]/.test(text) ? 'application/gpx+xml' : null
}
// Texte brut accepté comme document, sauf s'il ressemble à une page ou un balisage
if (declaredType === 'document' && !/^</.test(text)) return 'text/plain'
return null
}
export function isMimeAllowedForType(type: BinaryMediaType, mimeType: string): boolean {
return ALLOWED_BY_TYPE[type].includes(mimeType)
}
export function extensionForMime(mimeType: string): string {
return EXTENSION_BY_MIME[mimeType] ?? '.bin'
}
export class InvalidMediaError extends Error {}
// Photos : rotation du téléphone appliquée aux pixels, puis réencodage sans aucune métadonnée
// (GPS, appareil, date). Un GIF n'a pas d'EXIF et peut être animé : il passe tel quel.
export async function sanitizeImage(buf: Buffer, mimeType: string): Promise<Buffer> {
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<void> {
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
}
}
+171 -319
View File
@@ -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<string, string> = {
'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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
let payload: AnyJwtPayload
try {
payload = await app.jwt.verify<AnyJwtPayload>(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<ReturnType<typeof prepareUploadedFile>>
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<ReturnType<typeof prepareUploadedFile>>
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)
})