Files
Aether/backend/internal/webdav/fs.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
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>
2026-08-24 09:25:36 +02:00

874 lines
26 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 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/<Eigentümer> 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/<Eigentümer> 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/<Eigentümer>: Ebenen, hinter denen
// kein Ordner in der Datenbank steht.
virtual bool
// atShared unterscheidet die beiden: /Shared listet Eigentümer,
// /Shared/<Eigentümer> 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"
}