From be47d6844c18395aa38ea834b8181362df3a2b2b Mon Sep 17 00:00:00 2001 From: arnef Date: Sat, 29 Aug 2026 19:16:12 +0200 Subject: [PATCH] wip --- cmd/server/main.go | 13 ++++- internal/caldav/backend.go | 8 +-- internal/carddav/backend.go | 6 +-- internal/store/store.go | 105 ++++++++++++++++++++++++++++++++++++ internal/web/resources.go | 4 +- internal/webdav/handler.go | 15 +++++- 6 files changed, 138 insertions(+), 13 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 8649014..72e6b42 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -82,7 +82,7 @@ func main() { continue } for _, cal := range cals { - if err := st.EnsureCollection(user.Username, "cal-"+cal.Name); err != nil { + if err := st.EnsureCollection(user.Username, "col/calendars/"+cal.Name); err != nil { logger.Warn("creating calendar collection", "user", user.Username, "cal", cal.Name, "error", err) } } @@ -92,12 +92,21 @@ func main() { continue } for _, book := range books { - if err := st.EnsureCollection(user.Username, "card-"+book); err != nil { + if err := st.EnsureCollection(user.Username, "col/addressbooks/"+book); err != nil { logger.Warn("creating address book collection", "user", user.Username, "book", book, "error", err) } } } + // Run migration from legacy flat layout (cal-*/card-* dirs and data/files/) + // into new structured col/ subdirectory layout (/col//). + logger.Info("starting migration from old layout to new col/ subdirectory structure") + if err := store.MigrateDataDir(cfg.Storage.DataDir); err != nil { + logger.Error("running storage migration", "error", err) + os.Exit(1) + } + logger.Info("migration complete") + // ---- Middleware ---- authMw := auth.NewMiddleware(cfg, dbase, logger) diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go index c1c0903..aa0c524 100644 --- a/internal/caldav/backend.go +++ b/internal/caldav/backend.go @@ -96,7 +96,7 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) cals := []caldav.Calendar{b.birthdaysCalendarMeta(p.Username)} for _, cal := range names { - if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil { + if err := b.store.EnsureCollection(p.Username, "col/calendars/"+cal.Name); err != nil { b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err) continue } @@ -116,11 +116,11 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) disk, _ := b.store.ListCollections(p.Username) configured := make(map[string]bool) for _, cal := range names { - configured["cal-"+cal.Name] = true + configured["col/calendars/"+cal.Name] = true } for _, dir := range disk { - if strings.HasPrefix(dir, "cal-") && !configured[dir] { - name := strings.TrimPrefix(dir, "cal-") + if strings.HasPrefix(dir, "col/calendars/") && !configured[dir] { + name := strings.TrimPrefix(dir, "col/calendars/") cals = append(cals, b.calendarMeta(p.Username, name, name)) } } diff --git a/internal/carddav/backend.go b/internal/carddav/backend.go index b6b30c2..f0ec781 100644 --- a/internal/carddav/backend.go +++ b/internal/carddav/backend.go @@ -74,7 +74,7 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, var books []carddav.AddressBook for _, name := range names { - if err := b.store.EnsureCollection(p.Username, "card-"+name); err != nil { + if err := b.store.EnsureCollection(p.Username, "col/addressbooks/"+name); err != nil { b.logger.Warn("ensuring address book directory", "book", name, "error", err) continue } @@ -88,8 +88,8 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, configured["card-"+n] = true } for _, dir := range disk { - if strings.HasPrefix(dir, "card-") && !configured[dir] { - name := strings.TrimPrefix(dir, "card-") + if strings.HasPrefix(dir, "col/addressbooks/") && !configured[dir] { + name := strings.TrimPrefix(dir, "col/addressbooks/") books = append(books, b.bookMeta(p.Username, name, name)) } } diff --git a/internal/store/store.go b/internal/store/store.go index dd0c511..0cc53c4 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -195,6 +195,111 @@ func (s *Store) DeleteCollection(user, collection string) error { return err } +// cleanupLegacy moves a user's data from the old flat layout (cal-*/card-* dirs +// directly under user/root, and files/) into the new structured col/ +// subdirectory layout (/col/calendars/*, /col/addressbooks/*, +// /col/files). +func cleanupLegacy(dataDir, username string) error { + username = filepath.Base(username) // sanitize path traversal + userRoot := filepath.Join(dataDir, username) + + // 1 - Move cal-*/card-* flat directories into col/calendars/ / col/addressbooks/ + entries, err := os.ReadDir(userRoot) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + + for _, e := range entries { + if !e.IsDir() || e.Name() == "col" { + continue + } + name := e.Name() + var dst string + switch { + case strings.HasPrefix(name, "cal-"): + dst = filepath.Join(userRoot, "col", "calendars", strings.TrimPrefix(name, "cal-")) + case strings.HasPrefix(name, "card-"): + dst = filepath.Join(userRoot, "col", "addressbooks", strings.TrimPrefix(name, "card-")) + default: + continue + } + if _, err := os.Stat(dst); err == nil { + continue // already migrated + } + os.MkdirAll(filepath.Dir(dst), 0o755) + if err := os.Rename(filepath.Join(userRoot, name), dst); err != nil { + return fmt.Errorf("moving %s to %q: %w", name, dst, err) + } + } + + // 2 - Move files//* into /col/files/ + oldFilesRoot := filepath.Join(dataDir, "files", username) + if stat, err := os.Stat(oldFilesRoot); err == nil && stat.IsDir() { + dstFiles := filepath.Join(userRoot, "col", "files") + os.MkdirAll(dstFiles, 0o755) + + subEntries, err := os.ReadDir(oldFilesRoot) + if err == nil { + for _, s := range subEntries { + src := filepath.Join(oldFilesRoot, s.Name()) + dst := filepath.Join(dstFiles, s.Name()) + if s.IsDir() { + os.MkdirAll(filepath.Dir(dst), 0o755) + } + os.Rename(src, dst) + } + } + os.RemoveAll(oldFilesRoot) + } + + return nil +} + +// MigrateDataDir iterates all user data directories under dataDir and calls +// cleanupLegacy on each, handling both old flat-layout users (data//cal-*/...) +// and old files-direct-layout users (data/files//). +func MigrateDataDir(dataDir string) error { + users, err := os.ReadDir(dataDir) + if errors.Is(err, os.ErrNotExist) || len(users) == 0 { + return nil + } + if err != nil { + return err + } + + var dirs []string + // Collect all directories under data/ (excluding "col" which is the new layout, and non-dirs like nidus.db) + for _, e := range users { + if !e.IsDir() { + continue + } + name := e.Name() + if name == "files" { + // Collect all user dirs under data/files/ (legacy flat layout) + fileUsers, err2 := os.ReadDir(filepath.Join(dataDir, "files")) + if err2 == nil { + for _, fu := range fileUsers { + if fu.IsDir() { + dirs = append(dirs, fu.Name()) + } + } + } + } else if name != "col" { + dirs = append(dirs, name) + } + } + + for _, user := range dirs { + if err := cleanupLegacy(dataDir, user); err != nil { + return fmt.Errorf("migrating %q: %w", user, err) + } + } + return nil +} + // sanitize removes path-traversal characters from a path segment. func sanitize(s string) string { s = filepath.Base(s) diff --git a/internal/web/resources.go b/internal/web/resources.go index 8fb5155..44b9d80 100644 --- a/internal/web/resources.go +++ b/internal/web/resources.go @@ -117,9 +117,9 @@ func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind str return } - collPrefix := "cal-" + collPrefix := "col/calendars/" if kind == "addressbook" { - collPrefix = "card-" + collPrefix = "col/addressbooks/" } switch r.Method { diff --git a/internal/webdav/handler.go b/internal/webdav/handler.go index 4e8bee0..ce2de1f 100644 --- a/internal/webdav/handler.go +++ b/internal/webdav/handler.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "github.com/yourusername/caldav-server/internal/auth" @@ -12,9 +13,19 @@ import ( xwebdav "golang.org/x/net/webdav" ) +// sanitize removes path-traversal characters from a path segment. +func sanitize(s string) string { + s = filepath.Base(s) + s = strings.ReplaceAll(s, "..", "") + if s == "." || s == "" { + return "_" + } + return s +} + // NewHandler returns an http.Handler that provides standard WebDAV file access, // mounted at the fixed URL /files/ for every user and rooted at -// dataDir/files// on disk. The URL is the same for all users — +// dataDir//col/files/ on disk. The URL is the same for all users — // which user's directory is served is resolved from the Basic Auth identity // in the request context, not from the URL. // @@ -38,7 +49,7 @@ func NewHandler(cfg *config.Config, dataDir string, logger *slog.Logger) http.Ha h, ok := handlers[p.Username] if !ok { username := p.Username - userDir := filepath.Join(dataDir, "files", username) + userDir := filepath.Join(dataDir, sanitize(username), "col", "files") if err := os.MkdirAll(userDir, 0o755); err != nil { mu.Unlock() logger.Error("creating user WebDAV dir", "user", username, "error", err)