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)) } // 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)) } // 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() 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) } // 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() { // 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 } // 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() 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 } 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() 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 } 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() 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 } // 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 }