Files
Aether/backend/internal/files/handler.go
T
marcandClaude Opus 5 2cc02941b9 fix(files): beschaedigte Downloads werden gemeldet und abgebrochen
Die Pruefsumme wurde beim Ausliefern mitgerechnet und das Ergebnis
weggeworfen -- ein gekipptes Bit wurde erkannt und verschwiegen.

Jetzt: Logeintrag mit Datei-ID, Speicherschluessel, erwarteter und
gelesener Summe, dazu panic(http.ErrAbortHandler), den chis Recoverer
ausdruecklich durchreicht.

Damit das wirkt, werden die letzten 32 KiB zurueckgehalten. Sonst waere die
Datei beim Erkennen laengst vollstaendig beim Empfaenger und der Abbruch
kaeme zu spaet. Erst wenn die Summe stimmt, darf der Schluss hinterher;
sonst bekommt die Gegenstelle weniger Bytes als angekuendigt und wertet das
als Fehler statt als Datei.

scripts/pruefsumme-selbsttest.py, 14 Pruefungen: kippt ein Bit auf der
Platte (nicht im Programm), in der Mitte der Datei, damit es nicht schon an
der Laenge auffaellt; zwei Groessen ueber und unter den 32 KiB; und zuerst
die Gegenprobe, dass eine heile Datei durchkommt.

**Dabei gefunden:** Der neue Logeintrag blieb aus, obwohl der Abbruch
funktionierte. zerolog.Ctx liefert einen *abgeschalteten* Logger, wenn im
Kontext keiner steckt -- und Aether legte nie einen hinein. Alle 34
log.Ctx-Aufrufe im Baum schrieben ins Nichts, darunter die Warnung ueber
verwaiste Blobs beim Zurueckrollen einer Fassung.

Behoben zweifach: zerolog.DefaultContextLogger beim Start (deckt auch
Hintergrundarbeit ab) und ein Logger mit request_id im Anfragekontext. Zwei
Tests halten es fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:15:22 +02:00

872 lines
29 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))
// 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.
h256 := sha256.New()
zurueck := &tailWriter{w: w, keep: verifyTail}
if _, err := io.Copy(zurueck, io.TeeReader(rc, h256)); err != nil {
return // Gegenstelle ist weg
}
actual := hex.EncodeToString(h256.Sum(nil))
if actual != f.Checksum {
log.Ctx(r.Context()).Error().
Str("file_id", f.ID.String()).
Str("storage_key", f.StorageKey).
Str("erwartet", f.Checksum).
Str("gelesen", actual).
Int64("size", f.Size).
Msg("Pruefsumme stimmt nicht - Datei auf der Platte ist beschaedigt")
// ErrAbortHandler ist der vorgesehene Weg, eine Antwort mittendrin
// fallen zu lassen: net/http schließt die Verbindung und schreibt
// **keinen** eigenen Logeintrag dazu (chis Recoverer reicht diese eine
// Panik ausdrücklich durch). Weil der Schluss noch nicht draußen ist,
// bekommt die Gegenstelle weniger Bytes als im Content-Length
// angekündigt – und wertet das als Fehler.
panic(http.ErrAbortHandler)
}
// Stimmt sie, darf der Rest hinterher.
_ = zurueck.Flush()
}
// verifyTail: So viele Bytes werden bis zur Prüfung zurückgehalten.
//
// Sie sind der ganze Hebel: Ohne sie wäre die Datei beim Erkennen des Fehlers
// längst vollständig beim Empfänger, und ein Abbruch käme zu spät. 32 KiB
// kosten nichts und liegen über dem, was io.Copy in einem Zug bewegt.
const verifyTail = 32 * 1024
// tailWriter reicht alles durch außer den letzten `keep` Bytes.
type tailWriter struct {
w io.Writer
keep int
buf []byte
}
func (t *tailWriter) Write(p []byte) (int, error) {
t.buf = append(t.buf, p...)
if ueber := len(t.buf) - t.keep; ueber > 0 {
if _, err := t.w.Write(t.buf[:ueber]); err != nil {
return 0, err
}
t.buf = append(t.buf[:0], t.buf[ueber:]...)
}
return len(p), nil
}
// Flush gibt heraus, was zurückgehalten wurde. Wird es **nicht** gerufen,
// bleibt die Antwort unvollständig – genau das ist der Zweck.
func (t *tailWriter) Flush() error {
if len(t.buf) == 0 {
return nil
}
_, err := t.w.Write(t.buf)
t.buf = t.buf[:0]
return err
}
// 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"
}