Eine Zeichnung ist eine Datei, kein neues Ding – das ist die ganze Entscheidung dahinter. .excalidraw ist JSON; damit erbt sie Ordner, Gruppenordner, Papierkorb, Versionen, Freigabelinks, interne Freigaben und die Suche. Keine neue Tabelle, kein zweites Rechtemodell. Gespeichert wird über denselben Upload wie alles andere, wie beim Text-Editor in der Vorschau. Drei Sorgen, alle nachgemessen statt gehofft: Das Hauptbündel wächst von 484,11 auf 485,22 kB – das Zeichenbrett ist ein eigenes Stück (584 kB + 142 kB CSS) und wird nachgeladen wie der PDF-Betrachter. Wer nie zeichnet, holt es nie. Die Schriften: Die Richtlinie ist default-src 'self' ohne font-src, Excalidraw hätte seine handgezeichneten Schriften von aussen geladen und still eine Ersatzschrift gezeigt. Sie werden jetzt beim Bauen kopiert und über window.EXCALIDRAW_ASSET_PATH gefunden – gesetzt im Modul, nicht per Inline-Skript, das wäre von derselben Richtlinie blockiert. Xiaolai bleibt draussen: 13 der 14 MB für die chinesische Schrift. Die Reihenfolge der Dateiarten: .excalidraw wird vor code und text geprüft, sonst öffnete eine Zeichnung im Texteditor als Zahlenkolonne (json steht in codeExt). Kein gleichzeitiges Arbeiten. Die Komponente könnte es, aber den Transport bringt sie nicht mit – das wäre ein zweiter Dienst neben Aether. Solange es ihn nicht gibt, überschreibt der Zweite den Ersten. Zwei Dinge fangen das ab: Vor dem Speichern wird geprüft, ob sich die Fassung geändert hat, und dann gefragt statt überschrieben. Und Zeichnungen behalten ihre Fassungen von der ersten Minute an. Der zweite Punkt ist erst durch den Selbsttest aufgefallen: Versionierung ist sonst bewusst aus (Migration 000006), damit der Speicher nicht vollläuft. Für Zeichnungen stimmt die Rechnung nicht – sie werden in sich immer wieder bearbeitet und sind ein paar Kilobyte gross. Ohne sie wäre die Warnung im Editor ein Versprechen ohne Deckung gewesen. Neuer Selbsttest mit 12 Prüfungen: Er prüft die These nach, auf der alles beruht – wenn eine Zeichnung wirklich eine Datei ist, muss sie können, was Dateien können. Nebenbei: db-tests.sh liess je Lauf ein anonymes Postgres-Volume liegen (rund 49 MB). `docker rm -v` nimmt es jetzt mit; zehn verwaiste entfernt. Geprüft: go build/vet sauber, alle 15 Go-Pakete grün, 255 Prüfungen der acht Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
814 lines
28 KiB
Go
814 lines
28 KiB
Go
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"
|
||
}
|