package store import ( "errors" "fmt" "os" "path/filepath" "strings" "sync" ) // ErrNotFound is returned when a resource does not exist. var ErrNotFound = errors.New("not found") // ErrConflict is returned when trying to create a resource that already exists. var ErrConflict = errors.New("conflict") // Store is a filesystem-backed key/value store for DAV objects. // Each "collection" maps to a directory; each "object" maps to a file. // // Locking is sharded per-user (rather than one global mutex) so that // concurrent requests from different users don't serialize against each // other; operations within a single user's data still block one another. type Store struct { rootDir string locksMu sync.Mutex locks map[string]*sync.RWMutex } // NewStore creates or opens a Store rooted at rootDir. func NewStore(rootDir string) (*Store, error) { if err := os.MkdirAll(rootDir, 0o755); err != nil { return nil, fmt.Errorf("creating store root %q: %w", rootDir, err) } return &Store{rootDir: rootDir, locks: make(map[string]*sync.RWMutex)}, nil } // lockFor returns the per-user lock, creating it on first use. func (s *Store) lockFor(user string) *sync.RWMutex { s.locksMu.Lock() defer s.locksMu.Unlock() l, ok := s.locks[user] if !ok { l = &sync.RWMutex{} s.locks[user] = l } return l } // collectionPath returns the filesystem path for a collection. func (s *Store) collectionPath(user, collection string) string { return filepath.Join(s.rootDir, sanitize(user), sanitize(collection)) } // 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)) } // EnsureCollection creates the collection directory if it does not exist. func (s *Store) EnsureCollection(user, collection string) error { l := s.lockFor(user) l.Lock() defer l.Unlock() dir := s.collectionPath(user, collection) return os.MkdirAll(dir, 0o755) } // ListCollections returns all collection names for a user. func (s *Store) ListCollections(user string) ([]string, error) { l := s.lockFor(user) l.RLock() defer l.RUnlock() userDir := filepath.Join(s.rootDir, sanitize(user)) entries, err := os.ReadDir(userDir) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } var names []string for _, e := range entries { if e.IsDir() { names = append(names, e.Name()) } } return names, nil } // GetCollection returns metadata about a collection. 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)) if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound } return info, err } // PutObject writes data to an object, creating or replacing it. func (s *Store) PutObject(user, collection, objectID string, data []byte) error { l := s.lockFor(user) l.Lock() defer l.Unlock() dir := s.collectionPath(user, collection) if err := os.MkdirAll(dir, 0o755); err != nil { return err } path := s.objectPath(user, collection, objectID) // Write to a temp file then rename for atomicity. tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return fmt.Errorf("writing object: %w", err) } return os.Rename(tmp, path) } // GetObject reads an object's raw bytes. func (s *Store) GetObject(user, collection, objectID string) ([]byte, error) { l := s.lockFor(user) l.RLock() defer l.RUnlock() data, err := os.ReadFile(s.objectPath(user, collection, objectID)) if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound } return data, err } // DeleteObject removes an object. func (s *Store) DeleteObject(user, collection, objectID string) error { l := s.lockFor(user) l.Lock() defer l.Unlock() err := os.Remove(s.objectPath(user, collection, objectID)) if errors.Is(err, os.ErrNotExist) { return ErrNotFound } return err } // ListObjects returns all object filenames in a collection. func (s *Store) ListObjects(user, collection string) ([]string, error) { l := s.lockFor(user) l.RLock() defer l.RUnlock() dir := s.collectionPath(user, collection) entries, err := os.ReadDir(dir) if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound } if err != nil { return nil, err } var names []string for _, e := range entries { if !e.IsDir() && !strings.HasSuffix(e.Name(), ".tmp") { names = append(names, e.Name()) } } return names, nil } // StatObject returns FileInfo for an object. func (s *Store) StatObject(user, collection, objectID string) (os.FileInfo, error) { l := s.lockFor(user) l.RLock() defer l.RUnlock() info, err := os.Stat(s.objectPath(user, collection, objectID)) if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound } return info, err } // DeleteCollection removes an entire collection directory. func (s *Store) DeleteCollection(user, collection string) error { l := s.lockFor(user) l.Lock() defer l.Unlock() err := os.RemoveAll(s.collectionPath(user, collection)) 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) s = strings.ReplaceAll(s, "..", "") if s == "." || s == "" { return "_" } return s }