mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 16:16:09 +00:00
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:
1 parent
cdcff20e22
commit
78445589de
4 files changed
+919
-351
No files matched your search
Generated
+527
-32
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user