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>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+873
View File
@@ -0,0 +1,873 @@
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"
}