Files
Aether/backend/internal/files/handler.go
T
marcandClaude Opus 5 105b1a0e74 fix(trash): Vorschaubilder auch im Papierkorb ausliefern
GetThumbnail schlug die Datei mit is_deleted = false nach – im
Papierkorb also 404, und dort stand für jedes Bild nur ein graues
Quadrat. Man entschied über "endgültig löschen", ohne zu sehen, was es
war.

Schlägt der erste Griff fehl, wird jetzt im Papierkorb nachgesehen. Das
Vorschaubild überlebt das weiche Löschen ohnehin: Entfernt wird es erst
beim endgültigen Löschen, und der GC zählt es bis dahin als benutzt.

Geprüft gegen die laufende Instanz: Bild hochladen, löschen,
Vorschaubild abrufen -> 200, image/jpeg, 64x64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:21:55 +02:00

808 lines
27 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 (
"crypto/sha256"
"encoding/hex"
"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))
// Verify checksum while streaming.
h256 := sha256.New()
reader := io.TeeReader(rc, h256)
if _, err := io.Copy(w, reader); err != nil {
return // client disconnected
}
actual := hex.EncodeToString(h256.Sum(nil))
if actual != f.Checksum {
// Log corruption; headers already sent, can't change status.
// TODO: alert / observability hook
_ = actual
}
}
// 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{
".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"
}