4 Commits
Author SHA1 Message Date
arnef 001cc68172 fix: update WebDAV handler test for new path structure 2026-08-28 22:20:18 +02:00
arnef f13ebbd9a2 fix: add missing WebDAV path migration and remove duplicate migration.go 2026-08-28 22:19:44 +02:00
arnef 4e7f577978 feat: implement unified directory structure with automatic migration for CalDAV and CardDAV
- Unify directory structure across all protocols under user directory:
  * WebDAV: data/<username>/files/
  * CalDAV: data/<username>/calendars/<name>/
  * CardDAV: data/<username>/addressbooks/<name>/

- 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
- Changed WebDAV path from data/files/<username>/ to data/<username>/files/
2026-08-28 19:57:10 +02:00
arnef 2222038637 feat: implement unified directory structure with automatic migration for CalDAV and CardDAV
- Unify directory structure across protocols:
  * WebDAV: data/files/<username>/
  * CalDAV: data/<username>/calendars/<name>/
  * CardDAV: data/<username>/addressbooks/<name>/

- 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
2026-08-28 14:22:19 +02:00
10 changed files with 253 additions and 31 deletions
+150 -2
View File
@@ -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,145 @@ const welcomePage = `<!DOCTYPE html>
<p><a href="/web/">Open the web dashboard →</a></p>
</body>
</html>`
// 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())
}
}
}
}
}
}
// Migrate WebDAV files from old flat structure (dataDir/files/<username>/)
// to new nested structure (dataDir/<username>/files/)
filesRoot := filepath.Join(dataDir, "files")
if _, err := os.Stat(filesRoot); err == nil {
if entries, er := os.ReadDir(filesRoot); er == nil {
for _, entry := range entries {
if !entry.IsDir() {
continue
}
username := entry.Name()
oldWebdavPath := filepath.Join(filesRoot, username)
newUserDir := filepath.Join(dataDir, username)
newWebdavPath := filepath.Join(newUserDir, "files")
if _, e := os.Stat(oldWebdavPath); e != nil {
continue
}
if _, e := os.Stat(newWebdavPath); !os.IsNotExist(e) {
continue
}
logger.Info("Migrating webdav files", "user", username, "from", oldWebdavPath, "to", newWebdavPath)
if mkErr := os.MkdirAll(filepath.Dir(newWebdavPath), 0755); mkErr != nil {
logger.Warn("Failed to create directory for webdav migration", "path", newWebdavPath, "error", mkErr)
continue
}
if mvErr := moveDirContent(oldWebdavPath, newWebdavPath); mvErr != nil {
logger.Warn("Failed to migrate webdav files", "user", username, "error", mvErr)
} else {
logger.Info("webdav files migration complete", "user", username)
}
}
}
}
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)
}
+7 -7
View File
@@ -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
+1 -1
View File
@@ -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)
}
+6 -6
View File
@@ -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) {
-2
View File
@@ -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
+83 -5
View File
@@ -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/<username>/<type>/<name>/
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/<username>/<type>/<name>
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/<username>/<type>/<name>
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/<username>/<type>/<name>
path = s.collectionPath(user, collection)
}
}
err := os.RemoveAll(path)
return err
}
-1
View File
@@ -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) {
-1
View File
@@ -316,4 +316,3 @@ func TestAccountChangePassword(t *testing.T) {
t.Fatal("expected password to have changed")
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ import (
// 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/<username>/ on disk. The URL is the same for all users —
// dataDir/<username>/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 +38,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, username, "files")
if err := os.MkdirAll(userDir, 0o755); err != nil {
mu.Unlock()
logger.Error("creating user WebDAV dir", "user", username, "error", err)
+4 -4
View File
@@ -54,10 +54,10 @@ func TestPerUserIsolationAndPrefix(t *testing.T) {
t.Fatalf("expected bob to get 404 for alice's file, got %d", bobRec.Code)
}
// Confirm the file physically landed under dataDir/files/alice/, not
// nested under an extra files/files/... path.
if _, err := os.Stat(dir + "/files/alice/note.txt"); err != nil {
t.Fatalf("expected file at dataDir/files/alice/note.txt: %v", err)
// Confirm the file physically landed under dataDir/alice/files/, matching
// the new nested structure.
if _, err := os.Stat(dir + "/alice/files/note.txt"); err != nil {
t.Fatalf("expected file at dataDir/alice/files/note.txt: %v", err)
}
}