refactor: unify data directory structure

Unify user data directory from fragmented layout to consistent nested format:

- Move WebDAV from: data/files/<username> → data/<username>/files/
- Move CalDAV from: data/<username>/cal-<name> → data/<username>/calendars/<name>
- Move CardDAV from: data/<username>/card-<name> → data/<username>/addressbooks/<name>

Changes:
- internal/store/store.go: Update collectionPath() to map collection names
- internal/store/migrate.go: Add idempotent Migrate() method
- internal/store/migrate_test.go: Comprehensive migration tests
- internal/webdav/handler.go: Use new unified path structure
- cmd/server/main.go: Auto-run migration on startup
- tools/nidusctl/main.go: Add migrate subcommand
- Update tests to verify new structure

URL endpoints unchanged - only on-disk structure modified. All tests pass.
This commit is contained in:
2026-08-30 12:15:31 +02:00
parent f463c01f0f
commit 2fd39c8180
14 changed files with 964 additions and 22 deletions
+200
View File
@@ -0,0 +1,200 @@
package store
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Migrate restructures the data directory from the old format to the new unified format.
// It is idempotent and can be run multiple times safely.
//
// Old structure:
//
// data/files/<username>/
// data/<username>/cal-<name>/
// data/<username>/card-<name>/
//
// New structure:
//
// data/<username>/files/
// data/<username>/calendars/<name>/
// data/<username>/addressbooks/<name>/
func (s *Store) Migrate() error {
users, err := s.listUserDirectories()
if err != nil {
return fmt.Errorf("listing user directories: %w", err)
}
for _, user := range users {
if err := s.migrateUser(user); err != nil {
return fmt.Errorf("migrating user %q: %w", user, err)
}
}
return nil
}
// listUserDirectories returns all user directories in the data directory.
// It looks for directories that are NOT the old "files" directory,
// and also checks inside the old "files" directory for users who need migrating.
func (s *Store) listUserDirectories() ([]string, error) {
entries, err := os.ReadDir(s.rootDir)
if err != nil {
return nil, err
}
usersMap := make(map[string]bool)
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
// Skip the old files directory (will handle users inside it separately)
if name == "files" {
continue
}
usersMap[name] = true
}
// Also check the old files directory for users
filesDir := filepath.Join(s.rootDir, "files")
if filesEntries, err := os.ReadDir(filesDir); err == nil {
for _, e := range filesEntries {
if e.IsDir() {
usersMap[e.Name()] = true
}
}
}
var users []string
for user := range usersMap {
users = append(users, user)
}
return users, nil
}
// migrateUser migrates a single user's data from old to new structure.
func (s *Store) migrateUser(user string) error {
userDir := filepath.Join(s.rootDir, user)
// Create user directory if it doesn't exist (needed for WebDAV migration)
if err := os.MkdirAll(userDir, 0o755); err != nil {
return fmt.Errorf("creating user directory %q: %w", userDir, err)
}
// Migrate WebDAV files: files/<username> -> <username>/files
if err := s.migrateWebDAV(user); err != nil {
return err
}
// Migrate CalDAV calendars: cal-<name> -> calendars/<name>
if err := s.migrateCalendars(user, userDir); err != nil {
return err
}
// Migrate CardDAV address books: card-<name> -> addressbooks/<name>
if err := s.migrateAddressBooks(user, userDir); err != nil {
return err
}
return nil
}
// migrateWebDAV moves the old files/<username> directory to <username>/files.
func (s *Store) migrateWebDAV(user string) error {
oldPath := filepath.Join(s.rootDir, "files", user)
newPath := filepath.Join(s.rootDir, user, "files")
// Check if old directory exists and new doesn't
if _, err := os.Stat(oldPath); os.IsNotExist(err) {
return nil
}
if _, err := os.Stat(newPath); err == nil {
return nil // Already migrated
}
// Create parent directory if needed
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
return fmt.Errorf("creating directory %q: %w", filepath.Dir(newPath), err)
}
// Move the directory
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving %q to %q: %w", oldPath, newPath, err)
}
return nil
}
// migrateCalendars moves old cal-<name> directories to calendars/<name>.
func (s *Store) migrateCalendars(user string, userDir string) error {
entries, err := os.ReadDir(userDir)
if err != nil {
return fmt.Errorf("reading user directory %q: %w", userDir, err)
}
var calendars []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "cal-") {
calendars = append(calendars, e.Name())
}
}
if len(calendars) == 0 {
return nil
}
calendarsDir := filepath.Join(userDir, "calendars")
if err := os.MkdirAll(calendarsDir, 0o755); err != nil {
return fmt.Errorf("creating calendars directory %q: %w", calendarsDir, err)
}
for _, calName := range calendars {
oldPath := filepath.Join(userDir, calName)
newPath := filepath.Join(calendarsDir, strings.TrimPrefix(calName, "cal-"))
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving calendar %q: %w", calName, err)
}
}
return nil
}
// migrateAddressBooks moves old card-<name> directories to addressbooks/<name>.
func (s *Store) migrateAddressBooks(user string, userDir string) error {
entries, err := os.ReadDir(userDir)
if err != nil {
return fmt.Errorf("reading user directory %q: %w", userDir, err)
}
var addressBooks []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "card-") {
addressBooks = append(addressBooks, e.Name())
}
}
if len(addressBooks) == 0 {
return nil
}
addressBooksDir := filepath.Join(userDir, "addressbooks")
if err := os.MkdirAll(addressBooksDir, 0o755); err != nil {
return fmt.Errorf("creating addressbooks directory %q: %w", addressBooksDir, err)
}
for _, bookName := range addressBooks {
oldPath := filepath.Join(userDir, bookName)
newPath := filepath.Join(addressBooksDir, strings.TrimPrefix(bookName, "card-"))
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving address book %q: %w", bookName, err)
}
}
return nil
}