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>
782 lines
26 KiB
Go
782 lines
26 KiB
Go
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.
|
||
const maxVersionsKept = 5
|
||
|
||
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) {
|
||
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"
|
||
}
|