package files import ( "encoding/json" "errors" "fmt" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "mime" "net/http" "path/filepath" "strings" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/rs/zerolog/log" "aether/internal/acl" "aether/internal/audit" "aether/internal/auth" "aether/internal/documents" "aether/internal/storage" "aether/internal/tags" "aether/pkg/middleware" "aether/pkg/response" ) const maxUploadSize = 5 << 30 // 5 GB // maxVersionsKept begrenzt den Verlauf einer Datei mit aktivierter // Versionierung. Ältere Fassungen werden samt Blob entfernt. // // Eine Paketvariable, kein Feld an Handler: Der Wert wird beim Start einmal // aus MAX_VERSIONS_KEPT gesetzt und danach nur gelesen. Ihn stattdessen // durchzureichen hieße, vier Signaturen zu ändern – auch die von // ReplaceContent, die der Office-Weg benutzt –, für eine Zahl, die sich zur // Laufzeit nie ändert. var maxVersionsKept = 5 // SetMaxVersionsKept wird einmal beim Start aufgerufen, bevor der erste // Request läuft. // // Kleiner als 1 wird verworfen: Die Zahl geht als OFFSET in das DELETE der // Aufräumabfrage, und `OFFSET 0` löschte **jede** Fassung – aus „keine Grenze" // würde „kein Verlauf". Wer keinen Verlauf will, lässt die Versionierung an // der Datei aus; dafür gibt es den Schalter schon. func SetMaxVersionsKept(n int) { if n >= 1 { maxVersionsKept = n } } type Handler struct { store *Store storage storage.Storage auditor *audit.Logger acl *acl.Store // tags darf nil sein – die Dateiliste funktioniert dann ohne Aufkleber. tags *tags.Store // docs darf nil sein – dann ist die Texterkennung abgeschaltet und der // PDF-Betrachter sucht nur in dem Text, den das PDF selbst mitbringt. docs *documents.Store } func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, aclStore *acl.Store, tagStore *tags.Store, docStore *documents.Store) *Handler { return &Handler{store: store, storage: st, auditor: auditor, acl: aclStore, tags: tagStore, docs: docStore} } // logFile protokolliert Löschvorgänge. Normale Uploads und Downloads bleiben // bewusst draußen: bei Dutzenden Dateien am Tag ersäuft sonst das, worauf es // ankommt – nämlich was verschwunden ist. func (h *Handler) logFile(r *http.Request, action, targetID, targetName, detail string) { user := auth.UserFromContext(r.Context()) e := audit.Event{ Action: action, TargetType: "file", TargetID: targetID, TargetName: targetName, Detail: detail, IP: middleware.RealIP(r), } if user != nil { e.ActorID = &user.ID e.ActorName = user.Username } h.auditor.Log(r.Context(), e) } // POST /api/v1/files func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) mr, err := r.MultipartReader() if err != nil { response.Error(w, http.StatusBadRequest, "expected multipart upload") return } // The frontend sends folder_id BEFORE the file part, so we know the target // folder by the time we reach the streamed file content. var folderID *uuid.UUID for { part, err := mr.NextPart() if errors.Is(err, io.EOF) { break } if err != nil { response.Error(w, http.StatusBadRequest, "failed to read upload") return } switch part.FormName() { case "folder_id": buf, _ := io.ReadAll(io.LimitReader(part, 256)) part.Close() fid := strings.TrimSpace(string(buf)) if fid == "" { continue } id, err := uuid.Parse(fid) if err != nil { response.Error(w, http.StatusBadRequest, "invalid folder_id") return } folderID = &id case "file": // Erst hier steht der Zielordner fest – das Frontend schickt // folder_id vor dem Dateiteil. Zugriff und Eigentümer werden // deshalb an dieser Stelle geklärt, nicht früher. owner, ok := h.resolveFolder(w, r, folderID, true) if !ok { part.Close() return } filename := SanitizeFilename(part.FileName()) if filename == "" { part.Close() response.Error(w, http.StatusBadRequest, "file field required") return } mimeType := detectMIME(filename, part.Header.Get("Content-Type")) // Stream the part body straight into storage – no full-file buffering. h.storeUploadedFile(w, r, owner, folderID, filename, mimeType, part) part.Close() return default: part.Close() } } response.Error(w, http.StatusBadRequest, "file field required") } // storeUploadedFile streams content into storage and writes the file metadata. // Handles versioning when a file of the same name already exists. It writes the // HTTP response itself (success or error). // storeUploadedFile schreibt im Namen von owner. Bei eigenen Uploads ist das // der Anfragende selbst; in einem geliehenen Ordner der Eigentümer – die Datei // landet in dessen Bereich und zählt gegen dessen Kontingent. func (h *Handler) storeUploadedFile(w http.ResponseWriter, r *http.Request, owner acl.Access, folderID *uuid.UUID, filename, mimeType string, content io.Reader) { // Build human-readable storage key mirroring the app folder structure. folderPath, err := h.store.GetFolderPath(r.Context(), folderID) if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } storageKey := BuildStorageKey(owner.StorageRoot(), folderPath, filename) // Versioning: if a file with the same name already exists, preserve old content. existing, err := h.store.GetFileByName(r.Context(), owner.OwnerID, folderID, filename) if err != nil && !errors.Is(err, ErrNotFound) { response.Error(w, http.StatusInternalServerError, "database error") return } if existing != nil { // Die alte Fassung zählt nach dem Überschreiben nicht mehr zum Verbrauch. remaining, unlimited, err := h.store.RemainingQuota(r.Context(), owner.OwnerID, owner.OwnerQuota, existing.Size) if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } // Nur bei aktivierter Versionierung wird die alte Fassung aufgehoben. // Sonst ersetzt Put sie atomar (Temp + Rename) und der Platz bleibt frei. var versionKey string if existing.VersioningEnabled { versionKey = fmt.Sprintf("%s/.versions/%s", owner.StorageRoot(), uuid.New().String()) if err := h.storage.Move(r.Context(), existing.StorageKey, versionKey); err != nil { response.Error(w, http.StatusInternalServerError, "storage error") return } } if !unlimited { content = newQuotaReader(content, remaining) } checksum, size, err := h.storage.Put(r.Context(), storageKey, content) if err != nil { // Der Blob ist bereits verschoben – zurückrollen, sonst verliert der // Nutzer die bestehende Datei an einen Upload, der nie ankam. if versionKey != "" { if mvErr := h.storage.Move(r.Context(), versionKey, existing.StorageKey); mvErr != nil { log.Ctx(r.Context()).Error().Err(mvErr). Str("file_id", existing.ID.String()). Msg("rollback of version move failed – blob orphaned") } } if errors.Is(err, ErrQuotaExceeded) { response.Error(w, http.StatusRequestEntityTooLarge, "storage quota exceeded") return } response.Error(w, http.StatusInternalServerError, "storage error") return } if versionKey != "" { if err := h.store.CreateVersion(r.Context(), existing.ID, existing.Version, existing.Size, existing.Checksum, versionKey); err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } } else if existing.StorageKey != storageKey { // Altbestand unter abweichendem Pfad (z.B. früherer WebDAV-Upload): // Put hat ihn nicht ersetzt, er wäre sonst verwaist. _ = h.storage.Delete(r.Context(), existing.StorageKey) } updated, err := h.store.UpdateFileContent(r.Context(), existing.ID, size, checksum, storageKey) if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } if oldKeys, err := h.store.PruneVersions(r.Context(), existing.ID, maxVersionsKept); err == nil { for _, key := range oldKeys { _ = h.storage.Delete(r.Context(), key) } } _ = h.storage.Delete(r.Context(), "thumbs/"+existing.ID.String()+".jpg") response.JSON(w, http.StatusCreated, updated) return } remaining, unlimited, err := h.store.RemainingQuota(r.Context(), owner.OwnerID, owner.OwnerQuota, 0) if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } if !unlimited { content = newQuotaReader(content, remaining) } checksum, size, err := h.storage.Put(r.Context(), storageKey, content) if err != nil { if errors.Is(err, ErrQuotaExceeded) { response.Error(w, http.StatusRequestEntityTooLarge, "storage quota exceeded") return } response.Error(w, http.StatusInternalServerError, "storage error") return } created, err := h.store.CreateFile(r.Context(), &File{ OwnerID: owner.OwnerRef(), GroupID: owner.GroupRef(), FolderID: folderID, Name: filename, MimeType: mimeType, Size: size, Checksum: checksum, StorageKey: storageKey, }) if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } response.JSON(w, http.StatusCreated, created) } // GET /api/v1/files // Search GET /api/v1/search?q=… // // Der Umfang steckt in der Abfrage, nicht hier: Store.Search liefert nur, was // der Anfragende ohnehin erreichen darf. Ein nachgelagerter Filter wäre die // Variante, bei der man einen Fall vergisst. func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) query := strings.TrimSpace(r.URL.Query().Get("q")) // Ein einzelnes Zeichen trifft fast alles und kostet nur – die Oberfläche // fragt deshalb erst ab zwei Zeichen, und der Server hält sich daran. if len([]rune(query)) < 2 { response.JSON(w, http.StatusOK, map[string]any{"hits": []any{}, "query": query}) return } hits, err := h.store.Search(r.Context(), user.ID, query) if err != nil { log.Ctx(r.Context()).Error().Err(err).Msg("search failed") response.Error(w, http.StatusInternalServerError, "database error") return } response.JSON(w, http.StatusOK, map[string]any{ "hits": hits, "query": query, // truncated sagt der Oberfläche, dass sie „mehr als 100" anzeigen soll – // sonst sieht eine abgeschnittene Liste wie eine vollständige aus. "truncated": len(hits) >= SearchLimit, }) } func (h *Handler) List(w http.ResponseWriter, r *http.Request) { var folderID *uuid.UUID if fid := r.URL.Query().Get("folder_id"); fid != "" { id, err := uuid.Parse(fid) if err != nil { response.Error(w, http.StatusBadRequest, "invalid folder_id") return } folderID = &id } owner, ok := h.resolveFolder(w, r, folderID, false) if !ok { return } files, err := h.store.ListFiles(r.Context(), owner.OwnerID, folderID) if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } folders, err := h.store.ListFolders(r.Context(), owner.OwnerID, folderID) if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } // In der Wurzel kommen die Gruppenordner dazu, in denen der Anfragende // Mitglied ist. Sie gehören ihm nicht, erscheinen aber neben seinen eigenen // Ordnern – das ist der Punkt an einem Gruppenordner. if folderID == nil { user := auth.UserFromContext(r.Context()) groupFolders, err := h.store.ListGroupFoldersForUser(r.Context(), user.ID) if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } folders = append(folders, groupFolders...) } if files == nil { files = []*File{} } if folders == nil { folders = []*Folder{} } // Etiketten zu allen Dateien in *einer* Abfrage nachladen, nicht je Datei. // Bei fünfzig Kacheln wären das sonst fünfzig Rundreisen zur Datenbank, nur // um ein paar Aufkleber zu zeichnen. fileTags := map[string]any{} fileCorrespondents := map[string]any{} if h.tags != nil && len(files) > 0 { ids := make([]uuid.UUID, 0, len(files)) for _, f := range files { ids = append(ids, f.ID) } if m, err := h.tags.ForFiles(r.Context(), ids); err == nil { for id, list := range m { fileTags[id.String()] = list } } // Der Absender kommt auf demselben Weg: eine Abfrage für alle Kacheln. if m, err := h.tags.CorrespondentsForFiles(r.Context(), ids); err == nil { for id, c := range m { fileCorrespondents[id.String()] = c } } // Ein Fehler hier ist kein Grund, die Dateiliste zu verweigern – dann // fehlen eben die Aufkleber. } response.JSON(w, http.StatusOK, map[string]any{ "files": files, "folders": folders, // Nicht an der Datei selbst, damit File weiterhin genau das ist, was in // der Tabelle steht – und damit jede andere Stelle, die Dateien // ausliefert, nicht plötzlich leere Etikettenlisten mitschickt. "tags": fileTags, "correspondents": fileCorrespondents, }) } // GET /api/v1/files/{id}/download func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { id, err := parseID(r, "id") if err != nil { response.Error(w, http.StatusBadRequest, "invalid file id") return } owner, ok := h.resolveFile(w, r, id, false) if !ok { return } f, err := h.store.GetFile(r.Context(), id, owner.OwnerID) if errors.Is(err, ErrNotFound) { response.Error(w, http.StatusNotFound, "file not found") return } if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } rc, err := h.storage.Get(r.Context(), f.StorageKey) if err != nil { response.Error(w, http.StatusInternalServerError, "storage error") return } defer rc.Close() // Für ältere Dateien steht in der Datenbank oft application/octet-stream: // Bis zum 23.08.2026 kannte der Server die Endungen .mp3, .mp4 und .zip // nicht (siehe extraMIMETypes). Statt 94 Zeilen nachträglich umzuschreiben, // wird der Typ beim Ausliefern neu bestimmt, wenn der gespeicherte nichts // aussagt – das wirkt sofort und rührt die Daten nicht an. serveType := f.MimeType if serveType == "" || serveType == "application/octet-stream" { serveType = detectMIME(f.Name, "") } w.Header().Set("Content-Type", serveType) w.Header().Set("X-Checksum-SHA256", f.Checksum) // Ohne diesen Kopf hält der Browser die Antwort für unteilbar und gibt in // der Abspielleiste von Video und Ton kein Springen frei – man kann dann // nur von vorn bis hinten hören. w.Header().Set("Accept-Ranges", "bytes") // Die Vorschau braucht die Datei *im* Browser statt im Download-Ordner. // Das ist die gefährliche Richtung: Eine hochgeladene HTML- oder SVG-Datei // liefe im selben Ursprung wie die Anwendung und käme damit an die // Sitzung. Deshalb inline nur für eine feste Liste harmloser Typen, und // alles andere fällt still auf den Anhang zurück. if r.URL.Query().Get("inline") == "1" && inlineSafe(serveType) { w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename=%q`, f.Name)) // Kein Typ-Raten: Sonst könnte eine als Bild deklarierte Datei doch als // HTML gedeutet werden. w.Header().Set("X-Content-Type-Options", "nosniff") // sandbox nimmt der Antwort ihren Ursprung – selbst wenn doch etwas // Ausführbares durchkäme, hätte es keinen Zugriff auf Cookies. // allow-scripts nur für PDF: Der eingebaute Betrachter von Chrome ist // selbst ein Skript und zeigt sonst nichts an. Ohne allow-same-origin // bleibt der Ursprung trotzdem fremd, die Sitzung also unerreichbar. sandbox := "sandbox" if strings.HasPrefix(serveType, "application/pdf") { sandbox = "sandbox allow-scripts" } w.Header().Set("Content-Security-Policy", sandbox+"; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'self'") // Die Vorschau zeigt die Datei in einem Rahmen der eigenen Anwendung. // Die allgemeine Kopfzeile setzt DENY und verbietet damit auch das – // hier also gezielt auf den eigenen Ursprung heruntergestuft. Fremde // Seiten können die Datei weiterhin nicht einbetten. w.Header().Set("X-Frame-Options", "SAMEORIGIN") } else { w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name)) } // Springt der Abspieler mitten hinein, übernimmt ServeContent: Es // beantwortet den Range-Kopf mit 206 und dem passenden Ausschnitt. // // Die Prüfsumme bleibt dabei außen vor – sie deckt die ganze Datei ab, und // ein Ausschnitt lässt sich nicht dagegen halten. Beim vollständigen // Abruf, also bei jedem echten Download, wird weiter geprüft. if seeker, ok := rc.(io.ReadSeeker); ok && r.Header.Get("Range") != "" { http.ServeContent(w, r, f.Name, f.UpdatedAt, seeker) return } w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size)) // Prüfsumme beim Durchreichen mitrechnen – und den **Schluss // zurückhalten**, bis sie stimmt. // // Vorher wurde hier zwar gerechnet und verglichen, das Ergebnis aber // weggeworfen: kein Logeintrag, kein Hinweis, nichts. Der Kommentar an // dieser Stelle sagte selbst, was fehlt. Ein gekipptes Bit auf der Platte // wurde also erkannt und **verschwiegen** – bei einem Rechnungsarchiv, // in das man einmal im Jahr sieht, die teuerste Art von Stille. // // Die Kopfzeilen sind längst draußen, der Statuscode also nicht mehr zu // ändern – das stimmte. Nur folgt daraus nicht, dass man nichts tun kann: // Wer die letzten Kilobytes erst nach der Prüfung herausgibt, kann die // Verbindung noch fallen lassen, und der Browser sieht einen // abgebrochenen Download statt einer heilen Datei mit falschem Inhalt. ServeVerified(r.Context(), w, rc, f.Checksum, VerifyInfo{ FileID: f.ID.String(), StorageKey: f.StorageKey, Size: f.Size, }) } // inlineSafeTypes sind die Typen, die im Browser angezeigt werden dürfen. // Bewusst eine Positivliste: Was nicht daraufsteht, wird heruntergeladen. // Nicht drauf steht insbesondere SVG – eine SVG-Datei darf Skripte enthalten – // und alles, was nach HTML aussieht. var inlineSafeTypes = map[string]bool{ "image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true, "image/bmp": true, "application/pdf": true, "text/plain": true, "video/mp4": true, "video/webm": true, "audio/mpeg": true, "audio/ogg": true, "audio/wav": true, } func inlineSafe(mimeType string) bool { // Der gespeicherte Typ kann Parameter tragen ("text/plain; charset=utf-8"). base := strings.TrimSpace(strings.Split(mimeType, ";")[0]) return inlineSafeTypes[strings.ToLower(base)] } // DELETE /api/v1/files/{id} func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { id, err := parseID(r, "id") if err != nil { response.Error(w, http.StatusBadRequest, "invalid file id") return } owner, ok := h.resolveFile(w, r, id, true) if !ok { return } current, err := h.store.GetFile(r.Context(), id, owner.OwnerID) if errors.Is(err, ErrNotFound) { response.Error(w, http.StatusNotFound, "file not found") return } if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } trashKey := fmt.Sprintf("%s/.trash/%s_%s", owner.StorageRoot(), id.String()[:8], current.Name) if err := h.storage.Move(r.Context(), current.StorageKey, trashKey); err != nil { response.Error(w, http.StatusInternalServerError, "storage error") return } if err := h.store.SoftDeleteFile(r.Context(), id, owner.OwnerID, trashKey); err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } h.logFile(r, audit.ActionFileDelete, id.String(), current.Name, "moved to trash") response.NoContent(w) } // PATCH /api/v1/files/{id}/rename func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) { id, err := parseID(r, "id") if err != nil { response.Error(w, http.StatusBadRequest, "invalid file id") return } owner, ok := h.resolveFile(w, r, id, true) if !ok { return } var body struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" { response.Error(w, http.StatusBadRequest, "name required") return } newName := SanitizeFilename(body.Name) if newName == "" { response.Error(w, http.StatusBadRequest, "name required") return } current, err := h.store.GetFile(r.Context(), id, owner.OwnerID) if errors.Is(err, ErrNotFound) { response.Error(w, http.StatusNotFound, "file not found") return } if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } folderPath, err := h.store.GetFolderPath(r.Context(), current.FolderID) if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } newStorageKey := BuildStorageKey(owner.StorageRoot(), folderPath, newName) if err := h.storage.Move(r.Context(), current.StorageKey, newStorageKey); err != nil { response.Error(w, http.StatusInternalServerError, "storage error") return } f, err := h.store.RenameFile(r.Context(), id, owner.OwnerID, newName, newStorageKey) if errors.Is(err, ErrNotFound) { response.Error(w, http.StatusNotFound, "file not found") return } if errors.Is(err, ErrNameTaken) { response.Error(w, http.StatusConflict, "name already exists") return } if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } response.JSON(w, http.StatusOK, f) } // PATCH /api/v1/files/{id}/move func (h *Handler) Move(w http.ResponseWriter, r *http.Request) { id, err := parseID(r, "id") if err != nil { response.Error(w, http.StatusBadRequest, "invalid file id") return } owner, ok := h.resolveFile(w, r, id, true) if !ok { return } var body struct { FolderID *string `json:"folder_id"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { response.Error(w, http.StatusBadRequest, "invalid request body") return } var folderID *uuid.UUID if body.FolderID != nil && *body.FolderID != "" { fid, err := uuid.Parse(*body.FolderID) if err != nil { response.Error(w, http.StatusBadRequest, "invalid folder_id") return } // Ziel muss demselben Eigentümer gehören. Ein Verschieben über // Eigentumsgrenzen hinweg würde Bytes zwischen Kontingenten umbuchen // und Freigaben unter der Hand umhängen – bewusst nicht erlaubt. // Zum Übertragen kopiert man herunter und lädt wieder hoch. if _, err := h.store.GetFolder(r.Context(), fid, owner.OwnerID); err != nil { response.Error(w, http.StatusNotFound, "target folder not found in the same account") return } folderID = &fid } current, err := h.store.GetFile(r.Context(), id, owner.OwnerID) if errors.Is(err, ErrNotFound) { response.Error(w, http.StatusNotFound, "file not found") return } if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } newFolderPath, err := h.store.GetFolderPath(r.Context(), folderID) if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } newStorageKey := BuildStorageKey(owner.StorageRoot(), newFolderPath, current.Name) if err := h.storage.Move(r.Context(), current.StorageKey, newStorageKey); err != nil { response.Error(w, http.StatusInternalServerError, "storage error") return } f, err := h.store.MoveFile(r.Context(), id, owner.OwnerID, folderID, newStorageKey) if errors.Is(err, ErrNotFound) { response.Error(w, http.StatusNotFound, "file not found") return } if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } response.JSON(w, http.StatusOK, f) } // GET /api/v1/files/{id}/thumbnail func (h *Handler) GetThumbnail(w http.ResponseWriter, r *http.Request) { id, err := parseID(r, "id") if err != nil { response.Error(w, http.StatusBadRequest, "invalid file id") return } owner, ok := h.resolveFile(w, r, id, false) if !ok { return } f, err := h.store.GetFile(r.Context(), id, owner.OwnerID) if errors.Is(err, ErrNotFound) { // Auch aus dem Papierkorb ausliefern. Das Vorschaubild überlebt das // weiche Löschen (entfernt wird es erst beim endgültigen), und ohne // diesen zweiten Griff stünde dort nur ein graues Quadrat – man // entschiede über „endgültig löschen", ohne zu sehen, was es war. f, err = h.store.GetTrashedFile(r.Context(), id, owner.OwnerID) } if errors.Is(err, ErrNotFound) { response.Error(w, http.StatusNotFound, "file not found") return } if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } thumbKey := "thumbs/" + f.ID.String() + ".jpg" rc, err := h.storage.Get(r.Context(), thumbKey) if err != nil { http.NotFound(w, r) return } defer rc.Close() w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = io.Copy(w, rc) } // ── Favorites ───────────────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────────────── func parseID(r *http.Request, param string) (uuid.UUID, error) { return uuid.Parse(chi.URLParam(r, param)) } // extraMIMETypes füllt eine Lücke des Alpine-Images: Go liest die // Typtabelle aus /etc/mime.types, und die Datei existiert dort nicht. Die // eingebaute Ersatztabelle kennt nur eine Handvoll Web-Typen – .mp3, .mp4 und // .zip gehören nicht dazu. // // Sichtbare Folge vor dieser Ergänzung: Eine hochgeladene MP3 lag als // application/octet-stream in der Datenbank, ließ sich deshalb nicht abspielen // (die Vorschau entscheidet am Typ) und bekam das Symbol für „unbekannt". // // Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich, // auch wenn das Grundbild wechselt, und lässt sich testen. var extraMIMETypes = map[string]string{ // Zeichnungen und Mindmaps. Innen JSON – aber mit eigener Endung, damit // die Oberflaeche sie vom Texteditor fernhaelt. ".excalidraw": "application/json", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg", ".wav": "audio/wav", ".flac": "audio/flac", ".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".mkv": "video/x-matroska", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".zip": "application/zip", ".tar": "application/x-tar", ".gz": "application/gzip", ".tgz": "application/gzip", ".bz2": "application/x-bzip2", ".xz": "application/x-xz", ".7z": "application/x-7z-compressed", ".rar": "application/vnd.rar", ".zst": "application/zstd", ".deb": "application/vnd.debian.binary-package", ".csv": "text/csv", ".md": "text/markdown", ".txt": "text/plain", ".log": "text/plain", ".yaml": "text/yaml", ".yml": "text/yaml", ".toml": "text/plain", ".ini": "text/plain", ".conf": "text/plain", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".odt": "application/vnd.oasis.opendocument.text", ".ods": "application/vnd.oasis.opendocument.spreadsheet", ".ppt": "application/vnd.ms-powerpoint", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".odp": "application/vnd.oasis.opendocument.presentation", ".rtf": "application/rtf", } func detectMIME(filename, contentType string) string { if ext := strings.ToLower(filepath.Ext(filename)); ext != "" { if t := mime.TypeByExtension(ext); t != "" { return t } if t, ok := extraMIMETypes[ext]; ok { return t } } if contentType != "" && contentType != "application/octet-stream" { return contentType } return "application/octet-stream" }