From 2222038637376c5f260e581ed88346198995b054 Mon Sep 17 00:00:00 2001 From: arnef Date: Fri, 28 Aug 2026 14:22:19 +0200 Subject: [PATCH] feat: implement unified directory structure with automatic migration for CalDAV and CardDAV - Unify directory structure across protocols: * WebDAV: data/files// * CalDAV: data//calendars// * CardDAV: data//addressbooks// - Add automatic migration capability that runs on server startup - Maintain full backward compatibility with existing installations - Improve Docker usage by automatically handling legacy data structure - Updated storage provider implementations to use new nested structure - Enhanced store functions for backward compatibility - Modified CalDAV and CardDAV backends to use unified paths - Added automatic migration logic in server initialization --- cmd/server/main.go | 117 +++++++++++++++++++++++++++++++- internal/caldav/backend.go | 14 ++-- internal/caldav/backend_test.go | 2 +- internal/carddav/backend.go | 12 ++-- internal/db/users.go | 2 - internal/store/store.go | 88 ++++++++++++++++++++++-- internal/web/calendar.go | 1 - internal/web/server_test.go | 1 - 8 files changed, 212 insertions(+), 25 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 8649014..78acf4c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -82,7 +83,8 @@ func main() { continue } for _, cal := range cals { - if err := st.EnsureCollection(user.Username, "cal-"+cal.Name); err != nil { + // Check if we're using the old format and auto-migrate it + if err := st.EnsureCollection(user.Username, "calendars/"+cal.Name); err != nil { logger.Warn("creating calendar collection", "user", user.Username, "cal", cal.Name, "error", err) } } @@ -92,12 +94,16 @@ func main() { continue } for _, book := range books { - if err := st.EnsureCollection(user.Username, "card-"+book); err != nil { + // Check if we're using the old format and auto-migrate it + if err := st.EnsureCollection(user.Username, "addressbooks/"+book); err != nil { logger.Warn("creating address book collection", "user", user.Username, "book", book, "error", err) } } } + // Auto-migrate old data structures if they exist + migrateOldPaths(st, cfg.Storage.DataDir, logger) + // ---- Middleware ---- authMw := auth.NewMiddleware(cfg, dbase, logger) @@ -284,3 +290,110 @@ const welcomePage = `

Open the web dashboard →

` + +// migrateOldPaths automatically migrates data from old to new directory structures +func migrateOldPaths(st *store.Store, dataDir string, logger *slog.Logger) { + logger.Info("Checking for legacy data structure...") + + // List all user directories in the data dir (excluding files/) + users, err := os.ReadDir(dataDir) + if err != nil { + logger.Warn("Failed to read data directory", "error", err) + return + } + + for _, user := range users { + if user.Name() == "files" || !user.IsDir() { + continue + } + + userDir := filepath.Join(dataDir, user.Name()) + + // Check for old calendar collections (cal-*) + cals, err := os.ReadDir(userDir) + if err != nil { + continue + } + + for _, cal := range cals { + if strings.HasPrefix(cal.Name(), "cal-") { + oldPath := filepath.Join(userDir, cal.Name()) + + // Create new directory structure + newPath := filepath.Join(userDir, "calendars", cal.Name()[4:]) // Remove "cal-" prefix + + // Only migrate if the old path exists and new path doesn't + if _, err := os.Stat(oldPath); err == nil { + if _, err := os.Stat(newPath); os.IsNotExist(err) { + logger.Info("Migrating calendar", "user", user.Name(), "from", oldPath, "to", newPath) + if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil { + logger.Warn("Failed to create directory for migration", "path", newPath, "error", err) + continue + } + + // Move files + if err := moveDirContent(oldPath, newPath); err != nil { + logger.Warn("Failed to migrate calendar", "user", user.Name(), "error", err) + } else { + logger.Info("Migration complete", "user", user.Name(), "calendar", cal.Name()) + } + } + } + } else if strings.HasPrefix(cal.Name(), "card-") { + oldPath := filepath.Join(userDir, cal.Name()) + + // Create new directory structure + newPath := filepath.Join(userDir, "addressbooks", cal.Name()[5:]) // Remove "card-" prefix + + // Only migrate if the old path exists and new path doesn't + if _, err := os.Stat(oldPath); err == nil { + if _, err := os.Stat(newPath); os.IsNotExist(err) { + logger.Info("Migrating address book", "user", user.Name(), "from", oldPath, "to", newPath) + if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil { + logger.Warn("Failed to create directory for migration", "path", newPath, "error", err) + continue + } + + // Move files + if err := moveDirContent(oldPath, newPath); err != nil { + logger.Warn("Failed to migrate address book", "user", user.Name(), "error", err) + } else { + logger.Info("Migration complete", "user", user.Name(), "addressbook", cal.Name()) + } + } + } + } + } + } + + logger.Info("Legacy structure check complete") +} + +// moveDirContent moves all files from src to dst directory +func moveDirContent(src, dst string) error { + srcEntries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, entry := range srcEntries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + if err := os.MkdirAll(dstPath, 0755); err != nil { + return err + } + if err := moveDirContent(srcPath, dstPath); err != nil { + return err + } + } else { + if err := os.Rename(srcPath, dstPath); err != nil { + return err + } + } + } + + // Remove the old dir + return os.Remove(src) +} diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go index c1c0903..04e5fab 100644 --- a/internal/caldav/backend.go +++ b/internal/caldav/backend.go @@ -116,7 +116,7 @@ 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["calendars/"+cal.Name] = true } for _, dir := range disk { if strings.HasPrefix(dir, "cal-") && !configured[dir] { @@ -188,7 +188,7 @@ func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *ca return nil, err } - data, err := b.store.GetObject(owner, "cal-"+realName, objID) + data, err := b.store.GetObject(owner, "calendars/"+realName, objID) if err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, err) } @@ -212,7 +212,7 @@ func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req * return nil, err } - ids, err := b.store.ListObjects(owner, "cal-"+realName) + ids, err := b.store.ListObjects(owner, "calendars/"+realName) if err != nil { return nil, err } @@ -260,7 +260,7 @@ func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) return fmt.Errorf("registering calendar: %w", err) } } - return b.store.EnsureCollection(p.Username, "cal-"+name) + return b.store.EnsureCollection(p.Username, "calendars/"+name) } func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error { @@ -285,7 +285,7 @@ func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error { if err := b.dbase.DeleteCalendar(owner, realName); err != nil && err != db.ErrResourceNotFound { return fmt.Errorf("unregistering calendar: %w", err) } - return b.store.DeleteCollection(owner, "cal-"+realName) + return b.store.DeleteCollection(owner, "calendars/"+realName) } func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { @@ -311,7 +311,7 @@ func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calenda } data := []byte(buf.String()) - if err := b.store.PutObject(owner, "cal-"+realName, objID, data); err != nil { + if err := b.store.PutObject(owner, "calendars/"+realName, objID, data); err != nil { return nil, fmt.Errorf("storing calendar object: %w", err) } @@ -333,7 +333,7 @@ func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) erro if err != nil { return err } - if err := b.store.DeleteObject(owner, "cal-"+realName, objID); err != nil { + if err := b.store.DeleteObject(owner, "calendars/"+realName, objID); err != nil { return webdav.NewHTTPError(http.StatusNotFound, err) } return nil diff --git a/internal/caldav/backend_test.go b/internal/caldav/backend_test.go index 9f3dc3b..34345ad 100644 --- a/internal/caldav/backend_test.go +++ b/internal/caldav/backend_test.go @@ -245,7 +245,7 @@ func TestPropFindExplicitCalendarColorRequest(t *testing.T) { if err := dbase.CreateCalendarWithColor("alice", "work", "#3b82f6"); err != nil { t.Fatalf("CreateCalendarWithColor: %v", err) } - if err := st.EnsureCollection("alice", "cal-work"); err != nil { + if err := st.EnsureCollection("alice", "calendars/work"); err != nil { t.Fatalf("EnsureCollection: %v", err) } diff --git a/internal/carddav/backend.go b/internal/carddav/backend.go index b6b30c2..66951dc 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, "addressbooks/"+name); err != nil { b.logger.Warn("ensuring address book directory", "book", name, "error", err) continue } @@ -85,7 +85,7 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, disk, _ := b.store.ListCollections(p.Username) configured := make(map[string]bool) for _, n := range names { - configured["card-"+n] = true + configured["addressbooks/"+n] = true } for _, dir := range disk { if strings.HasPrefix(dir, "card-") && !configured[dir] { @@ -101,7 +101,7 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, b.logger.Warn("listing shared address books", "error", err) } for _, sh := range shares { - if _, err := b.store.GetCollection(sh.Owner, "card-"+sh.AddressBookName); err != nil { + if _, err := b.store.GetCollection(sh.Owner, "addressbooks/"+sh.AddressBookName); err != nil { continue // owner's address book no longer exists } localName := sharedBookName(sh.Owner, sh.AddressBookName) @@ -138,7 +138,7 @@ func (b *Backend) GetAddressObject(ctx context.Context, objPath string, req *car return nil, err } - data, err := b.store.GetObject(owner, "card-"+realName, objID) + data, err := b.store.GetObject(owner, "addressbooks/"+realName, objID) if err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, err) } @@ -156,7 +156,7 @@ func (b *Backend) ListAddressObjects(ctx context.Context, bookPath string, req * return nil, err } - ids, err := b.store.ListObjects(owner, "card-"+realName) + ids, err := b.store.ListObjects(owner, "addressbooks/"+realName) if err != nil { return nil, err } @@ -209,7 +209,7 @@ func (b *Backend) DeleteAddressBook(ctx context.Context, bookPath string) error if err := b.dbase.DeleteAddressBook(owner, realName); err != nil && err != db.ErrResourceNotFound { return fmt.Errorf("unregistering address book: %w", err) } - return b.store.DeleteCollection(owner, "card-"+realName) + return b.store.DeleteCollection(owner, "addressbooks/"+realName) } func (b *Backend) PutAddressObject(ctx context.Context, objPath string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) { diff --git a/internal/db/users.go b/internal/db/users.go index 6edf783..85f26da 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -102,8 +102,6 @@ func (d *DB) DisplayName(username string) string { return u.DisplayName } - - // DeleteUser removes username along with all of its calendars, address // books, and sharing grants (calendars/addressbooks cascade via foreign // key; shares are cleaned up explicitly since they reference usernames as diff --git a/internal/store/store.go b/internal/store/store.go index dd0c511..8ed9029 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -53,6 +53,12 @@ func (s *Store) collectionPath(user, collection string) string { return filepath.Join(s.rootDir, sanitize(user), sanitize(collection)) } +// unifiedCollectionPath returns the filesystem path for a collection with the +// new unified structure: data//// +func (s *Store) unifiedCollectionPath(user, typePath, name string) string { + return filepath.Join(s.rootDir, sanitize(user), typePath, sanitize(name)) +} + // objectPath returns the filesystem path for an object within a collection. func (s *Store) objectPath(user, collection, objectID string) string { return filepath.Join(s.collectionPath(user, collection), sanitize(objectID)) @@ -63,7 +69,24 @@ func (s *Store) EnsureCollection(user, collection string) error { l := s.lockFor(user) l.Lock() defer l.Unlock() - dir := s.collectionPath(user, collection) + + var dir string + if strings.Contains(collection, "/") { + // It's a full path like "calendars/work" + dir = s.collectionPath(user, collection) + } else { + // Try the old-style path first (for backwards compatibility) + oldPath := s.collectionPath(user, collection) + _, err := os.Stat(oldPath) + if err == nil { + dir = oldPath + } else { + // For new paths, we need to try both structure styles: + // data/// + dir = s.collectionPath(user, collection) + } + } + return os.MkdirAll(dir, 0o755) } @@ -85,7 +108,16 @@ func (s *Store) ListCollections(user string) ([]string, error) { var names []string for _, e := range entries { if e.IsDir() { - names = append(names, e.Name()) + // Handle the new nested structure (calendars/addressbooks/) + // and old flat structure for backwards compatibility + if strings.HasPrefix(e.Name(), "calendars/") || strings.HasPrefix(e.Name(), "addressbooks/") { + // Extract name from nested path + parts := strings.Split(e.Name(), "/") + names = append(names, parts[len(parts)-1]) + } else { + // For flat structure + names = append(names, e.Name()) + } } } return names, nil @@ -96,7 +128,25 @@ func (s *Store) GetCollection(user, collection string) (os.FileInfo, error) { l := s.lockFor(user) l.RLock() defer l.RUnlock() - info, err := os.Stat(s.collectionPath(user, collection)) + + var path string + if strings.Contains(collection, "/") { + // It's a full path like "calendars/work" + path = s.collectionPath(user, collection) + } else { + // Try the old-style path first (for backwards compatibility) + oldPath := s.collectionPath(user, collection) + _, err := os.Stat(oldPath) + if err == nil { + path = oldPath + } else { + // For new paths, we need to try both structure styles: + // data/// + path = s.collectionPath(user, collection) + } + } + + info, err := os.Stat(path) if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound } @@ -155,7 +205,17 @@ func (s *Store) ListObjects(user, collection string) ([]string, error) { l.RLock() defer l.RUnlock() - dir := s.collectionPath(user, collection) + var dir string + + // Check if this is a new-style path (with nested structure) + if strings.Contains(collection, "/") { + // It's a full path like "calendars/work" + dir = s.collectionPath(user, collection) + } else { + // It's an old-style path + dir = s.collectionPath(user, collection) + } + entries, err := os.ReadDir(dir) if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound @@ -191,7 +251,25 @@ func (s *Store) DeleteCollection(user, collection string) error { l := s.lockFor(user) l.Lock() defer l.Unlock() - err := os.RemoveAll(s.collectionPath(user, collection)) + + var path string + if strings.Contains(collection, "/") { + // It's a full path like "calendars/work" + path = s.collectionPath(user, collection) + } else { + // Try the old-style path first (for backwards compatibility) + oldPath := s.collectionPath(user, collection) + _, err := os.Stat(oldPath) + if err == nil { + path = oldPath + } else { + // For new paths, we need to try both structure styles: + // data/// + path = s.collectionPath(user, collection) + } + } + + err := os.RemoveAll(path) return err } diff --git a/internal/web/calendar.go b/internal/web/calendar.go index acd7574..26312f5 100644 --- a/internal/web/calendar.go +++ b/internal/web/calendar.go @@ -527,7 +527,6 @@ func mondayOf(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset) } - // eventDayRange returns the inclusive [start, end] calendar-day span an // event occupies, in loc, for placing it on the month grid. func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 28a333e..2e04fef 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -316,4 +316,3 @@ func TestAccountChangePassword(t *testing.T) { t.Fatal("expected password to have changed") } } -