- 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
285 lines
7.5 KiB
Go
285 lines
7.5 KiB
Go
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/<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))
|
|
}
|
|
|
|
// 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/<username>/<type>/<name>
|
|
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/<username>/<type>/<name>
|
|
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/<username>/<type>/<name>
|
|
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
|
|
}
|