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>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,210 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user