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"
}
+59
View File
@@ -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()
}
+155
View File
@@ -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, &quotaHit, &denied, r.ContentLength),
LockSystem: h.locks,
Prefix: "/dav",
}
dav.ServeHTTP(&statusWriter{ResponseWriter: w, quotaHit: &quotaHit, 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)
}
+310
View File
@@ -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
}
+64
View File
@@ -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
}