Files
nidus/internal/store/store.go
T
2026-08-29 19:16:12 +02:00

312 lines
8.2 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))
}
// 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/<username>) into the new structured col/
// subdirectory layout (<user>/col/calendars/*, <user>/col/addressbooks/*,
// <user>/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/<username>/* into <user>/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/<username>/cal-*/...)
// and old files-direct-layout users (data/files/<username>/).
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
}