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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user