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>
211 lines
6.6 KiB
Go
211 lines
6.6 KiB
Go
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
|
||
}
|