Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
001cc68172 | ||
|
|
f13ebbd9a2 | ||
|
|
4e7f577978 | ||
|
|
2222038637 |
+149
-10
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -82,7 +83,8 @@ func main() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, cal := range cals {
|
for _, cal := range cals {
|
||||||
if err := st.EnsureCollection(user.Username, "col/calendars/"+cal.Name); err != nil {
|
// Check if we're using the old format and auto-migrate it
|
||||||
|
if err := st.EnsureCollection(user.Username, "calendars/"+cal.Name); err != nil {
|
||||||
logger.Warn("creating calendar collection", "user", user.Username, "cal", cal.Name, "error", err)
|
logger.Warn("creating calendar collection", "user", user.Username, "cal", cal.Name, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,20 +94,15 @@ func main() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, book := range books {
|
for _, book := range books {
|
||||||
if err := st.EnsureCollection(user.Username, "col/addressbooks/"+book); err != nil {
|
// Check if we're using the old format and auto-migrate it
|
||||||
|
if err := st.EnsureCollection(user.Username, "addressbooks/"+book); err != nil {
|
||||||
logger.Warn("creating address book collection", "user", user.Username, "book", book, "error", err)
|
logger.Warn("creating address book collection", "user", user.Username, "book", book, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run migration from legacy flat layout (cal-*/card-* dirs and data/files/)
|
// Auto-migrate old data structures if they exist
|
||||||
// into new structured col/ subdirectory layout (<user>/col/<type>/<name>).
|
migrateOldPaths(st, cfg.Storage.DataDir, logger)
|
||||||
logger.Info("starting migration from old layout to new col/ subdirectory structure")
|
|
||||||
if err := store.MigrateDataDir(cfg.Storage.DataDir); err != nil {
|
|
||||||
logger.Error("running storage migration", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
logger.Info("migration complete")
|
|
||||||
|
|
||||||
// ---- Middleware ----
|
// ---- Middleware ----
|
||||||
authMw := auth.NewMiddleware(cfg, dbase, logger)
|
authMw := auth.NewMiddleware(cfg, dbase, logger)
|
||||||
@@ -293,3 +290,145 @@ const welcomePage = `<!DOCTYPE html>
|
|||||||
<p><a href="/web/">Open the web dashboard →</a></p>
|
<p><a href="/web/">Open the web dashboard →</a></p>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
||||||
|
// migrateOldPaths automatically migrates data from old to new directory structures
|
||||||
|
func migrateOldPaths(st *store.Store, dataDir string, logger *slog.Logger) {
|
||||||
|
logger.Info("Checking for legacy data structure...")
|
||||||
|
|
||||||
|
// List all user directories in the data dir (excluding files/)
|
||||||
|
users, err := os.ReadDir(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to read data directory", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range users {
|
||||||
|
if user.Name() == "files" || !user.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
userDir := filepath.Join(dataDir, user.Name())
|
||||||
|
|
||||||
|
// Check for old calendar collections (cal-*)
|
||||||
|
cals, err := os.ReadDir(userDir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cal := range cals {
|
||||||
|
if strings.HasPrefix(cal.Name(), "cal-") {
|
||||||
|
oldPath := filepath.Join(userDir, cal.Name())
|
||||||
|
|
||||||
|
// Create new directory structure
|
||||||
|
newPath := filepath.Join(userDir, "calendars", cal.Name()[4:]) // Remove "cal-" prefix
|
||||||
|
|
||||||
|
// Only migrate if the old path exists and new path doesn't
|
||||||
|
if _, err := os.Stat(oldPath); err == nil {
|
||||||
|
if _, err := os.Stat(newPath); os.IsNotExist(err) {
|
||||||
|
logger.Info("Migrating calendar", "user", user.Name(), "from", oldPath, "to", newPath)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil {
|
||||||
|
logger.Warn("Failed to create directory for migration", "path", newPath, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move files
|
||||||
|
if err := moveDirContent(oldPath, newPath); err != nil {
|
||||||
|
logger.Warn("Failed to migrate calendar", "user", user.Name(), "error", err)
|
||||||
|
} else {
|
||||||
|
logger.Info("Migration complete", "user", user.Name(), "calendar", cal.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(cal.Name(), "card-") {
|
||||||
|
oldPath := filepath.Join(userDir, cal.Name())
|
||||||
|
|
||||||
|
// Create new directory structure
|
||||||
|
newPath := filepath.Join(userDir, "addressbooks", cal.Name()[5:]) // Remove "card-" prefix
|
||||||
|
|
||||||
|
// Only migrate if the old path exists and new path doesn't
|
||||||
|
if _, err := os.Stat(oldPath); err == nil {
|
||||||
|
if _, err := os.Stat(newPath); os.IsNotExist(err) {
|
||||||
|
logger.Info("Migrating address book", "user", user.Name(), "from", oldPath, "to", newPath)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil {
|
||||||
|
logger.Warn("Failed to create directory for migration", "path", newPath, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move files
|
||||||
|
if err := moveDirContent(oldPath, newPath); err != nil {
|
||||||
|
logger.Warn("Failed to migrate address book", "user", user.Name(), "error", err)
|
||||||
|
} else {
|
||||||
|
logger.Info("Migration complete", "user", user.Name(), "addressbook", cal.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate WebDAV files from old flat structure (dataDir/files/<username>/)
|
||||||
|
// to new nested structure (dataDir/<username>/files/)
|
||||||
|
filesRoot := filepath.Join(dataDir, "files")
|
||||||
|
if _, err := os.Stat(filesRoot); err == nil {
|
||||||
|
if entries, er := os.ReadDir(filesRoot); er == nil {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
username := entry.Name()
|
||||||
|
oldWebdavPath := filepath.Join(filesRoot, username)
|
||||||
|
newUserDir := filepath.Join(dataDir, username)
|
||||||
|
newWebdavPath := filepath.Join(newUserDir, "files")
|
||||||
|
|
||||||
|
if _, e := os.Stat(oldWebdavPath); e != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, e := os.Stat(newWebdavPath); !os.IsNotExist(e) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Migrating webdav files", "user", username, "from", oldWebdavPath, "to", newWebdavPath)
|
||||||
|
if mkErr := os.MkdirAll(filepath.Dir(newWebdavPath), 0755); mkErr != nil {
|
||||||
|
logger.Warn("Failed to create directory for webdav migration", "path", newWebdavPath, "error", mkErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mvErr := moveDirContent(oldWebdavPath, newWebdavPath); mvErr != nil {
|
||||||
|
logger.Warn("Failed to migrate webdav files", "user", username, "error", mvErr)
|
||||||
|
} else {
|
||||||
|
logger.Info("webdav files migration complete", "user", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Legacy structure check complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveDirContent moves all files from src to dst directory
|
||||||
|
func moveDirContent(src, dst string) error {
|
||||||
|
srcEntries, err := os.ReadDir(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range srcEntries {
|
||||||
|
srcPath := filepath.Join(src, entry.Name())
|
||||||
|
dstPath := filepath.Join(dst, entry.Name())
|
||||||
|
|
||||||
|
if entry.IsDir() {
|
||||||
|
if err := os.MkdirAll(dstPath, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := moveDirContent(srcPath, dstPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := os.Rename(srcPath, dstPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the old dir
|
||||||
|
return os.Remove(src)
|
||||||
|
}
|
||||||
|
|||||||
+10
-10
@@ -96,7 +96,7 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
|
|||||||
|
|
||||||
cals := []caldav.Calendar{b.birthdaysCalendarMeta(p.Username)}
|
cals := []caldav.Calendar{b.birthdaysCalendarMeta(p.Username)}
|
||||||
for _, cal := range names {
|
for _, cal := range names {
|
||||||
if err := b.store.EnsureCollection(p.Username, "col/calendars/"+cal.Name); err != nil {
|
if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil {
|
||||||
b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err)
|
b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -116,11 +116,11 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
|
|||||||
disk, _ := b.store.ListCollections(p.Username)
|
disk, _ := b.store.ListCollections(p.Username)
|
||||||
configured := make(map[string]bool)
|
configured := make(map[string]bool)
|
||||||
for _, cal := range names {
|
for _, cal := range names {
|
||||||
configured["col/calendars/"+cal.Name] = true
|
configured["calendars/"+cal.Name] = true
|
||||||
}
|
}
|
||||||
for _, dir := range disk {
|
for _, dir := range disk {
|
||||||
if strings.HasPrefix(dir, "col/calendars/") && !configured[dir] {
|
if strings.HasPrefix(dir, "cal-") && !configured[dir] {
|
||||||
name := strings.TrimPrefix(dir, "col/calendars/")
|
name := strings.TrimPrefix(dir, "cal-")
|
||||||
cals = append(cals, b.calendarMeta(p.Username, name, name))
|
cals = append(cals, b.calendarMeta(p.Username, name, name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *ca
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := b.store.GetObject(owner, "cal-"+realName, objID)
|
data, err := b.store.GetObject(owner, "calendars/"+realName, objID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||||
}
|
}
|
||||||
@@ -212,7 +212,7 @@ func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ids, err := b.store.ListObjects(owner, "cal-"+realName)
|
ids, err := b.store.ListObjects(owner, "calendars/"+realName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -260,7 +260,7 @@ func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar)
|
|||||||
return fmt.Errorf("registering calendar: %w", err)
|
return fmt.Errorf("registering calendar: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return b.store.EnsureCollection(p.Username, "cal-"+name)
|
return b.store.EnsureCollection(p.Username, "calendars/"+name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error {
|
func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error {
|
||||||
@@ -285,7 +285,7 @@ func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error {
|
|||||||
if err := b.dbase.DeleteCalendar(owner, realName); err != nil && err != db.ErrResourceNotFound {
|
if err := b.dbase.DeleteCalendar(owner, realName); err != nil && err != db.ErrResourceNotFound {
|
||||||
return fmt.Errorf("unregistering calendar: %w", err)
|
return fmt.Errorf("unregistering calendar: %w", err)
|
||||||
}
|
}
|
||||||
return b.store.DeleteCollection(owner, "cal-"+realName)
|
return b.store.DeleteCollection(owner, "calendars/"+realName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
|
func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
|
||||||
@@ -311,7 +311,7 @@ func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calenda
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := []byte(buf.String())
|
data := []byte(buf.String())
|
||||||
if err := b.store.PutObject(owner, "cal-"+realName, objID, data); err != nil {
|
if err := b.store.PutObject(owner, "calendars/"+realName, objID, data); err != nil {
|
||||||
return nil, fmt.Errorf("storing calendar object: %w", err)
|
return nil, fmt.Errorf("storing calendar object: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +333,7 @@ func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := b.store.DeleteObject(owner, "cal-"+realName, objID); err != nil {
|
if err := b.store.DeleteObject(owner, "calendars/"+realName, objID); err != nil {
|
||||||
return webdav.NewHTTPError(http.StatusNotFound, err)
|
return webdav.NewHTTPError(http.StatusNotFound, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ func TestPropFindExplicitCalendarColorRequest(t *testing.T) {
|
|||||||
if err := dbase.CreateCalendarWithColor("alice", "work", "#3b82f6"); err != nil {
|
if err := dbase.CreateCalendarWithColor("alice", "work", "#3b82f6"); err != nil {
|
||||||
t.Fatalf("CreateCalendarWithColor: %v", err)
|
t.Fatalf("CreateCalendarWithColor: %v", err)
|
||||||
}
|
}
|
||||||
if err := st.EnsureCollection("alice", "cal-work"); err != nil {
|
if err := st.EnsureCollection("alice", "calendars/work"); err != nil {
|
||||||
t.Fatalf("EnsureCollection: %v", err)
|
t.Fatalf("EnsureCollection: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook,
|
|||||||
|
|
||||||
var books []carddav.AddressBook
|
var books []carddav.AddressBook
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if err := b.store.EnsureCollection(p.Username, "col/addressbooks/"+name); err != nil {
|
if err := b.store.EnsureCollection(p.Username, "addressbooks/"+name); err != nil {
|
||||||
b.logger.Warn("ensuring address book directory", "book", name, "error", err)
|
b.logger.Warn("ensuring address book directory", "book", name, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -85,11 +85,11 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook,
|
|||||||
disk, _ := b.store.ListCollections(p.Username)
|
disk, _ := b.store.ListCollections(p.Username)
|
||||||
configured := make(map[string]bool)
|
configured := make(map[string]bool)
|
||||||
for _, n := range names {
|
for _, n := range names {
|
||||||
configured["card-"+n] = true
|
configured["addressbooks/"+n] = true
|
||||||
}
|
}
|
||||||
for _, dir := range disk {
|
for _, dir := range disk {
|
||||||
if strings.HasPrefix(dir, "col/addressbooks/") && !configured[dir] {
|
if strings.HasPrefix(dir, "card-") && !configured[dir] {
|
||||||
name := strings.TrimPrefix(dir, "col/addressbooks/")
|
name := strings.TrimPrefix(dir, "card-")
|
||||||
books = append(books, b.bookMeta(p.Username, name, name))
|
books = append(books, b.bookMeta(p.Username, name, name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook,
|
|||||||
b.logger.Warn("listing shared address books", "error", err)
|
b.logger.Warn("listing shared address books", "error", err)
|
||||||
}
|
}
|
||||||
for _, sh := range shares {
|
for _, sh := range shares {
|
||||||
if _, err := b.store.GetCollection(sh.Owner, "card-"+sh.AddressBookName); err != nil {
|
if _, err := b.store.GetCollection(sh.Owner, "addressbooks/"+sh.AddressBookName); err != nil {
|
||||||
continue // owner's address book no longer exists
|
continue // owner's address book no longer exists
|
||||||
}
|
}
|
||||||
localName := sharedBookName(sh.Owner, sh.AddressBookName)
|
localName := sharedBookName(sh.Owner, sh.AddressBookName)
|
||||||
@@ -138,7 +138,7 @@ func (b *Backend) GetAddressObject(ctx context.Context, objPath string, req *car
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := b.store.GetObject(owner, "card-"+realName, objID)
|
data, err := b.store.GetObject(owner, "addressbooks/"+realName, objID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ func (b *Backend) ListAddressObjects(ctx context.Context, bookPath string, req *
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ids, err := b.store.ListObjects(owner, "card-"+realName)
|
ids, err := b.store.ListObjects(owner, "addressbooks/"+realName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ func (b *Backend) DeleteAddressBook(ctx context.Context, bookPath string) error
|
|||||||
if err := b.dbase.DeleteAddressBook(owner, realName); err != nil && err != db.ErrResourceNotFound {
|
if err := b.dbase.DeleteAddressBook(owner, realName); err != nil && err != db.ErrResourceNotFound {
|
||||||
return fmt.Errorf("unregistering address book: %w", err)
|
return fmt.Errorf("unregistering address book: %w", err)
|
||||||
}
|
}
|
||||||
return b.store.DeleteCollection(owner, "card-"+realName)
|
return b.store.DeleteCollection(owner, "addressbooks/"+realName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Backend) PutAddressObject(ctx context.Context, objPath string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) {
|
func (b *Backend) PutAddressObject(ctx context.Context, objPath string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) {
|
||||||
|
|||||||
@@ -102,8 +102,6 @@ func (d *DB) DisplayName(username string) string {
|
|||||||
return u.DisplayName
|
return u.DisplayName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// DeleteUser removes username along with all of its calendars, address
|
// DeleteUser removes username along with all of its calendars, address
|
||||||
// books, and sharing grants (calendars/addressbooks cascade via foreign
|
// books, and sharing grants (calendars/addressbooks cascade via foreign
|
||||||
// key; shares are cleaned up explicitly since they reference usernames as
|
// key; shares are cleaned up explicitly since they reference usernames as
|
||||||
|
|||||||
+77
-104
@@ -53,6 +53,12 @@ func (s *Store) collectionPath(user, collection string) string {
|
|||||||
return filepath.Join(s.rootDir, sanitize(user), sanitize(collection))
|
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.
|
// objectPath returns the filesystem path for an object within a collection.
|
||||||
func (s *Store) objectPath(user, collection, objectID string) string {
|
func (s *Store) objectPath(user, collection, objectID string) string {
|
||||||
return filepath.Join(s.collectionPath(user, collection), sanitize(objectID))
|
return filepath.Join(s.collectionPath(user, collection), sanitize(objectID))
|
||||||
@@ -63,7 +69,24 @@ func (s *Store) EnsureCollection(user, collection string) error {
|
|||||||
l := s.lockFor(user)
|
l := s.lockFor(user)
|
||||||
l.Lock()
|
l.Lock()
|
||||||
defer l.Unlock()
|
defer l.Unlock()
|
||||||
dir := s.collectionPath(user, collection)
|
|
||||||
|
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)
|
return os.MkdirAll(dir, 0o755)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +108,18 @@ func (s *Store) ListCollections(user string) ([]string, error) {
|
|||||||
var names []string
|
var names []string
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() {
|
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())
|
names = append(names, e.Name())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return names, nil
|
return names, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +128,25 @@ func (s *Store) GetCollection(user, collection string) (os.FileInfo, error) {
|
|||||||
l := s.lockFor(user)
|
l := s.lockFor(user)
|
||||||
l.RLock()
|
l.RLock()
|
||||||
defer l.RUnlock()
|
defer l.RUnlock()
|
||||||
info, err := os.Stat(s.collectionPath(user, collection))
|
|
||||||
|
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) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -155,7 +205,17 @@ func (s *Store) ListObjects(user, collection string) ([]string, error) {
|
|||||||
l.RLock()
|
l.RLock()
|
||||||
defer l.RUnlock()
|
defer l.RUnlock()
|
||||||
|
|
||||||
dir := s.collectionPath(user, collection)
|
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)
|
entries, err := os.ReadDir(dir)
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -191,113 +251,26 @@ func (s *Store) DeleteCollection(user, collection string) error {
|
|||||||
l := s.lockFor(user)
|
l := s.lockFor(user)
|
||||||
l.Lock()
|
l.Lock()
|
||||||
defer l.Unlock()
|
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
|
var path string
|
||||||
// directly under user/root, and files/<username>) into the new structured col/
|
if strings.Contains(collection, "/") {
|
||||||
// subdirectory layout (<user>/col/calendars/*, <user>/col/addressbooks/*,
|
// It's a full path like "calendars/work"
|
||||||
// <user>/col/files).
|
path = s.collectionPath(user, collection)
|
||||||
func cleanupLegacy(dataDir, username string) error {
|
} else {
|
||||||
username = filepath.Base(username) // sanitize path traversal
|
// Try the old-style path first (for backwards compatibility)
|
||||||
userRoot := filepath.Join(dataDir, username)
|
oldPath := s.collectionPath(user, collection)
|
||||||
|
_, err := os.Stat(oldPath)
|
||||||
// 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 {
|
if err == nil {
|
||||||
for _, s := range subEntries {
|
path = oldPath
|
||||||
src := filepath.Join(oldFilesRoot, s.Name())
|
} else {
|
||||||
dst := filepath.Join(dstFiles, s.Name())
|
// For new paths, we need to try both structure styles:
|
||||||
if s.IsDir() {
|
// data/<username>/<type>/<name>
|
||||||
os.MkdirAll(filepath.Dir(dst), 0o755)
|
path = s.collectionPath(user, collection)
|
||||||
}
|
}
|
||||||
os.Rename(src, dst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
os.RemoveAll(oldFilesRoot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
err := os.RemoveAll(path)
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
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.
|
// sanitize removes path-traversal characters from a path segment.
|
||||||
|
|||||||
@@ -527,7 +527,6 @@ func mondayOf(t time.Time) time.Time {
|
|||||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset)
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// eventDayRange returns the inclusive [start, end] calendar-day span an
|
// eventDayRange returns the inclusive [start, end] calendar-day span an
|
||||||
// event occupies, in loc, for placing it on the month grid.
|
// event occupies, in loc, for placing it on the month grid.
|
||||||
func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) {
|
func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) {
|
||||||
|
|||||||
@@ -117,9 +117,9 @@ func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind str
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
collPrefix := "col/calendars/"
|
collPrefix := "cal-"
|
||||||
if kind == "addressbook" {
|
if kind == "addressbook" {
|
||||||
collPrefix = "col/addressbooks/"
|
collPrefix = "card-"
|
||||||
}
|
}
|
||||||
|
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
|
|||||||
@@ -316,4 +316,3 @@ func TestAccountChangePassword(t *testing.T) {
|
|||||||
t.Fatal("expected password to have changed")
|
t.Fatal("expected password to have changed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/yourusername/caldav-server/internal/auth"
|
"github.com/yourusername/caldav-server/internal/auth"
|
||||||
@@ -13,19 +12,9 @@ import (
|
|||||||
xwebdav "golang.org/x/net/webdav"
|
xwebdav "golang.org/x/net/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHandler returns an http.Handler that provides standard WebDAV file access,
|
// NewHandler returns an http.Handler that provides standard WebDAV file access,
|
||||||
// mounted at the fixed URL /files/ for every user and rooted at
|
// mounted at the fixed URL /files/ for every user and rooted at
|
||||||
// dataDir/<username>/col/files/ on disk. The URL is the same for all users —
|
// dataDir/<username>/files/ on disk. The URL is the same for all users —
|
||||||
// which user's directory is served is resolved from the Basic Auth identity
|
// which user's directory is served is resolved from the Basic Auth identity
|
||||||
// in the request context, not from the URL.
|
// in the request context, not from the URL.
|
||||||
//
|
//
|
||||||
@@ -49,7 +38,7 @@ func NewHandler(cfg *config.Config, dataDir string, logger *slog.Logger) http.Ha
|
|||||||
h, ok := handlers[p.Username]
|
h, ok := handlers[p.Username]
|
||||||
if !ok {
|
if !ok {
|
||||||
username := p.Username
|
username := p.Username
|
||||||
userDir := filepath.Join(dataDir, sanitize(username), "col", "files")
|
userDir := filepath.Join(dataDir, username, "files")
|
||||||
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
logger.Error("creating user WebDAV dir", "user", username, "error", err)
|
logger.Error("creating user WebDAV dir", "user", username, "error", err)
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ func TestPerUserIsolationAndPrefix(t *testing.T) {
|
|||||||
t.Fatalf("expected bob to get 404 for alice's file, got %d", bobRec.Code)
|
t.Fatalf("expected bob to get 404 for alice's file, got %d", bobRec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm the file physically landed under dataDir/files/alice/, not
|
// Confirm the file physically landed under dataDir/alice/files/, matching
|
||||||
// nested under an extra files/files/... path.
|
// the new nested structure.
|
||||||
if _, err := os.Stat(dir + "/files/alice/note.txt"); err != nil {
|
if _, err := os.Stat(dir + "/alice/files/note.txt"); err != nil {
|
||||||
t.Fatalf("expected file at dataDir/files/alice/note.txt: %v", err)
|
t.Fatalf("expected file at dataDir/alice/files/note.txt: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user