package files import ( "archive/zip" "fmt" "io" "net/http" "path" "strings" "time" "github.com/google/uuid" "aether/internal/auth" "aether/pkg/response" ) // Bereits komprimierte Formate werden nur *gelagert*, nicht noch einmal // gepackt. Ein 116-MB-Paket durch Deflate zu schicken kostet Sekunden CPU und // spart nichts – die Bytes sind schon dicht. var storeOnlyExt = map[string]bool{ ".zip": true, ".gz": true, ".tgz": true, ".bz2": true, ".xz": true, ".7z": true, ".rar": true, ".zst": true, ".deb": true, ".rpm": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".mp3": true, ".mp4": true, ".m4a": true, ".mkv": true, ".webm": true, ".avi": true, ".pdf": true, } // DownloadZip packt eine Auswahl in ein Archiv – POST /api/v1/download/zip. // // **Gestreamt, nicht gesammelt.** Das Archiv entsteht direkt in der Antwort; // es liegt zu keinem Zeitpunkt ganz auf der Platte oder im Speicher. Bei einem // 5-GB-Ordner wäre das andernfalls 5 GB Bedarf auf einem Speicher, der ohnehin // knapp ist – und der Nutzer sähe minutenlang nichts. // // Der Preis: Ist das Archiv einmal angefangen, steht der Statuscode schon auf // 200. Ein Fehler danach kann nur noch dazu führen, dass das Archiv unvollständig // endet. Deshalb wird alles, was scheitern kann – Rechte, Auswahl, Grenzen – // **vor** dem ersten geschriebenen Byte geprüft. func (h *Handler) DownloadZip(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) if user == nil { response.Error(w, http.StatusUnauthorized, "not signed in") return } // Formular *und* JSON: Der Browser lädt eine Antwort nur dann als Datei // herunter, wenn sie aus einer Navigation stammt – also aus einem // abgeschickten Formular. Ein fetch() müsste das ganze Archiv erst in den // Speicher holen, und genau das soll hier nicht passieren. if err := r.ParseForm(); err != nil { response.Error(w, http.StatusBadRequest, "invalid form") return } name := strings.TrimSpace(r.FormValue("name")) folderID := strings.TrimSpace(r.FormValue("folder_id")) rawIDs := r.Form["file_ids"] if len(rawIDs) == 1 && strings.Contains(rawIDs[0], ",") { rawIDs = strings.Split(rawIDs[0], ",") } var entries []ZipEntry var err error switch { case folderID != "": id, perr := uuid.Parse(folderID) if perr != nil { response.Error(w, http.StatusBadRequest, "invalid folder_id") return } // Rechteprüfung über dieselbe Stelle wie überall: Wer den Ordner nicht // lesen darf, bekommt 404 – nicht 403, sonst verrät die Antwort, dass // es ihn gibt. access, aerr := h.acl.ResolveFolder(r.Context(), user.ID, id) if aerr != nil { response.Error(w, http.StatusNotFound, "not found") return } entries, err = h.store.FilesInFolderTree(r.Context(), access.OwnerID, id) if name == "" { if folder, ferr := h.store.GetFolder(r.Context(), id, access.OwnerID); ferr == nil { name = folder.Name } } default: ids := make([]uuid.UUID, 0, len(rawIDs)) for _, raw := range rawIDs { id, perr := uuid.Parse(strings.TrimSpace(raw)) if perr != nil { response.Error(w, http.StatusBadRequest, "invalid file id") return } ids = append(ids, id) } if len(ids) == 0 { response.Error(w, http.StatusBadRequest, "nothing selected") return } entries, err = h.store.FilesForZipByIDs(r.Context(), user.ID, ids) } if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } if len(entries) == 0 { // Auch der Fall „alles herausgefiltert, weil nichts erreichbar war". response.Error(w, http.StatusNotFound, "nothing to download") return } if len(entries) > MaxZipEntries { response.Error(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("too many files (limit %d) — narrow the selection", MaxZipEntries)) return } // Ab hier ist die Antwort unterwegs. filename := zipFilename(name) w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename)) w.Header().Set("X-Content-Type-Options", "nosniff") // Keine Länge im Voraus: Sie stünde erst fest, wenn alles gepackt ist – und // dann wäre das Streamen sinnlos gewesen. Der Browser zeigt deshalb keinen // Fortschrittsbalken, dafür beginnt der Download sofort. w.WriteHeader(http.StatusOK) zw := zip.NewWriter(w) used := map[string]int{} for _, e := range entries { if r.Context().Err() != nil { // Abgebrochen (Fenster zu, Verbindung weg). Nicht weiterpacken. break } entryPath := uniquePath(used, e.Path) method := zip.Deflate if storeOnlyExt[strings.ToLower(path.Ext(e.Name))] { method = zip.Store } hdr := &zip.FileHeader{Name: entryPath, Method: method, Modified: time.Now()} // Ohne dieses Bit deuten manche Entpacker (Windows-Explorer) den Namen // in einer alten Codepage – aus „Rechnung Müller.pdf" wird dann // „Rechnung Müller.pdf". hdr.Flags |= 0x800 fw, werr := zw.CreateHeader(hdr) if werr != nil { break } rc, gerr := h.storage.Get(r.Context(), e.StorageKey) if gerr != nil { // Eine fehlende Datei darf das ganze Archiv nicht kippen – der Rest // ist ja in Ordnung. Sie fehlt darin, das ist die ehrlichste // Auskunft, die zu diesem Zeitpunkt noch möglich ist. continue } _, cerr := io.Copy(fw, rc) rc.Close() if cerr != nil { break } } _ = zw.Close() } // zipFilename baut einen Dateinamen, der auf jedem System durchgeht. func zipFilename(name string) string { name = strings.TrimSpace(name) if name == "" { name = "download-" + time.Now().Format("2006-01-02") } // Zeichen, die Windows in Dateinamen verbietet, dazu Pfadtrenner. name = strings.Map(func(r rune) rune { if strings.ContainsRune(`/\:*?"<>|`, r) || r < 32 { return '-' } return r }, name) return name + ".zip" } // uniquePath verhindert doppelte Einträge im Archiv. // // Bei einer Auswahl aus mehreren Ordnern – oder bei allem mit einem Etikett – // können zwei Dateien denselben Namen tragen. Ein Archiv mit zwei gleichen // Einträgen entpacken manche Programme kommentarlos zu einer Datei; die zweite // wäre still verloren. func uniquePath(used map[string]int, p string) string { used[p]++ if used[p] == 1 { return p } ext := doubleExt(p) return fmt.Sprintf("%s (%d)%s", strings.TrimSuffix(p, ext), used[p], ext) } // doubleExt erkennt zweiteilige Endungen wie .tar.gz. // // path.Ext sieht nur ".gz", und die Zahl landete dann mittendrin: // „paket.tar (2).gz". Gemessen beim Prüfen, nicht vermutet. func doubleExt(p string) string { ext := path.Ext(p) if rest := strings.TrimSuffix(p, ext); strings.HasSuffix(strings.ToLower(rest), ".tar") { return path.Ext(rest) + ext } return ext }