From a74c76857da675e3a2a4b42a61b20eab043ed411 Mon Sep 17 00:00:00 2001 From: Conrad Hoffmann Date: Fri, 2 Feb 2024 22:19:36 +0100 Subject: [PATCH] Initial multi-calendar/address book support Thanks to the latest version of go-webdav, this is now a thing. A lot of operations (like creating a calendar) are not yet supported. But the basics work fine. Note that multi-calendar means that different users can each have their own calenders. Resource sharing is not yet implemented either. Includes the adding of a lot of debug logs, as issues are otherwise pretty hard to figure out. The logging still needs to be made more consistent, and probably cleaned up a bit in some places. --- cmd/tokidoki/main.go | 2 +- go.mod | 4 +- go.sum | 9 +- storage/filesystem.go | 40 ++++-- storage/filesystem_caldav.go | 175 +++++++++++++++++------ storage/filesystem_carddav.go | 260 +++++++++++++++++++++++----------- storage/postgresql.go | 12 +- 7 files changed, 347 insertions(+), 155 deletions(-) diff --git a/cmd/tokidoki/main.go b/cmd/tokidoki/main.go index a8fea6a..3b982f7 100644 --- a/cmd/tokidoki/main.go +++ b/cmd/tokidoki/main.go @@ -58,7 +58,7 @@ func (u *tokidokiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } if u.carddavBackend != nil { - path, err := u.carddavBackend.AddressbookHomeSetPath(r.Context()) + path, err := u.carddavBackend.AddressBookHomeSetPath(r.Context()) if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) } else { diff --git a/go.mod b/go.mod index 0b82583..4fc335f 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/emersion/go-imap v1.2.1 github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 - github.com/emersion/go-webdav v0.4.0 + github.com/emersion/go-webdav v0.5.1-0.20240202164822-eaac65215b3a github.com/go-chi/chi/v5 v5.0.10 github.com/msteinert/pam v1.2.0 github.com/rs/zerolog v1.31.0 @@ -17,6 +17,6 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/teambition/rrule-go v1.8.2 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/sys v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index a4baf96..eca6eec 100644 --- a/go.sum +++ b/go.sum @@ -8,11 +8,10 @@ github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTe github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= -github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= -github.com/emersion/go-webdav v0.4.0 h1:iIkgitJBUNu2c1vL0KqqRb5jDjs38bzM/H7WxewrIh4= -github.com/emersion/go-webdav v0.4.0/go.mod h1:lkPYZO/vsDNV9GPyVMBBsAUZzzxINL97bEVFykApo58= +github.com/emersion/go-webdav v0.5.1-0.20240202164822-eaac65215b3a h1:IHOPSOw+XXKRSkuXTOeEtDejkuXqf0ohjZq9hew6ArA= +github.com/emersion/go-webdav v0.5.1-0.20240202164822-eaac65215b3a/go.mod h1:ycyIzTelG5pHln4t+Y32/zBvmrM7+mV7x+V+Gx4ZQno= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -34,8 +33,8 @@ github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/storage/filesystem.go b/storage/filesystem.go index 2f34959..afc058b 100644 --- a/storage/filesystem.go +++ b/storage/filesystem.go @@ -57,9 +57,20 @@ func ensureLocalDir(path string) error { return nil } +func (b *filesystemBackend) localDir(homeSetPath string, components ...string) (string, error) { + c := append([]string{b.path}, homeSetPath) + c = append(c, components...) + localPath := filepath.Join(c...) + if err := ensureLocalDir(localPath); err != nil { + return "", err + } + return localPath, nil +} + // don't use this directly, use localCalDAVPath or localCardDAVPath instead. +// note that homesetpath is expected to end in / func (b *filesystemBackend) safeLocalPath(homeSetPath string, urlPath string) (string, error) { - localPath := filepath.Join(b.path, homeSetPath, defaultResourceName) + localPath := filepath.Join(b.path, homeSetPath) if err := ensureLocalDir(localPath); err != nil { return "", err } @@ -69,27 +80,26 @@ func (b *filesystemBackend) safeLocalPath(homeSetPath string, urlPath string) (s } // We are mapping to local filesystem path, so be conservative about what to accept - // TODO this changes once multiple addess books are supported - dir, file := path.Split(urlPath) - // only accept resources in default calendar/adress book for now - if path.Clean(dir) != path.Join(homeSetPath, defaultResourceName) { - if strings.HasPrefix(dir, homeSetPath) { - err := fmt.Errorf("invalid request path: %s (%s is not %s)", urlPath, dir, path.Join(homeSetPath, defaultResourceName)) - return "", webdav.NewHTTPError(400, err) - } else { - err := fmt.Errorf("access to resource outside of home set: %s", urlPath) - return "", webdav.NewHTTPError(403, err) - } + if strings.HasSuffix(urlPath, "/") { + urlPath = path.Clean(urlPath) + "/" + } else { + urlPath = path.Clean(urlPath) } + if !strings.HasPrefix(urlPath, homeSetPath) { + err := fmt.Errorf("access to resource outside of home set: %s", urlPath) + return "", webdav.NewHTTPError(403, err) + } + urlPath = strings.TrimPrefix(urlPath, homeSetPath) + // only accept simple file names for now - if !validFilenameRegex.MatchString(file) { + dir, file := path.Split(urlPath) + if file != "" && !validFilenameRegex.MatchString(file) { log.Debug().Str("file", file).Msg("file name does not match regex") err := fmt.Errorf("invalid file name: %s", file) return "", webdav.NewHTTPError(400, err) } - // dir (= homeSetPath) is already included in path, so only file here - return filepath.Join(localPath, file), nil + return filepath.Join(localPath, dir, file), nil } func etagForFile(path string) (string, error) { diff --git a/storage/filesystem_caldav.go b/storage/filesystem_caldav.go index 193cd2a..ffd74ed 100644 --- a/storage/filesystem_caldav.go +++ b/storage/filesystem_caldav.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io/fs" - "io/ioutil" "net/http" "os" "path" @@ -19,6 +18,8 @@ import ( "github.com/emersion/go-webdav/caldav" ) +const calendarFileName = "calendar.json" + func (b *filesystemBackend) CalendarHomeSetPath(ctx context.Context) (string, error) { upPath, err := b.CurrentUserPrincipal(ctx) if err != nil { @@ -28,7 +29,16 @@ func (b *filesystemBackend) CalendarHomeSetPath(ctx context.Context) (string, er return path.Join(upPath, b.caldavPrefix) + "/", nil } -func (b *filesystemBackend) localCalDAVPath(ctx context.Context, urlPath string) (string, error) { +func (b *filesystemBackend) localCalDAVDir(ctx context.Context, components ...string) (string, error) { + homeSetPath, err := b.CalendarHomeSetPath(ctx) + if err != nil { + return "", err + } + + return b.localDir(homeSetPath, components...) +} + +func (b *filesystemBackend) safeLocalCalDAVPath(ctx context.Context, urlPath string) (string, error) { homeSetPath, err := b.CalendarHomeSetPath(ctx) if err != nil { return "", err @@ -55,18 +65,15 @@ func calendarFromFile(path string, propFilter []string) (*ical.Calendar, error) //return icalPropFilter(cal, propFilter), nil } -func (b *filesystemBackend) loadAllCalendars(ctx context.Context, propFilter []string) ([]caldav.CalendarObject, error) { +func (b *filesystemBackend) loadAllCalendarObjects(ctx context.Context, urlPath string, propFilter []string) ([]caldav.CalendarObject, error) { var result []caldav.CalendarObject - localPath, err := b.localCalDAVPath(ctx, "") + localPath, err := b.safeLocalCalDAVPath(ctx, urlPath) if err != nil { return result, err } - homeSetPath, err := b.CalendarHomeSetPath(ctx) - if err != nil { - return result, err - } + log.Debug().Str("path", localPath).Msg("loading calendar objects") err = filepath.Walk(localPath, func(filename string, info os.FileInfo, err error) error { if err != nil { @@ -89,8 +96,10 @@ func (b *filesystemBackend) loadAllCalendars(ctx context.Context, propFilter []s return err } + // TODO can this potentially be called on a calendar object resource? + // Would work (as Walk() includes root), except for the path construction below obj := caldav.CalendarObject{ - Path: path.Join(homeSetPath, defaultResourceName, filepath.Base(filename)), + Path: path.Join(urlPath, filepath.Base(filename)), ModTime: info.ModTime(), ContentLength: info.Size(), ETag: etag, @@ -100,55 +109,118 @@ func (b *filesystemBackend) loadAllCalendars(ctx context.Context, propFilter []s return nil }) - log.Debug().Int("results", len(result)).Str("path", localPath).Msg("filesystem.loadAllCalendars() successful") return result, err } -func createDefaultCalendar(path, localPath string) error { +func (b *filesystemBackend) createDefaultCalendar(ctx context.Context) (*caldav.Calendar, error) { // TODO what should the default calendar look like? + localPath, err_ := b.localCalDAVDir(ctx, defaultResourceName) + if err_ != nil { + return nil, fmt.Errorf("error creating default calendar: %s", err_.Error()) + } + + homeSetPath, err_ := b.CalendarHomeSetPath(ctx) + if err_ != nil { + return nil, fmt.Errorf("error creating default calendar: %s", err_.Error()) + } + + urlPath := path.Join(homeSetPath, defaultResourceName) + "/" + + log.Debug().Str("local", localPath).Str("url", urlPath).Msg("filesystem.createDefaultCalendar()") + defaultC := caldav.Calendar{ - Path: path, + Path: urlPath, Name: "My calendar", Description: "Default calendar", MaxResourceSize: 4096, } blob, err := json.MarshalIndent(defaultC, "", " ") if err != nil { - return fmt.Errorf("error creating default calendar: %s", err.Error()) + return nil, fmt.Errorf("error creating default calendar: %s", err.Error()) } - err = os.WriteFile(localPath, blob, 0644) + err = os.WriteFile(path.Join(localPath, calendarFileName), blob, 0644) if err != nil { - return fmt.Errorf("error writing default calendar: %s", err.Error()) + return nil, fmt.Errorf("error writing default calendar: %s", err.Error()) } - return nil + return &defaultC, nil } -func (b *filesystemBackend) Calendar(ctx context.Context) (*caldav.Calendar, error) { - log.Debug().Msg("filesystem.Calendar()") +func (b *filesystemBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) { + log.Debug().Msg("filesystem.ListCalendars()") - localPath, err := b.localCalDAVPath(ctx, "") + localPath, err := b.localCalDAVDir(ctx) if err != nil { return nil, err } - localPath = filepath.Join(localPath, "calendar.json") + + log.Debug().Str("path", localPath).Msg("looking for calendars") + + var result []caldav.Calendar + + err = filepath.Walk(localPath, func(filename string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("error accessing %s: %s", filename, err.Error()) + } + + if !info.IsDir() || filename == localPath { + return nil + } + + calPath := path.Join(filename, calendarFileName) + data, err := os.ReadFile(calPath) + if err != nil { + if os.IsNotExist(err) { + return nil // not a calendar dir + } else { + return fmt.Errorf("error accessing %s: %s", calPath, err.Error()) + } + } + + var calendar caldav.Calendar + err = json.Unmarshal(data, &calendar) + if err != nil { + return fmt.Errorf("error reading calendar %s: %s", calPath, err.Error()) + } + + result = append(result, calendar) + return nil + }) + + if err == nil && len(result) == 0 { + // Nothing here yet? Create the default calendar. + log.Debug().Msg("no calendars found, creating default calendar") + cal, err_ := b.createDefaultCalendar(ctx) + if err_ != nil { + log.Debug().Int("results", len(result)).Bool("success", false).Str("error", err_.Error()).Msg("filesystem.ListCalendars() done") + return nil, fmt.Errorf("error creating default calendar: %s", err_.Error()) + } + result = append(result, *cal) + } + + if err != nil { + log.Warn().Int("results", len(result)).Bool("success", false).Str("error", err.Error()).Msg("filesystem.ListCalendars() done") + } else { + log.Debug().Int("results", len(result)).Bool("success", true).Msg("filesystem.ListCalendars() done") + } + return result, err +} + +func (b *filesystemBackend) GetCalendar(ctx context.Context, urlPath string) (*caldav.Calendar, error) { + log.Debug().Str("path", urlPath).Msg("filesystem.GetCalendar()") + + localPath, err := b.safeLocalCalDAVPath(ctx, urlPath) + if err != nil { + return nil, err + } + localPath = filepath.Join(localPath, calendarFileName) log.Debug().Str("local_path", localPath).Msg("loading calendar") - data, readErr := ioutil.ReadFile(localPath) - if os.IsNotExist(readErr) { - urlPath, err := b.CalendarHomeSetPath(ctx) - if err != nil { - return nil, err - } - urlPath = path.Join(urlPath, defaultResourceName) + "/" - log.Debug().Str("local_path", localPath).Str("url_path", urlPath).Msg("creating calendar") - err = createDefaultCalendar(urlPath, localPath) - if err != nil { - return nil, err - } - data, readErr = ioutil.ReadFile(localPath) - } + data, readErr := os.ReadFile(localPath) if readErr != nil { + if os.IsNotExist(readErr) { + return nil, webdav.NewHTTPError(404, err) + } return nil, fmt.Errorf("error opening calendar: %s", readErr.Error()) } var calendar caldav.Calendar @@ -163,7 +235,7 @@ func (b *filesystemBackend) Calendar(ctx context.Context) (*caldav.Calendar, err func (b *filesystemBackend) GetCalendarObject(ctx context.Context, objPath string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { log.Debug().Str("url_path", objPath).Msg("filesystem.GetCalendarObject()") - localPath, err := b.localCalDAVPath(ctx, objPath) + localPath, err := b.safeLocalCalDAVPath(ctx, objPath) if err != nil { return nil, err } @@ -203,31 +275,46 @@ func (b *filesystemBackend) GetCalendarObject(ctx context.Context, objPath strin return &obj, nil } -func (b *filesystemBackend) ListCalendarObjects(ctx context.Context, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { - log.Debug().Msg("filesystem.ListCalendarObjects()") +func (b *filesystemBackend) ListCalendarObjects(ctx context.Context, urlPath string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { + log.Debug().Str("path", urlPath).Msg("filesystem.ListCalendarObjects()") var propFilter []string if req != nil && !req.AllProps { propFilter = req.Props } - return b.loadAllCalendars(ctx, propFilter) + result, err := b.loadAllCalendarObjects(ctx, urlPath, propFilter) + if err != nil { + log.Warn().Int("results", len(result)).Bool("success", false).Str("error", err.Error()).Msg("filesystem.ListCalendarObjects() done") + } else { + log.Debug().Int("results", len(result)).Bool("success", true).Msg("filesystem.ListCalendarObjects() done") + } + return result, err } -func (b *filesystemBackend) QueryCalendarObjects(ctx context.Context, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { - log.Debug().Msg("filesystem.QueryCalendarObjects()") +func (b *filesystemBackend) QueryCalendarObjects(ctx context.Context, urlPath string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { + log.Debug().Str("path", urlPath).Msg("filesystem.QueryCalendarObjects()") var propFilter []string if query != nil && !query.CompRequest.AllProps { propFilter = query.CompRequest.Props } - result, err := b.loadAllCalendars(ctx, propFilter) + result, err := b.loadAllCalendarObjects(ctx, urlPath, propFilter) if err != nil { + log.Warn().Int("results", len(result)).Str("error", err.Error()).Msg("filesystem.QueryCalendarObjects() error loading") return result, err } - return caldav.Filter(query, result) + log.Debug().Int("results", len(result)).Bool("success", true).Msg("filesystem.QueryCalendarObjects() load done") + + filtered, err := caldav.Filter(query, result) + if err != nil { + log.Warn().Int("results", len(result)).Str("error", err.Error()).Msg("filesystem.QueryCalendarObjects() error filtering") + return result, err + } + log.Debug().Int("results", len(filtered)).Bool("success", true).Msg("filesystem.QueryCalendarObjects() done") + return filtered, nil } func (b *filesystemBackend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (loc string, err error) { @@ -242,7 +329,7 @@ func (b *filesystemBackend) PutCalendarObject(ctx context.Context, objPath strin dirname, _ := path.Split(objPath) objPath = path.Join(dirname, uid+".ics") - localPath, err := b.localCalDAVPath(ctx, objPath) + localPath, err := b.safeLocalCalDAVPath(ctx, objPath) if err != nil { return "", err } @@ -291,7 +378,7 @@ func (b *filesystemBackend) PutCalendarObject(ctx context.Context, objPath strin func (b *filesystemBackend) DeleteCalendarObject(ctx context.Context, path string) error { log.Debug().Str("url_path", path).Msg("filesystem.DeleteCalendarObject()") - localPath, err := b.localCalDAVPath(ctx, path) + localPath, err := b.safeLocalCalDAVPath(ctx, path) if err != nil { return err } diff --git a/storage/filesystem_carddav.go b/storage/filesystem_carddav.go index 257456c..cfee141 100644 --- a/storage/filesystem_carddav.go +++ b/storage/filesystem_carddav.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io/fs" - "io/ioutil" "net/http" "os" "path" @@ -19,7 +18,9 @@ import ( "github.com/emersion/go-webdav/carddav" ) -func (b *filesystemBackend) AddressbookHomeSetPath(ctx context.Context) (string, error) { +const addressBookFileName = "addressbook.json" + +func (b *filesystemBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) { upPath, err := b.CurrentUserPrincipal(ctx) if err != nil { return "", err @@ -28,8 +29,17 @@ func (b *filesystemBackend) AddressbookHomeSetPath(ctx context.Context) (string, return path.Join(upPath, b.carddavPrefix) + "/", nil } -func (b *filesystemBackend) localCardDAVPath(ctx context.Context, urlPath string) (string, error) { - homeSetPath, err := b.AddressbookHomeSetPath(ctx) +func (b *filesystemBackend) localCardDAVDir(ctx context.Context, components ...string) (string, error) { + homeSetPath, err := b.AddressBookHomeSetPath(ctx) + if err != nil { + return "", err + } + + return b.localDir(homeSetPath, components...) +} + +func (b *filesystemBackend) safeLocalCardDAVPath(ctx context.Context, urlPath string) (string, error) { + homeSetPath, err := b.AddressBookHomeSetPath(ctx) if err != nil { return "", err } @@ -74,10 +84,69 @@ func vcardFromFile(path string, propFilter []string) (vcard.Card, error) { return vcardPropFilter(card, propFilter), nil } -func createDefaultAddressBook(path, localPath string) error { +func (b *filesystemBackend) loadAllAddressObjects(ctx context.Context, urlPath string, propFilter []string) ([]carddav.AddressObject, error) { + var result []carddav.AddressObject + + localPath, err := b.safeLocalCardDAVPath(ctx, urlPath) + if err != nil { + return result, err + } + + log.Debug().Str("path", localPath).Msg("loading address objects") + + err = filepath.Walk(localPath, func(filename string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("error accessing %s: %s", filename, err) + } + + if !info.Mode().IsRegular() || filepath.Ext(filename) != ".vcf" { + return nil + } + + card, err := vcardFromFile(filename, propFilter) + if err != nil { + return err + } + + etag, err := etagForFile(filename) + if err != nil { + return err + } + + // TODO can this potentially be called on an address object resource? + // would work (as Walk() includes root), except for the path construction below + obj := carddav.AddressObject{ + Path: path.Join(urlPath, filepath.Base(filename)), + ModTime: info.ModTime(), + ContentLength: info.Size(), + ETag: etag, + Card: card, + } + result = append(result, obj) + return nil + }) + + return result, err +} + +func (b *filesystemBackend) createDefaultAddressBook(ctx context.Context) (*carddav.AddressBook, error) { // TODO what should the default address book look like? + localPath, err_ := b.localCardDAVDir(ctx, defaultResourceName) + if err_ != nil { + return nil, fmt.Errorf("error creating default address book: %s", err_.Error()) + } + + homeSetPath, err_ := b.AddressBookHomeSetPath(ctx) + if err_ != nil { + return nil, fmt.Errorf("error creating default address book: %s", err_.Error()) + } + + urlPath := path.Join(homeSetPath, defaultResourceName) + "/" + + log.Debug().Str("local", localPath).Str("url", urlPath).Msg("filesystem.createDefaultAddressBook()") + defaultAB := carddav.AddressBook{ - Path: path, + Path: urlPath, Name: "My contacts", Description: "Default address book", MaxResourceSize: 1024, @@ -85,40 +154,92 @@ func createDefaultAddressBook(path, localPath string) error { } blob, err := json.MarshalIndent(defaultAB, "", " ") if err != nil { - return fmt.Errorf("error creating default address book: %s", err.Error()) + return nil, fmt.Errorf("error creating default address book: %s", err.Error()) } - err = os.WriteFile(localPath, blob, 0644) + err = os.WriteFile(path.Join(localPath, addressBookFileName), blob, 0644) if err != nil { - return fmt.Errorf("error writing default address book: %s", err.Error()) + return nil, fmt.Errorf("error writing default address book: %s", err.Error()) } - return nil + return &defaultAB, nil } -func (b *filesystemBackend) AddressBook(ctx context.Context) (*carddav.AddressBook, error) { - log.Debug().Msg("filesystem.AddressBook()") - localPath, err := b.localCardDAVPath(ctx, "") +func (b *filesystemBackend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) { + log.Debug().Msg("filesystem.ListAddressBooks()") + + localPath, err := b.localCardDAVDir(ctx) if err != nil { return nil, err } - localPath = filepath.Join(localPath, "addressbook.json") + + log.Debug().Str("path", localPath).Msg("looking for address books") + + var result []carddav.AddressBook + + err = filepath.Walk(localPath, func(filename string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("error accessing %s: %s", filename, err.Error()) + } + + if !info.IsDir() || filename == localPath { + return nil + } + + abPath := path.Join(filename, addressBookFileName) + data, err := os.ReadFile(abPath) + if err != nil { + if os.IsNotExist(err) { + return nil // not an address book dir + } else { + return fmt.Errorf("error accessing %s: %s", abPath, err.Error()) + } + } + + var addressBook carddav.AddressBook + err = json.Unmarshal(data, &addressBook) + if err != nil { + return fmt.Errorf("error reading address book %s: %s", abPath, err.Error()) + } + + result = append(result, addressBook) + return nil + }) + + if err == nil && len(result) == 0 { + // Nothing here yet? Create the default address book + log.Debug().Msg("no address books found, creating default address book") + ab, err_ := b.createDefaultAddressBook(ctx) + if err_ != nil { + log.Debug().Int("results", len(result)).Bool("success", false).Str("error", err_.Error()).Msg("filesystem.ListAddressBooks() done") + return nil, fmt.Errorf("error creating default address book: %s", err_.Error()) + } + result = append(result, *ab) + } + + if err != nil { + log.Warn().Int("results", len(result)).Bool("success", false).Str("error", err.Error()).Msg("filesystem.ListAddressBooks() done") + } else { + log.Debug().Int("results", len(result)).Bool("success", true).Msg("filesystem.ListAddressBooks() done") + } + return result, err +} + +func (b *filesystemBackend) GetAddressBook(ctx context.Context, urlPath string) (*carddav.AddressBook, error) { + log.Debug().Str("path", urlPath).Msg("filesystem.AddressBook()") + + localPath, err := b.safeLocalCardDAVPath(ctx, urlPath) + if err != nil { + return nil, err + } + localPath = filepath.Join(localPath, addressBookFileName) log.Debug().Str("local_path", localPath).Msg("loading addressbook") - data, readErr := ioutil.ReadFile(localPath) - if os.IsNotExist(readErr) { - urlPath, err := b.AddressbookHomeSetPath(ctx) - if err != nil { - return nil, err - } - urlPath = path.Join(urlPath, defaultResourceName) + "/" - log.Debug().Str("local_path", localPath).Str("url_path", urlPath).Msg("creating addressbook") - err = createDefaultAddressBook(urlPath, localPath) - if err != nil { - return nil, err - } - data, readErr = ioutil.ReadFile(localPath) - } + data, readErr := os.ReadFile(localPath) + if readErr != nil { + if os.IsNotExist(readErr) { + return nil, webdav.NewHTTPError(404, err) + } return nil, fmt.Errorf("error opening address book: %s", readErr.Error()) } var addressBook carddav.AddressBook @@ -132,7 +253,8 @@ func (b *filesystemBackend) AddressBook(ctx context.Context) (*carddav.AddressBo func (b *filesystemBackend) GetAddressObject(ctx context.Context, objPath string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) { log.Debug().Str("url_path", objPath).Msg("filesystem.GetAddressObject()") - localPath, err := b.localCardDAVPath(ctx, objPath) + + localPath, err := b.safeLocalCardDAVPath(ctx, objPath) if err != nil { return nil, err } @@ -170,76 +292,46 @@ func (b *filesystemBackend) GetAddressObject(ctx context.Context, objPath string return &obj, nil } -func (b *filesystemBackend) loadAllContacts(ctx context.Context, propFilter []string) ([]carddav.AddressObject, error) { - var result []carddav.AddressObject +func (b *filesystemBackend) ListAddressObjects(ctx context.Context, urlPath string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { + log.Debug().Str("path", urlPath).Msg("filesystem.ListAddressObjects()") - localPath, err := b.localCardDAVPath(ctx, "") - if err != nil { - return result, err - } - - homeSetPath, err := b.AddressbookHomeSetPath(ctx) - if err != nil { - return result, err - } - - err = filepath.Walk(localPath, func(filename string, info os.FileInfo, err error) error { - if err != nil { - return fmt.Errorf("error accessing %s: %s", filename, err) - } - - if !info.Mode().IsRegular() || filepath.Ext(filename) != ".vcf" { - return nil - } - - card, err := vcardFromFile(filename, propFilter) - if err != nil { - return err - } - - etag, err := etagForFile(filename) - if err != nil { - return err - } - - obj := carddav.AddressObject{ - Path: path.Join(homeSetPath, defaultResourceName, filepath.Base(filename)), - ModTime: info.ModTime(), - ContentLength: info.Size(), - ETag: etag, - Card: card, - } - result = append(result, obj) - return nil - }) - - log.Debug().Int("results", len(result)).Str("path", localPath).Msg("filesystem.loadAllContacts() successful") - return result, err -} - -func (b *filesystemBackend) ListAddressObjects(ctx context.Context, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { - log.Debug().Msg("filesystem.ListAddressObjects()") var propFilter []string if req != nil && !req.AllProp { propFilter = req.Props } - return b.loadAllContacts(ctx, propFilter) + result, err := b.loadAllAddressObjects(ctx, urlPath, propFilter) + if err != nil { + log.Warn().Int("results", len(result)).Bool("success", false).Str("error", err.Error()).Msg("filesystem.ListAddressObjects() done") + } else { + log.Debug().Int("results", len(result)).Bool("success", true).Msg("filesystem.ListAddressObjects() done") + } + return result, err } -func (b *filesystemBackend) QueryAddressObjects(ctx context.Context, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { - log.Debug().Msg("filesystem.QueryAddressObjects()") +func (b *filesystemBackend) QueryAddressObjects(ctx context.Context, urlPath string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { + log.Debug().Str("path", urlPath).Msg("filesystem.QueryAddressObjects()") + var propFilter []string if query != nil && !query.DataRequest.AllProp { propFilter = query.DataRequest.Props } - result, err := b.loadAllContacts(ctx, propFilter) + result, err := b.loadAllAddressObjects(ctx, urlPath, propFilter) if err != nil { + log.Warn().Int("results", len(result)).Str("error", err.Error()).Msg("filesystem.QueryAddressObjects() error loading") return result, err } - return carddav.Filter(query, result) + log.Debug().Int("results", len(result)).Bool("success", true).Msg("filesystem.QueryAddressObjects() load done") + + filtered, err := carddav.Filter(query, result) + if err != nil { + log.Warn().Int("results", len(result)).Str("error", err.Error()).Msg("filesystem.QueryAddressObjects() error filtering") + return result, err + } + log.Debug().Int("results", len(filtered)).Bool("success", true).Msg("filesystem.QueryAddressObjects() done") + return filtered, nil } func (b *filesystemBackend) PutAddressObject(ctx context.Context, objPath string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (loc string, err error) { @@ -249,7 +341,7 @@ func (b *filesystemBackend) PutAddressObject(ctx context.Context, objPath string dirname, _ := path.Split(objPath) objPath = path.Join(dirname, card.Value(vcard.FieldUID)+".vcf") - localPath, err := b.localCardDAVPath(ctx, objPath) + localPath, err := b.safeLocalCardDAVPath(ctx, objPath) if err != nil { return "", err } @@ -298,7 +390,7 @@ func (b *filesystemBackend) PutAddressObject(ctx context.Context, objPath string func (b *filesystemBackend) DeleteAddressObject(ctx context.Context, path string) error { log.Debug().Str("url_path", path).Msg("filesystem.DeleteAddressObject()") - localPath, err := b.localCardDAVPath(ctx, path) + localPath, err := b.safeLocalCardDAVPath(ctx, path) if err != nil { return err } diff --git a/storage/postgresql.go b/storage/postgresql.go index b2a9c87..25c0972 100644 --- a/storage/postgresql.go +++ b/storage/postgresql.go @@ -20,11 +20,15 @@ func (*psqlBackend) CurrentUserPrincipal(ctx context.Context) (string, error) { panic("TODO") } -func (*psqlBackend) AddressbookHomeSetPath(ctx context.Context) (string, error) { +func (*psqlBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) { panic("TODO") } -func (*psqlBackend) AddressBook(ctx context.Context) (*carddav.AddressBook, error) { +func (*psqlBackend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) { + panic("TODO") +} + +func (*psqlBackend) GetAddressBook(ctx context.Context, path string) (*carddav.AddressBook, error) { panic("TODO") } @@ -32,11 +36,11 @@ func (*psqlBackend) GetAddressObject(ctx context.Context, path string, req *card panic("TODO") } -func (*psqlBackend) ListAddressObjects(ctx context.Context, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { +func (*psqlBackend) ListAddressObjects(ctx context.Context, path string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { panic("TODO") } -func (*psqlBackend) QueryAddressObjects(ctx context.Context, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { +func (*psqlBackend) QueryAddressObjects(ctx context.Context, path string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { panic("TODO") }