Files
Aether/backend/internal/files/handler_zip.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

211 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}