feat(files): streaming ZIP download in the project explorer
Nightly Build / build (push) Successful in 8m52s

Each row of the project Files tab gains a download action, and the toolbar
gains a Download ZIP button scoped to the folder being browsed (at the root,
the whole project). Visible to read-only members too: download is a read.

Single files need no new backend: they reuse GET /api/file?force_download.
Directories go through the new GET /api/file/download, which builds the ZIP
on the fly: an async task walks the tree and async_zip (Astral's maintained
rs-async-zip fork) streams entries into a bounded duplex stream backing the
response body — no temp file, no whole-archive buffer, backpressure for free,
and the task dies with the client. Compression is per entry: Deflate at
maximum level, except files whose magic bytes name an already-compressed
format (media/PDF via the shared sniffer, the ZIP family, gzip/zstd/7z/rar,
compressed audio), which are Stored. Entries are prefixed with the folder
name, empty folders and unix permission bits survive, symlinks are never
followed into the archive, and containment stays fail-closed under the
resolved root. Covered by a round-trip test read back with the crate's own
reader (and verified against unzip/python's zipfile).
This commit is contained in:
2026-08-07 19:49:30 +01:00
parent c96ceee037
commit 8013022321
9 changed files with 315 additions and 8 deletions
+2
View File
@@ -275,7 +275,9 @@ export default {
'projects.files.drop': 'Déposez les fichiers ici pour les envoyer',
'projects.files.btn.new_folder': 'Nouveau dossier',
'projects.files.btn.upload': 'Envoyer',
'projects.files.btn.download': 'Télécharger (ZIP)',
'projects.files.uploading': 'Envoi…',
'projects.files.action.download': 'Télécharger',
'projects.files.action.rename': 'Renommer',
'projects.files.action.delete': 'Supprimer',
'projects.files.confirm.delete_file': 'Supprimer « {name} » ?',