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"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"aether/internal/files"
|
||||
)
|
||||
|
||||
// Ein abgebrochener Upload darf nichts hinterlassen. x/net/webdav ruft Close
|
||||
// per defer auf – auch nach einem Fehler in io.Copy. Ohne die Sperre in Close
|
||||
// landete der halb gefüllte Puffer als vollständige Datei in Cloud und
|
||||
// Datenbank: der Client meldet einen Fehler, die Datei ist trotzdem da, nur
|
||||
// abgeschnitten – und die Prüfsumme passt zum Torso, fällt also nie auf.
|
||||
//
|
||||
// fileStore und storage sind hier absichtlich nil: greift Close doch darauf
|
||||
// zu, paniert der Test statt still durchzulaufen.
|
||||
func TestWriteFileCloseDiscardsAbortedUpload(t *testing.T) {
|
||||
t.Run("quota exceeded during write", func(t *testing.T) {
|
||||
f := &davWriteFile{
|
||||
ctx: context.Background(), name: "big.bin", expected: -1,
|
||||
written: 31, writeErr: files.ErrQuotaExceeded,
|
||||
}
|
||||
|
||||
if err := f.Close(); !errors.Is(err, files.ErrQuotaExceeded) {
|
||||
t.Fatalf("Close() = %v, want ErrQuotaExceeded", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("connection dropped mid-upload", func(t *testing.T) {
|
||||
f := &davWriteFile{
|
||||
ctx: context.Background(), name: "abort.bin",
|
||||
expected: 4096, written: 14,
|
||||
}
|
||||
|
||||
if err := f.Close(); err == nil {
|
||||
t.Fatal("Close() = nil, want error for incomplete upload")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Chunked Uploads kündigen keine Größe an (Content-Length -1). Dann kann Close
|
||||
// die Vollständigkeit nicht prüfen und darf deshalb nicht blockieren – sonst
|
||||
// wäre jeder chunked Upload kaputt.
|
||||
func TestWriteFileCloseAcceptsUnknownLength(t *testing.T) {
|
||||
f := &davWriteFile{
|
||||
ctx: context.Background(), name: "chunked.bin",
|
||||
expected: -1, written: 12,
|
||||
}
|
||||
|
||||
// Kommt bis zum Store durch – der ist nil, also panic statt Fehler.
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("Close() should have proceeded to the store, but returned early")
|
||||
}
|
||||
}()
|
||||
_ = f.Close()
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
xwebdav "golang.org/x/net/webdav"
|
||||
|
||||
"aether/internal/acl"
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/middleware"
|
||||
)
|
||||
|
||||
// Brute-force-Schutz für /dav/. Der Endpunkt nutzt Basic Auth und läuft am
|
||||
// Rate-Limit des normalen Logins vorbei – ohne eigene Bremse ließe sich hier
|
||||
// beliebig oft raten.
|
||||
const (
|
||||
davMaxFailures = 10
|
||||
davWindow = 15 * time.Minute
|
||||
davLockout = 15 * time.Minute
|
||||
)
|
||||
|
||||
// maxChallengeDrain begrenzt, wie viel von einem unauthentifizierten Upload
|
||||
// verworfen wird, um die Verbindung am Leben zu halten – siehe drainForChallenge.
|
||||
const maxChallengeDrain = 4 << 30 // 4 GiB
|
||||
|
||||
// Handler serves WebDAV at /dav/ with HTTP Basic Auth.
|
||||
type Handler struct {
|
||||
authStore *auth.Store
|
||||
fileStore *files.Store
|
||||
acl *acl.Store
|
||||
storage storage.Storage
|
||||
locks xwebdav.LockSystem
|
||||
limiter *middleware.FailureLimiter
|
||||
}
|
||||
|
||||
func NewHandler(authStore *auth.Store, fileStore *files.Store, aclStore *acl.Store, st storage.Storage) *Handler {
|
||||
return &Handler{
|
||||
authStore: authStore,
|
||||
fileStore: fileStore,
|
||||
acl: aclStore,
|
||||
storage: st,
|
||||
locks: xwebdav.NewMemLS(),
|
||||
limiter: middleware.NewFailureLimiter(davMaxFailures, davWindow, davLockout),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := middleware.RealIP(r)
|
||||
if allowed, retryAfter := h.limiter.Allowed(clientIP); !allowed {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1))
|
||||
http.Error(w, "Too many failed login attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || username == "" || password == "" {
|
||||
drainForChallenge(r)
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Nur App-Passwörter. Das Kontopasswort ist zugleich der Zugang zum
|
||||
// Admin-Bereich – wer es hier errät, bekommt weit mehr als Dateizugriff.
|
||||
// Ein App-Passwort öffnet ausschließlich diesen Endpunkt und lässt sich
|
||||
// einzeln widerrufen, ohne dass jeder andere Client neu eingerichtet werden
|
||||
// muss. Nebeneffekt: ein indizierter Lookup statt eines Argon2-Durchlaufs
|
||||
// bei jeder einzelnen Anfrage (gemessen 0,5 ms statt 22,7 ms).
|
||||
user, err := h.authStore.VerifyAppPassword(r.Context(), username, password)
|
||||
if err != nil {
|
||||
h.limiter.Fail(clientIP)
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
h.limiter.Reset(clientIP)
|
||||
|
||||
quotaHit, denied := false, false
|
||||
dav := &xwebdav.Handler{
|
||||
FileSystem: newFS(h.fileStore, h.storage, h.acl, user, "aHit, &denied, r.ContentLength),
|
||||
LockSystem: h.locks,
|
||||
Prefix: "/dav",
|
||||
}
|
||||
dav.ServeHTTP(&statusWriter{ResponseWriter: w, quotaHit: "aHit, denied: &denied}, r)
|
||||
}
|
||||
|
||||
// challenge fordert zur Anmeldung auf. Der Realm-Text nennt ausdrücklich das
|
||||
// App-Passwort: Clients zeigen ihn im Anmeldedialog an, und ohne diesen Hinweis
|
||||
// tippt man dort wieder und wieder sein Kontopasswort ein und versteht nicht,
|
||||
// warum es abgelehnt wird.
|
||||
func challenge(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Aether WebDAV - use an app password, not your account password"`)
|
||||
http.Error(w, "Unauthorized - WebDAV requires an app password (Profile -> App passwords)", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// drainForChallenge liest den Rumpf einer unauthentifizierten Anfrage weg,
|
||||
// bevor die Aufforderung zur Anmeldung rausgeht.
|
||||
//
|
||||
// Klingt verschwenderisch, ist aber der Unterschied zwischen „Upload geht" und
|
||||
// „Upload geht nicht": Manche Clients – KDEs KIO etwa – schicken grundsätzlich
|
||||
// erst ohne Zugangsdaten und wiederholen die Anfrage nach der 401. Dabei senden
|
||||
// sie den Rumpf sofort mit, ohne `Expect: 100-continue` abzuwarten. Antwortet
|
||||
// der Server dann sofort mit 401, kann Go den angefangenen Rumpf nicht mehr
|
||||
// loswerden und schließt die Verbindung. Der Client steckt aber noch mitten im
|
||||
// Schreiben, bekommt einen Verbindungsabbruch und meldet einen nichtssagenden
|
||||
// Fehler, statt die 401 zu lesen und sich anzumelden.
|
||||
//
|
||||
// Wer `Expect: 100-continue` schickt (curl, rclone, Finder), zahlt hier nichts:
|
||||
// Go beantwortet die Aufforderung, bevor auch nur ein Byte des Rumpfes fließt.
|
||||
// Die doppelte Übertragung trifft also nur Clients, die es nicht besser machen.
|
||||
func drainForChallenge(r *http.Request) {
|
||||
if r.ContentLength <= 0 || r.Header.Get("Expect") != "" {
|
||||
return
|
||||
}
|
||||
// Obergrenze, damit eine unauthentifizierte Anfrage den Server nicht
|
||||
// beliebig lange beschäftigen kann. Darüber bleibt es beim Abbruch.
|
||||
_, _ = io.CopyN(io.Discard, r.Body, maxChallengeDrain)
|
||||
}
|
||||
|
||||
// statusWriter korrigiert Statuscodes, die x/net/webdav pauschal vergibt.
|
||||
//
|
||||
// Die Bibliothek bildet jeden Fehler aus einer Operation auf einen festen Code
|
||||
// ab – ein gescheiterter io.Copy wird zu 405, ein OpenFile-Fehler zu 404. Der
|
||||
// Client zeigt dann „Methode nicht erlaubt" oder „nicht gefunden", obwohl die
|
||||
// Ursache eine ganz andere war. Zwei Fälle lohnen die Korrektur:
|
||||
//
|
||||
// - Kontingent voll → 507 Insufficient Storage (RFC 4918)
|
||||
// - fehlendes Recht → 403 Forbidden
|
||||
//
|
||||
// Ohne die zweite Zuordnung sähe jemand, der in einem nur lesbar freigegebenen
|
||||
// Ordner schreiben will, ein „nicht gefunden" – und suchte den Fehler beim
|
||||
// Pfad statt bei seinen Rechten.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
quotaHit *bool
|
||||
denied *bool
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
if w.wroteHeader {
|
||||
return
|
||||
}
|
||||
w.wroteHeader = true
|
||||
switch {
|
||||
case *w.quotaHit && code == http.StatusMethodNotAllowed:
|
||||
code = http.StatusInsufficientStorage
|
||||
case *w.denied && (code == http.StatusMethodNotAllowed || code == http.StatusNotFound || code == http.StatusConflict):
|
||||
code = http.StatusForbidden
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/acl"
|
||||
"aether/internal/files"
|
||||
)
|
||||
|
||||
// Geteilte Ordner und Gruppenordner über WebDAV.
|
||||
//
|
||||
// WebDAV kennt keine IDs, es navigiert über Pfade – und ein Pfad lief bisher
|
||||
// immer durch den Baum genau eines Prinzipals: des angemeldeten Nutzers. Damit
|
||||
// waren Gruppenordner und Freigaben dort unsichtbar; sie gehören einer Gruppe
|
||||
// oder einem anderen Konto.
|
||||
//
|
||||
// Der Aufbau bildet die Weboberfläche nach, damit man nicht umdenken muss:
|
||||
//
|
||||
// /dav/<Gruppenordner>/… – wie in der Wurzel der Weboberfläche
|
||||
// /dav/Shared/<Person>/<Freigabe>/… – wie die Ansicht „Shared with me"
|
||||
//
|
||||
// Beim Betreten wechselt der Prinzipal. Personen-Freigaben bleiben verschachtelt:
|
||||
// Ein flacher Aufbau müsste bei gleichnamigen Freigaben Namen vergeben, die sich
|
||||
// ändern, sobald eine weitere dazukommt – und ein Sync-Client, dem sich Pfade
|
||||
// unter den Füßen verschieben, lädt alles neu hoch oder löscht es.
|
||||
const sharedRoot = "Shared"
|
||||
|
||||
// davScope beschreibt, in wessen Namen ein Pfad aufgelöst wird.
|
||||
type davScope struct {
|
||||
principal uuid.UUID
|
||||
storageRoot string
|
||||
quota int64
|
||||
writable bool
|
||||
|
||||
// base ist der Ordner, ab dem parts zählen. nil = Wurzel des Prinzipals.
|
||||
// Bei einer Freigabe zeigt er auf den freigegebenen Ordner, damit alles
|
||||
// darüber unerreichbar bleibt.
|
||||
base *uuid.UUID
|
||||
parts []string
|
||||
|
||||
// sharedFile ist gesetzt, wenn der Pfad genau auf eine einzeln freigegebene
|
||||
// Datei zeigt. Ohne dieses Feld liefe die Auflösung in die Wurzel des
|
||||
// Eigentümers – und die gilt als Ordner, wodurch die Datei als Verzeichnis
|
||||
// gemeldet würde.
|
||||
sharedFile *files.File
|
||||
|
||||
// virtual markiert die beiden Ebenen ohne echten Ordner dahinter:
|
||||
// /Shared und /Shared/<Eigentümer>.
|
||||
virtual bool
|
||||
// virtualEntries sind die Einträge, die eine virtuelle Ebene auflistet.
|
||||
virtualEntries []sharedEntry
|
||||
}
|
||||
|
||||
// sharedEntry ist eine Freigabe oder ein Gruppenordner, wie er unter /Shared
|
||||
// erscheint.
|
||||
type sharedEntry struct {
|
||||
owner string // Verzeichnisname der Ebene 2
|
||||
name string // Name der Freigabe selbst
|
||||
principal uuid.UUID
|
||||
root string // Speicherwurzel des Prinzipals
|
||||
quota int64
|
||||
writable bool
|
||||
folderID *uuid.UUID // gesetzt bei Ordnern
|
||||
file *files.File
|
||||
}
|
||||
|
||||
// forbid meldet dem Handler ein fehlendes Recht und liefert den passenden
|
||||
// Fehler. Nur für Ablehnungen aus Rechtegründen – nicht für „gibt es nicht",
|
||||
// sonst würde eine schlicht fehlende Datei als „verboten" gemeldet und
|
||||
// verriete damit, dass an dem Pfad etwas ist.
|
||||
func (fs *aetherFS) forbid() error {
|
||||
if fs.denied != nil {
|
||||
*fs.denied = true
|
||||
}
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
func (fs *aetherFS) ownScope(parts []string) *davScope {
|
||||
return &davScope{
|
||||
principal: fs.user.ID,
|
||||
storageRoot: "users/" + fs.user.Username,
|
||||
quota: fs.user.QuotaBytes,
|
||||
writable: true,
|
||||
parts: parts,
|
||||
}
|
||||
}
|
||||
|
||||
// scopeFor klärt für einen WebDAV-Pfad, welcher Prinzipal gilt und welcher
|
||||
// Rest des Pfades in dessen Baum aufzulösen ist.
|
||||
func (fs *aetherFS) scopeFor(ctx context.Context, parts []string) (*davScope, error) {
|
||||
if len(parts) == 0 {
|
||||
return fs.ownScope(parts), nil
|
||||
}
|
||||
|
||||
if parts[0] != sharedRoot {
|
||||
// Gruppenordner liegen in der Wurzel, gleichrangig neben den eigenen –
|
||||
// so wie in der Weboberfläche. Die Namen sind bereits eindeutig
|
||||
// gemacht, ein eigener Ordner gewinnt also bei Gleichheit.
|
||||
groups, err := fs.rootGroupFolders(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range groups {
|
||||
if e.name == parts[0] {
|
||||
return &davScope{
|
||||
principal: e.principal, storageRoot: e.root, quota: e.quota,
|
||||
writable: e.writable, base: e.folderID, parts: parts[1:],
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return fs.ownScope(parts), nil
|
||||
}
|
||||
|
||||
if fs.acl == nil {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
entries, err := fs.aclShares(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Gibt es nichts Geliehenes, gibt es auch kein /Shared. Sonst bliebe bei
|
||||
// Clients ein leerer Ordner stehen, den niemand loswird.
|
||||
if len(entries) == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
// /Shared – Liste der Eigentümer.
|
||||
if len(parts) == 1 {
|
||||
return &davScope{virtual: true, virtualEntries: entries}, nil
|
||||
}
|
||||
|
||||
// /Shared/<Eigentümer> – Liste von dessen Freigaben.
|
||||
owned := []sharedEntry{}
|
||||
for _, e := range entries {
|
||||
if e.owner == parts[1] {
|
||||
owned = append(owned, e)
|
||||
}
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
return &davScope{virtual: true, virtualEntries: owned}, nil
|
||||
}
|
||||
|
||||
// /Shared/<Eigentümer>/<Freigabe>/… – ab hier gilt ein echter Prinzipal.
|
||||
for _, e := range owned {
|
||||
if e.name != parts[2] {
|
||||
continue
|
||||
}
|
||||
if e.file != nil {
|
||||
// Eine einzeln freigegebene Datei hat nichts unter sich.
|
||||
if len(parts) > 3 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return &davScope{
|
||||
principal: e.principal, storageRoot: e.root, quota: e.quota,
|
||||
writable: e.writable, sharedFile: e.file,
|
||||
}, nil
|
||||
}
|
||||
return &davScope{
|
||||
principal: e.principal, storageRoot: e.root, quota: e.quota,
|
||||
writable: e.writable, base: e.folderID, parts: parts[3:],
|
||||
}, nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
// rootGroupFolders liefert die Gruppenordner des Nutzers mit dem Namen, unter
|
||||
// dem sie in der Wurzel erscheinen.
|
||||
//
|
||||
// Bei Namensgleichheit gewinnt der eigene Ordner und der Gruppenordner bekommt
|
||||
// den Gruppennamen angehängt. Grund: WebDAV adressiert über Pfade – zwei
|
||||
// gleichnamige Einträge in einem Verzeichnis kann ein Client nicht auseinander-
|
||||
// halten. In der Weboberfläche ist das harmlos, dort führen IDs zum Ziel.
|
||||
func (fs *aetherFS) rootGroupFolders(ctx context.Context) ([]sharedEntry, error) {
|
||||
groupFolders, err := fs.fileStore.ListGroupFoldersForUser(ctx, fs.user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(groupFolders) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ownFolders, err := fs.fileStore.ListFolders(ctx, fs.user.ID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
taken := map[string]bool{}
|
||||
for _, f := range ownFolders {
|
||||
taken[f.Name] = true
|
||||
}
|
||||
|
||||
out := make([]sharedEntry, 0, len(groupFolders))
|
||||
for _, gf := range groupFolders {
|
||||
if gf.GroupID == nil || fs.acl == nil {
|
||||
continue
|
||||
}
|
||||
info, err := fs.acl.ResolveFolder(ctx, fs.user.ID, gf.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
group, _ := fs.fileStore.GroupName(ctx, *gf.GroupID)
|
||||
|
||||
name := gf.Name
|
||||
if taken[name] && group != "" {
|
||||
name = gf.Name + " (" + group + ")"
|
||||
}
|
||||
for n := 2; taken[name]; n++ {
|
||||
name = gf.Name + " (" + strconv.Itoa(n) + ")"
|
||||
}
|
||||
taken[name] = true
|
||||
|
||||
id := gf.ID
|
||||
out = append(out, sharedEntry{
|
||||
owner: group,
|
||||
name: name,
|
||||
principal: info.OwnerID,
|
||||
root: info.StorageRoot(),
|
||||
quota: info.OwnerQuota,
|
||||
writable: info.CanWrite(),
|
||||
folderID: &id,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// aclShares sammelt, was andere Personen freigegeben haben – der Inhalt von
|
||||
// /Shared. Gruppenordner gehören nicht dazu, die liegen in der Wurzel.
|
||||
func (fs *aetherFS) aclShares(ctx context.Context) ([]sharedEntry, error) {
|
||||
var out []sharedEntry
|
||||
|
||||
shared, err := fs.acl.ListSharedWith(ctx, fs.user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range shared {
|
||||
e := sharedEntry{
|
||||
owner: r.OwnerName,
|
||||
name: r.Name,
|
||||
writable: r.Permission == acl.PermWrite,
|
||||
}
|
||||
if r.Type == "folder" {
|
||||
info, err := fs.acl.ResolveFolder(ctx, fs.user.ID, r.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
id := r.ID
|
||||
e.principal, e.root, e.quota = info.OwnerID, info.StorageRoot(), info.OwnerQuota
|
||||
e.folderID = &id
|
||||
} else {
|
||||
info, err := fs.acl.ResolveFile(ctx, fs.user.ID, r.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
f, err := fs.fileStore.GetFile(ctx, r.ID, info.OwnerID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
e.principal, e.root, e.quota = info.OwnerID, info.StorageRoot(), info.OwnerQuota
|
||||
e.file = f
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
|
||||
return dedupe(out), nil
|
||||
}
|
||||
|
||||
// dedupe stellt sicher, dass in einem Verzeichnis kein Name zweimal vorkommt –
|
||||
// zwei gleichnamige Einträge machen eine PROPFIND-Antwort für Clients
|
||||
// unbrauchbar. Sortiert wird vorher, damit die Vergabe bei gleichem Bestand
|
||||
// immer gleich ausfällt und Pfade sich nicht von Lauf zu Lauf ändern.
|
||||
func dedupe(entries []sharedEntry) []sharedEntry {
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].owner != entries[j].owner {
|
||||
return entries[i].owner < entries[j].owner
|
||||
}
|
||||
return entries[i].name < entries[j].name
|
||||
})
|
||||
seen := map[string]int{}
|
||||
for i := range entries {
|
||||
key := entries[i].owner + "/" + entries[i].name
|
||||
seen[key]++
|
||||
if n := seen[key]; n > 1 {
|
||||
entries[i].name += " (" + strconv.Itoa(n) + ")"
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// owners fasst die Einträge zur zweiten Ebene zusammen.
|
||||
func owners(entries []sharedEntry) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if !seen[e.owner] {
|
||||
seen[e.owner] = true
|
||||
out = append(out, e.owner)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package webdav
|
||||
|
||||
import "testing"
|
||||
|
||||
// dedupe vergibt Namen für gleichnamige Freigaben. Entscheidend ist, dass die
|
||||
// Vergabe bei gleichem Bestand immer gleich ausfällt: Ein Sync-Client, dem sich
|
||||
// Pfade zwischen zwei Läufen verschieben, lädt alles neu hoch oder löscht es.
|
||||
func TestDedupeIsStable(t *testing.T) {
|
||||
in := []sharedEntry{
|
||||
{owner: "anna", name: "Projekt"},
|
||||
{owner: "ben", name: "Projekt"},
|
||||
{owner: "anna", name: "Projekt"},
|
||||
{owner: "anna", name: "Notizen"},
|
||||
}
|
||||
first := namesOf(dedupe(append([]sharedEntry(nil), in...)))
|
||||
|
||||
// Andere Eingabereihenfolge, gleicher Bestand → gleiches Ergebnis.
|
||||
shuffled := []sharedEntry{in[3], in[2], in[1], in[0]}
|
||||
second := namesOf(dedupe(shuffled))
|
||||
|
||||
if len(first) != len(second) {
|
||||
t.Fatalf("unterschiedlich viele Einträge: %v vs %v", first, second)
|
||||
}
|
||||
for i := range first {
|
||||
if first[i] != second[i] {
|
||||
t.Fatalf("Reihenfolge nicht stabil:\n %v\n %v", first, second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeMakesNamesUniquePerOwner(t *testing.T) {
|
||||
got := namesOf(dedupe([]sharedEntry{
|
||||
{owner: "anna", name: "Projekt"},
|
||||
{owner: "anna", name: "Projekt"},
|
||||
{owner: "anna", name: "Projekt"},
|
||||
// Gleicher Name bei einem anderen Eigentümer ist kein Konflikt – die
|
||||
// beiden liegen in verschiedenen Verzeichnissen.
|
||||
{owner: "ben", name: "Projekt"},
|
||||
}))
|
||||
want := []string{"anna/Projekt", "anna/Projekt (2)", "anna/Projekt (3)", "ben/Projekt"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("Eintrag %d = %q, erwartet %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// owners liefert die zweite Ebene unter /Shared – jede Person genau einmal.
|
||||
func TestOwnersAreUnique(t *testing.T) {
|
||||
got := owners([]sharedEntry{
|
||||
{owner: "anna"}, {owner: "anna"}, {owner: "ben"}, {owner: "anna"},
|
||||
})
|
||||
if len(got) != 2 || got[0] != "anna" || got[1] != "ben" {
|
||||
t.Fatalf("owners = %v, erwartet [anna ben]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func namesOf(entries []sharedEntry) []string {
|
||||
out := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
out[i] = e.owner + "/" + e.name
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user