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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create old structure
|
||||
// WebDAV: files/<username>/
|
||||
if err := os.MkdirAll(filepath.Join(tmpDir, "files", "alice"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "files", "alice", "test.txt"), []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// CalDAV: <username>/cal-<name>/
|
||||
if err := os.MkdirAll(filepath.Join(tmpDir, "bob", "cal-work"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "bob", "cal-work", "evt.ics"), []byte("calendar"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// CardDAV: <username>/card-<name>/
|
||||
if err := os.MkdirAll(filepath.Join(tmpDir, "bob", "card-contacts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "bob", "card-contacts", "vcard.vcf"), []byte("card"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create store and migrate
|
||||
st, err := NewStore(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := st.Migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify WebDAV: <username>/files/
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "alice", "files", "test.txt")); err != nil {
|
||||
t.Errorf("alice files not migrated: %v", err)
|
||||
}
|
||||
|
||||
// Verify CalDAV: <username>/calendars/<name>
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "calendars", "work", "evt.ics")); err != nil {
|
||||
t.Errorf("bob calendars not migrated: %v", err)
|
||||
}
|
||||
|
||||
// Verify CardDAV: <username>/addressbooks/<name>
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "addressbooks", "contacts", "vcard.vcf")); err != nil {
|
||||
t.Errorf("bob addressbooks not migrated: %v", err)
|
||||
}
|
||||
|
||||
// Verify old structure is gone
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "files", "alice")); err == nil {
|
||||
t.Error("old files directory not removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "cal-work")); err == nil {
|
||||
t.Error("old cal- directory not removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "card-contacts")); err == nil {
|
||||
t.Error("old card- directory not removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateIdempotent(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create new structure
|
||||
if err := os.MkdirAll(filepath.Join(tmpDir, "alice", "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "alice", "files", "test.txt"), []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st, err := NewStore(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Run migration twice
|
||||
if err := st.Migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify data still there
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "alice", "files", "test.txt")); err != nil {
|
||||
t.Errorf("data not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateMissingDirectories(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
st, err := NewStore(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Should not error on empty directory
|
||||
if err := st.Migrate(); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -48,9 +48,26 @@ func (s *Store) lockFor(user string) *sync.RWMutex {
|
||||
return l
|
||||
}
|
||||
|
||||
// collectionPath returns the filesystem path for a collection.
|
||||
// collectionPath returns the filesystem path for a collection in the new unified format.
|
||||
// Calendar collections: <username>/calendars/<name>
|
||||
// Address book collections: <username>/addressbooks/<name>
|
||||
// WebDAV collections: <username>/files (all files in one directory)
|
||||
func (s *Store) collectionPath(user, collection string) string {
|
||||
return filepath.Join(s.rootDir, sanitize(user), sanitize(collection))
|
||||
userDir := filepath.Join(s.rootDir, sanitize(user))
|
||||
|
||||
if strings.HasPrefix(collection, "cal-") {
|
||||
return filepath.Join(userDir, "calendars", strings.TrimPrefix(collection, "cal-"))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(collection, "card-") {
|
||||
return filepath.Join(userDir, "addressbooks", strings.TrimPrefix(collection, "card-"))
|
||||
}
|
||||
|
||||
if collection == "files" {
|
||||
return filepath.Join(userDir, "files")
|
||||
}
|
||||
|
||||
return filepath.Join(userDir, sanitize(collection))
|
||||
}
|
||||
|
||||
// objectPath returns the filesystem path for an object within a collection.
|
||||
@@ -68,6 +85,7 @@ func (s *Store) EnsureCollection(user, collection string) error {
|
||||
}
|
||||
|
||||
// ListCollections returns all collection names for a user.
|
||||
// Returns both old-style (cal-*, card-*) and new-style (calendars/*, addressbooks/*) collections.
|
||||
func (s *Store) ListCollections(user string) ([]string, error) {
|
||||
l := s.lockFor(user)
|
||||
l.RLock()
|
||||
@@ -85,7 +103,8 @@ func (s *Store) ListCollections(user string) ([]string, error) {
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
name := e.Name()
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
|
||||
Reference in New Issue
Block a user