package webdav import ( "bytes" "context" "fmt" "io" "os" "path" "strings" "time" "github.com/google/uuid" "github.com/rs/zerolog/log" xwebdav "golang.org/x/net/webdav" "aether/internal/acl" "aether/internal/auth" "aether/internal/files" "aether/internal/storage" ) // aetherFS implements webdav.FileSystem for a single authenticated user. type aetherFS struct { fileStore *files.Store storage storage.Storage acl *acl.Store user *auth.User // quotaHit meldet dem Handler, dass ein Schreibvorgang am Kontingent // gescheitert ist. Nötig, weil x/net/webdav jeden Fehler aus io.Copy // pauschal als 405 beantwortet – für „voll" ist 507 der richtige Code. quotaHit *bool // denied meldet dem Handler, dass eine Aktion an fehlenden Rechten // gescheitert ist – siehe statusWriter. denied *bool // contentLength ist die vom Client angekündigte Größe des Request-Bodys // (-1 bei chunked). Close vergleicht damit, ob der Upload vollständig // ankam – siehe davWriteFile.Close. contentLength int64 } func newFS(fileStore *files.Store, st storage.Storage, aclStore *acl.Store, user *auth.User, quotaHit, denied *bool, contentLength int64) xwebdav.FileSystem { return &aetherFS{ fileStore: fileStore, storage: st, acl: aclStore, user: user, quotaHit: quotaHit, denied: denied, contentLength: contentLength, } } // splitPath converts a WebDAV path into clean path parts. func splitPath(name string) []string { name = path.Clean("/" + name) if name == "/" { return nil } return strings.Split(strings.TrimPrefix(name, "/"), "/") } // resolve löst einen WebDAV-Pfad auf. Der Scope bestimmt vorher, in wessen // Namen das geschieht – eigener Baum, Gruppenordner oder fremde Freigabe. func (fs *aetherFS) resolve(ctx context.Context, name string) (folder *files.Folder, file *files.File, parentID *uuid.UUID, sc *davScope, err error) { sc, serr := fs.scopeFor(ctx, splitPath(name)) if serr != nil { return nil, nil, nil, nil, serr } // Virtuelle Ebenen haben keinen Ordner dahinter; der Aufrufer erkennt das // an sc.virtual und behandelt sie als Verzeichnis ohne Inhalt in der DB. if sc.virtual { return nil, nil, nil, sc, nil } if sc.sharedFile != nil { return nil, sc.sharedFile, sc.sharedFile.FolderID, sc, nil } if f, ferr := fs.fileStore.FolderByPathFrom(ctx, sc.principal, sc.base, sc.parts); ferr == nil { return f, nil, nil, sc, nil } if len(sc.parts) == 0 { return nil, nil, nil, sc, os.ErrNotExist } parentParts := sc.parts[:len(sc.parts)-1] fileName := sc.parts[len(sc.parts)-1] parent, perr := fs.fileStore.FolderByPathFrom(ctx, sc.principal, sc.base, parentParts) if perr != nil { return nil, nil, nil, sc, os.ErrNotExist } var pid *uuid.UUID if parent.ID != uuid.Nil { pid = &parent.ID } fi, fierr := fs.fileStore.FileInFolder(ctx, sc.principal, pid, fileName) if fierr != nil { return nil, nil, pid, sc, os.ErrNotExist } return nil, fi, pid, sc, nil } func (fs *aetherFS) Mkdir(ctx context.Context, name string, _ os.FileMode) error { parts := splitPath(name) if len(parts) == 0 { return fs.forbid() } sc, err := fs.scopeFor(ctx, parts[:len(parts)-1]) if err != nil { return os.ErrNotExist } // In /Shared und /Shared/ lässt sich nichts anlegen – die // Ebenen sind virtuell, es gäbe keinen Ort, an dem der Ordner läge. if sc.virtual || !sc.writable { return fs.forbid() } folderName := parts[len(parts)-1] parent, err := fs.fileStore.FolderByPathFrom(ctx, sc.principal, sc.base, sc.parts) if err != nil { return os.ErrNotExist } var parentID *uuid.UUID if parent.ID != uuid.Nil { parentID = &parent.ID } // Der neue Ordner gehört dem Prinzipal des Elternordners, nicht dem, der // ihn anlegt – sonst stünde in einem Gruppenordner plötzlich ein privater. f := &files.Folder{Name: folderName, ParentID: parentID} if sc.principal == fs.user.ID { id := fs.user.ID f.OwnerID = &id } else if parent.GroupID != nil { f.GroupID = parent.GroupID } else { f.OwnerID = parent.OwnerID } _, err = fs.fileStore.CreateFolder(ctx, f) return err } func (fs *aetherFS) RemoveAll(ctx context.Context, name string) error { folder, file, _, sc, err := fs.resolve(ctx, name) if err != nil { return err } // /Shared und /Shared/ gibt es nicht wirklich – da ist nichts // zu löschen. Und ohne Schreibrecht wird auch darunter nichts entfernt. if sc.virtual || !sc.writable { return fs.forbid() } if folder != nil && folder.ID != uuid.Nil { return fs.fileStore.DeleteFolder(ctx, folder.ID, sc.principal) } if file != nil { trashKey := fmt.Sprintf("%s/.trash/%s_%s", sc.storageRoot, uuid.New().String()[:8], file.Name) if merr := fs.storage.Move(ctx, file.StorageKey, trashKey); merr != nil { return merr } return fs.fileStore.SoftDeleteFile(ctx, file.ID, sc.principal, trashKey) } return os.ErrNotExist } func (fs *aetherFS) Rename(ctx context.Context, oldName, newName string) error { newParts := splitPath(newName) if len(newParts) == 0 { return fs.forbid() } newScope, err := fs.scopeFor(ctx, newParts[:len(newParts)-1]) if err != nil { return os.ErrNotExist } if newScope.virtual || !newScope.writable { return fs.forbid() } newBaseName := newParts[len(newParts)-1] newParent, err := fs.fileStore.FolderByPathFrom(ctx, newScope.principal, newScope.base, newScope.parts) if err != nil { return os.ErrNotExist } var newParentID *uuid.UUID if newParent.ID != uuid.Nil { newParentID = &newParent.ID } folder, file, oldParentID, oldScope, err := fs.resolve(ctx, oldName) if err != nil { return err } if oldScope.virtual || !oldScope.writable { return fs.forbid() } // Über Eigentumsgrenzen hinweg wird nicht verschoben: Das buchte Bytes // zwischen Kontingenten um und hängte Freigaben unter der Hand um. // Herunterladen und neu hochladen ist der ehrliche Weg. if oldScope.principal != newScope.principal { return fs.forbid() } if file != nil { // Rename (same parent) or move (different parent). sameFolder := ptrEqual(oldParentID, newParentID) renamed := newBaseName != file.Name if sameFolder && !renamed { return nil } // Der Storage-Pfad enthält Ordner und Dateinamen – der Blob muss der // Umbenennung folgen, sonst zeigt der Schlüssel in der Datenbank ins Leere. newFolderPath, err := fs.fileStore.GetFolderPath(ctx, newParentID) if err != nil { return err } newKey := files.BuildStorageKey(newScope.storageRoot, newFolderPath, newBaseName) movedBlob := false if newKey != file.StorageKey { if err := fs.storage.Move(ctx, file.StorageKey, newKey); err != nil { return err } movedBlob = true } // Scheitert die Datenbank, muss der Blob zurück – sonst laufen Metadaten // und Storage auseinander. rollback := func() { if !movedBlob { return } if err := fs.storage.Move(ctx, newKey, file.StorageKey); err != nil { log.Ctx(ctx).Error().Err(err). Str("file_id", file.ID.String()). Msg("webdav: rollback of rename move failed – blob orphaned") } } if !sameFolder { if _, err = fs.fileStore.MoveFile(ctx, file.ID, oldScope.principal, newParentID, newKey); err != nil { rollback() return err } } if renamed { if _, err = fs.fileStore.RenameFile(ctx, file.ID, oldScope.principal, newBaseName, newKey); err != nil { rollback() return err } } return nil } if folder != nil && folder.ID != uuid.Nil { sameParent := true if folder.ParentID != nil { sameParent = newParentID != nil && *folder.ParentID == *newParentID } else { sameParent = newParentID == nil } renamed := newBaseName != folder.Name if sameParent && !renamed { return nil } // Dateien vor der Umbenennung einsammeln – danach sind die alten // Storage-Schlüssel nicht mehr rekonstruierbar. affected, err := fs.fileStore.ListFilesInSubtree(ctx, oldScope.principal, folder.ID) if err != nil { return err } if renamed { if _, err = fs.fileStore.RenameFolder(ctx, folder.ID, oldScope.principal, newBaseName); err != nil { return err } } if !sameParent { if _, err = fs.fileStore.MoveFolder(ctx, folder.ID, oldScope.principal, newParentID); err != nil { return err } } // Der Ordnername steckt im Pfad jeder Datei darunter – Blobs nachziehen. files.ResyncBlobPaths(ctx, fs.fileStore, fs.storage, newScope.storageRoot, affected) return nil } return os.ErrNotExist } func (fs *aetherFS) Stat(ctx context.Context, name string) (os.FileInfo, error) { folder, file, _, sc, err := fs.resolve(ctx, name) if err != nil { return nil, err } if sc != nil && sc.virtual { return &davFileInfo{name: path.Base(name), modTime: time.Now(), isDir: true}, nil } if folder != nil { modTime := folder.CreatedAt if modTime.IsZero() { modTime = time.Now() } return &davFileInfo{name: path.Base(name), modTime: modTime, isDir: true}, nil } return &davFileInfo{name: file.Name, size: file.Size, modTime: file.UpdatedAt}, nil } func (fs *aetherFS) OpenFile(ctx context.Context, name string, flag int, _ os.FileMode) (xwebdav.File, error) { parts := splitPath(name) sc, err := fs.scopeFor(ctx, parts) if err != nil { return nil, err } writing := flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE) != 0 // Virtuelle Ebenen: Verzeichnisse ohne Eintrag in der Datenbank. if sc.virtual { if writing { return nil, fs.forbid() } return &davDir{ name: path.Base(name), modTime: time.Now(), virtual: true, shared: sc.virtualEntries, atShared: len(parts) == 1, }, nil } // Einzeln freigegebene Datei – der Scope zeigt direkt auf sie. if sc.sharedFile != nil { if writing { return nil, fs.forbid() } return fs.openForReading(ctx, sc.sharedFile) } // Verzeichnis im Baum des Prinzipals. if folder, ferr := fs.fileStore.FolderByPathFrom(ctx, sc.principal, sc.base, sc.parts); ferr == nil { if writing { return nil, fs.forbid() } var folderID *uuid.UUID if folder.ID != uuid.Nil { folderID = &folder.ID } d := &davDir{ name: path.Base(name), modTime: folder.CreatedAt, fileStore: fs.fileStore, user: fs.user, principal: sc.principal, folderID: folderID, } // In der Wurzel kommen die Gruppenordner dazu – gleichrangig neben den // eigenen, wie in der Weboberfläche. Personen-Freigaben liegen dagegen // gebündelt unter „Shared", und dieser Eintrag erscheint nur, wenn es // überhaupt welche gibt. if len(parts) == 0 && fs.acl != nil { now := time.Now() if groups, err := fs.rootGroupFolders(ctx); err == nil { for _, g := range groups { d.extra = append(d.extra, &davFileInfo{name: g.name, modTime: now, isDir: true}) } } if shares, err := fs.aclShares(ctx); err == nil && len(shares) > 0 { d.extra = append(d.extra, &davFileInfo{name: sharedRoot, modTime: now, isDir: true}) } } return d, nil } if len(parts) == 0 { return nil, os.ErrNotExist } if len(sc.parts) == 0 { return nil, os.ErrNotExist } fileName := sc.parts[len(sc.parts)-1] parent, err := fs.fileStore.FolderByPathFrom(ctx, sc.principal, sc.base, sc.parts[:len(sc.parts)-1]) if err != nil { return nil, os.ErrNotExist } var parentID *uuid.UUID if parent.ID != uuid.Nil { parentID = &parent.ID } // Write mode. if writing { if !sc.writable { return nil, fs.forbid() } return &davWriteFile{ ctx: ctx, name: fileName, folderID: parentID, user: fs.user, principal: sc.principal, storageRoot: sc.storageRoot, quota: sc.quota, parentOwner: parent, fileStore: fs.fileStore, storage: fs.storage, quotaHit: fs.quotaHit, expected: fs.contentLength, }, nil } file, err := fs.fileStore.FileInFolder(ctx, sc.principal, parentID, fileName) if err != nil { return nil, os.ErrNotExist } return fs.openForReading(ctx, file) } // virtualEntries baut die Auflistung der beiden künstlichen Ebenen. func virtualEntries(shared []sharedEntry, atShared bool) []os.FileInfo { now := time.Now() if atShared { out := []os.FileInfo{} for _, owner := range owners(shared) { out = append(out, &davFileInfo{name: owner, modTime: now, isDir: true}) } return out } out := []os.FileInfo{} for _, e := range shared { if e.file != nil { out = append(out, &davFileInfo{ name: e.name, size: e.file.Size, modTime: e.file.UpdatedAt, }) continue } out = append(out, &davFileInfo{name: e.name, modTime: now, isDir: true}) } return out } func (fs *aetherFS) openForReading(ctx context.Context, file *files.File) (xwebdav.File, error) { rc, err := fs.storage.Get(ctx, file.StorageKey) if err != nil { return nil, err } defer rc.Close() data, err := io.ReadAll(rc) if err != nil { return nil, err } return &davReadFile{ name: file.Name, size: file.Size, modTime: file.UpdatedAt, reader: bytes.NewReader(data), }, nil } // ── FileInfo ────────────────────────────────────────────────────────────────── type davFileInfo struct { name string size int64 modTime time.Time isDir bool } func (fi *davFileInfo) Name() string { return fi.name } func (fi *davFileInfo) Size() int64 { return fi.size } func (fi *davFileInfo) Mode() os.FileMode { return 0644 } func (fi *davFileInfo) ModTime() time.Time { return fi.modTime } func (fi *davFileInfo) IsDir() bool { return fi.isDir } func (fi *davFileInfo) Sys() any { return nil } // ── Directory ──────────────────────────────────────────────────────────────── type davDir struct { name string modTime time.Time fileStore *files.Store user *auth.User folderID *uuid.UUID entries []os.FileInfo pos int // principal ist der Eigentümer des Ordners – bei geliehenen Ordnern nicht // der angemeldete Nutzer. principal uuid.UUID // virtual markiert /Shared und /Shared/: Ebenen, hinter denen // kein Ordner in der Datenbank steht. virtual bool // atShared unterscheidet die beiden: /Shared listet Eigentümer, // /Shared/ listet dessen Freigaben. atShared bool shared []sharedEntry // extra hängt zusätzliche Einträge an eine echte Auflistung – in der // eigenen Wurzel den virtuellen Shared-Ordner. extra []os.FileInfo } func (d *davDir) Close() error { return nil } func (d *davDir) Read(_ []byte) (int, error) { return 0, io.EOF } func (d *davDir) Seek(_ int64, _ int) (int64, error) { return 0, nil } func (d *davDir) Write(_ []byte) (int, error) { return 0, os.ErrPermission } func (d *davDir) Stat() (os.FileInfo, error) { name := d.name if name == "" { name = "/" } modTime := d.modTime if modTime.IsZero() { modTime = time.Now() } return &davFileInfo{name: name, modTime: modTime, isDir: true}, nil } func (d *davDir) Readdir(count int) ([]os.FileInfo, error) { if d.entries == nil { if err := d.loadEntries(); err != nil { return nil, err } } if d.pos >= len(d.entries) { if count > 0 { return nil, io.EOF } return nil, nil } end := len(d.entries) if count > 0 && d.pos+count < end { end = d.pos + count } out := d.entries[d.pos:end] d.pos = end return out, nil } func (d *davDir) loadEntries() error { if d.virtual { d.entries = virtualEntries(d.shared, d.atShared) return nil } ctx := context.Background() childFolders, err := d.fileStore.ListFolders(ctx, d.principal, d.folderID) if err != nil { return err } childFiles, err := d.fileStore.ListFiles(ctx, d.principal, d.folderID) if err != nil { return err } d.entries = make([]os.FileInfo, 0, len(childFolders)+len(childFiles)) for _, f := range childFolders { d.entries = append(d.entries, &davFileInfo{name: f.Name, modTime: f.CreatedAt, isDir: true}) } for _, f := range childFiles { d.entries = append(d.entries, &davFileInfo{name: f.Name, size: f.Size, modTime: f.UpdatedAt}) } d.entries = append(d.entries, d.extra...) return nil } // ── Read file ──────────────────────────────────────────────────────────────── type davReadFile struct { name string size int64 modTime time.Time reader *bytes.Reader } func (f *davReadFile) Close() error { return nil } func (f *davReadFile) Read(p []byte) (int, error) { return f.reader.Read(p) } func (f *davReadFile) Seek(o int64, w int) (int64, error) { return f.reader.Seek(o, w) } func (f *davReadFile) Write(_ []byte) (int, error) { return 0, os.ErrPermission } func (f *davReadFile) Readdir(_ int) ([]os.FileInfo, error) { return nil, os.ErrInvalid } func (f *davReadFile) Stat() (os.FileInfo, error) { return &davFileInfo{name: f.name, size: f.size, modTime: f.modTime}, nil } // ── Write file ─────────────────────────────────────────────────────────────── type davWriteFile struct { ctx context.Context name string folderID *uuid.UUID user *auth.User fileStore *files.Store storage storage.Storage // principal, storageRoot und quota beziehen sich auf den Eigentümer des // Zielordners – in einem Gruppenordner oder einer fremden Freigabe ist das // nicht der angemeldete Nutzer. Die Datei landet in dessen Bereich und // zählt gegen dessen Kontingent. principal uuid.UUID storageRoot string quota int64 parentOwner *files.Folder // Der Upload wird auf der Platte zwischengelagert, nicht im Arbeitsspeicher: // x/net/webdav übergibt den Inhalt häppchenweise, die endgültige Größe steht // erst bei Close fest. Gepuffert im RAM belegte eine 122-MB-Datei auch 122 MB // Speicher – bei mehreren Uploads gleichzeitig ist der Server damit weg. tmp *os.File written int64 // Kontingent, lazy beim ersten Write ermittelt – zu diesem Zeitpunkt steht // fest, ob eine bestehende Datei überschrieben wird. quotaChecked bool unlimited bool remaining int64 quotaHit *bool // expected ist die angekündigte Body-Größe, writeErr der erste Fehler aus // Write. Beides entscheidet in Close, ob überhaupt geschrieben wird. expected int64 writeErr error } // checkQuota ermittelt das verbleibende Kontingent einmalig pro Schreibvorgang. func (f *davWriteFile) checkQuota() error { if f.quotaChecked { return nil } f.quotaChecked = true // Wird eine bestehende Datei ersetzt, zählt deren alte Größe nicht mehr mit. var allowance int64 if existing, err := f.fileStore.FileInFolder(f.ctx, f.principal, f.folderID, f.name); err == nil { allowance = existing.Size } remaining, unlimited, err := f.fileStore.RemainingQuota(f.ctx, f.principal, f.quota, allowance) if err != nil { return err } f.remaining, f.unlimited = remaining, unlimited return nil } // discardTemp räumt die Zwischendatei weg. Muss auf jedem Pfad laufen, auch // beim Abbruch – sonst füllt jeder fehlgeschlagene Upload das Staging. func (f *davWriteFile) discardTemp() { if f.tmp == nil { return } name := f.tmp.Name() _ = f.tmp.Close() if err := os.Remove(name); err != nil && !os.IsNotExist(err) { log.Ctx(f.ctx).Warn().Err(err).Str("path", name).Msg("webdav: temp file not removed") } f.tmp = nil } func (f *davWriteFile) Close() error { defer f.discardTemp() // x/net/webdav ruft Close per defer auf – auch wenn io.Copy vorher // abgebrochen ist (Kontingent voll, Verbindung weg). Ohne diese Sperre // würde der halb gefüllte Puffer als vollständige Datei committet: der // Client sieht einen Fehler, in der Cloud liegt trotzdem ein abgeschnittenes // Blob – mit passender Prüfsumme, also unentdeckbar. Ein abgebrochener // Upload darf nichts hinterlassen. if f.writeErr != nil { return f.writeErr } if f.expected >= 0 && f.written != f.expected { return fmt.Errorf("incomplete upload: got %d of %d bytes", f.written, f.expected) } // Ab hier wird gelesen, was geschrieben wurde – zurück an den Anfang. content, err := f.contentReader() if err != nil { return err } // Gleicher lesbarer Pfad wie beim Web-Upload, damit beide Wege identisch // ablegen und die Dateien auch ohne Datenbank auffindbar bleiben. folderPath, err := f.fileStore.GetFolderPath(f.ctx, f.folderID) if err != nil { return err } storageKey := files.BuildStorageKey(f.storageRoot, folderPath, f.name) mime := mimeByName(f.name) existing, findErr := f.fileStore.FileInFolder(f.ctx, f.principal, f.folderID, f.name) // Overwrite: der neue Blob landet auf demselben lesbaren Pfad wie der alte. // Bei aktivierter Versionierung muss die bestehende Fassung deshalb erst // zur Seite – sonst überschriebe Put sie, und die Version zeigte auf den // neuen Inhalt. Ohne Versionierung ersetzt Put sie atomar. if findErr == nil { var versionKey string if existing.VersioningEnabled { versionKey = fmt.Sprintf("%s/.versions/%s", f.storageRoot, uuid.New().String()) if err := f.storage.Move(f.ctx, existing.StorageKey, versionKey); err != nil { return err } } checksum, size, err := f.storage.Put(f.ctx, storageKey, content) if err != nil { // Zurückrollen, sonst ist die bestehende Datei verloren. if versionKey != "" { if mvErr := f.storage.Move(f.ctx, versionKey, existing.StorageKey); mvErr != nil { log.Ctx(f.ctx).Error().Err(mvErr). Str("file_id", existing.ID.String()). Msg("webdav: rollback of version move failed – blob orphaned") } } return err } if versionKey != "" { if err := f.fileStore.CreateVersion(f.ctx, existing.ID, existing.Version, existing.Size, existing.Checksum, versionKey); err != nil { return err } } else if existing.StorageKey != storageKey { // Altbestand unter abweichendem Pfad – sonst verwaist der Blob. _ = f.storage.Delete(f.ctx, existing.StorageKey) } _, err = f.fileStore.UpdateFileContent(f.ctx, existing.ID, size, checksum, storageKey) return err } // New file. checksum, size, err := f.storage.Put(f.ctx, storageKey, content) if err != nil { return err } // Die neue Datei gehört dem Prinzipal des Zielordners: in einem // Gruppenordner der Gruppe, in einer fremden Freigabe deren Eigentümer. newFile := &files.File{ FolderID: f.folderID, Name: f.name, MimeType: mime, Size: size, Checksum: checksum, StorageKey: storageKey, } if f.parentOwner != nil && f.parentOwner.GroupID != nil { newFile.GroupID = f.parentOwner.GroupID } else { id := f.principal newFile.OwnerID = &id } if _, err := f.fileStore.CreateFile(f.ctx, newFile); err != nil { // Ohne DB-Zeile ist das Blob unerreichbar und würde für immer Platz // belegen. Eigener Kontext, weil f.ctx hier oft schon abgebrochen ist. cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(f.ctx), 10*time.Second) defer cancel() if delErr := f.storage.Delete(cleanupCtx, storageKey); delErr != nil { log.Ctx(f.ctx).Error().Err(delErr). Str("storage_key", storageKey). Msg("webdav: orphaned blob after failed file creation") } return err } return nil } func (f *davWriteFile) Read(_ []byte) (int, error) { return 0, os.ErrPermission } func (f *davWriteFile) Seek(_ int64, _ int) (int64, error) { return 0, nil } // contentReader spult die Zwischendatei zurück, damit Put sie von vorn lesen // kann. Ohne einen einzigen Write (leere Datei per PUT) gibt es keine – dann // liefert ein leerer Reader das richtige Ergebnis. func (f *davWriteFile) contentReader() (io.Reader, error) { if f.tmp == nil { return bytes.NewReader(nil), nil } if _, err := f.tmp.Seek(0, io.SeekStart); err != nil { return nil, fmt.Errorf("rewind temp file: %w", err) } return f.tmp, nil } // ensureTemp legt die Zwischendatei beim ersten Schreibzugriff an. Bevorzugt im // Staging-Verzeichnis des Speichers, damit sie auf derselben Platte liegt wie // die späteren Blobs; kann der Speicher das nicht, tut es das System-Temp. func (f *davWriteFile) ensureTemp() error { if f.tmp != nil { return nil } var err error if tf, ok := f.storage.(storage.TempFiler); ok { f.tmp, err = tf.CreateTemp("dav-upload-*") } else { f.tmp, err = os.CreateTemp("", "dav-upload-*") } if err != nil { return fmt.Errorf("create upload buffer: %w", err) } return nil } // Write lagert den Inhalt zwischen und bricht ab, sobald das Kontingent // überschritten wird – so wächst auch die Zwischendatei nicht über das // Erlaubte hinaus. func (f *davWriteFile) Write(p []byte) (int, error) { if err := f.checkQuota(); err != nil { return 0, err } if !f.unlimited { if int64(len(p)) > f.remaining { if f.quotaHit != nil { *f.quotaHit = true } f.writeErr = files.ErrQuotaExceeded return 0, files.ErrQuotaExceeded } f.remaining -= int64(len(p)) } if err := f.ensureTemp(); err != nil { f.writeErr = err return 0, err } n, err := f.tmp.Write(p) f.written += int64(n) if err != nil { f.writeErr = err } return n, err } func (f *davWriteFile) Readdir(_ int) ([]os.FileInfo, error) { return nil, os.ErrInvalid } func (f *davWriteFile) Stat() (os.FileInfo, error) { return &davFileInfo{name: f.name, modTime: time.Now()}, nil } // ── helpers ─────────────────────────────────────────────────────────────────── func ptrEqual(a, b *uuid.UUID) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } return *a == *b } func mimeByName(name string) string { ext := strings.ToLower(path.Ext(name)) m := map[string]string{ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".pdf": "application/pdf", ".txt": "text/plain", ".md": "text/markdown", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".zip": "application/zip", ".tar": "application/x-tar", } if t, ok := m[ext]; ok { return t } return "application/octet-stream" }