diff --git a/storage/filesystem.go b/storage/filesystem.go index 0f82ef7..21aa8be 100644 --- a/storage/filesystem.go +++ b/storage/filesystem.go @@ -1,24 +1,16 @@ package storage import ( - "context" "crypto/sha1" "encoding/base64" - "encoding/json" - "errors" "fmt" "io" - "io/fs" - "io/ioutil" - "net/http" "os" "path" "path/filepath" "regexp" "strings" - "github.com/emersion/go-ical" - "github.com/emersion/go-vcard" "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/carddav" @@ -34,7 +26,8 @@ type filesystemBackend struct { } var ( - validFilenameRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]+(.[a-zA-Z]+)?$`) + validFilenameRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]+(.[a-zA-Z]+)?$`) + defaultResourceName = "default" ) func NewFilesystem(fsPath, caldavPrefix, carddavPrefix string, userPrincipalBackend webdav.UserPrincipalBackend) (caldav.Backend, carddav.Backend, error) { @@ -54,24 +47,6 @@ func NewFilesystem(fsPath, caldavPrefix, carddavPrefix string, userPrincipalBack return backend, backend, nil } -func (b *filesystemBackend) AddressbookHomeSetPath(ctx context.Context) (string, error) { - upPath, err := b.CurrentUserPrincipal(ctx) - if err != nil { - return "", err - } - - return path.Join(upPath, b.carddavPrefix) + "/", nil -} - -func (b *filesystemBackend) CalendarHomeSetPath(ctx context.Context) (string, error) { - upPath, err := b.CurrentUserPrincipal(ctx) - if err != nil { - return "", err - } - - return path.Join(upPath, b.caldavPrefix) + "/", nil -} - func ensureLocalDir(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { err = os.MkdirAll(path, 0755) @@ -84,7 +59,7 @@ func ensureLocalDir(path string) error { // don't use this directly, use localCalDAVPath or localCardDAVPath instead. func (b *filesystemBackend) safeLocalPath(homeSetPath string, urlPath string) (string, error) { - localPath := filepath.Join(b.path, homeSetPath, "default") + localPath := filepath.Join(b.path, homeSetPath, defaultResourceName) if err := ensureLocalDir(localPath); err != nil { return "", err } @@ -97,12 +72,12 @@ func (b *filesystemBackend) safeLocalPath(homeSetPath string, urlPath string) (s // 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, "default") { + 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, "default")) + 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) + err := fmt.Errorf("access to resource outside of home set: %s", urlPath) return "", webdav.NewHTTPError(403, err) } } @@ -117,24 +92,6 @@ func (b *filesystemBackend) safeLocalPath(homeSetPath string, urlPath string) (s return filepath.Join(localPath, file), nil } -func (b *filesystemBackend) localCalDAVPath(ctx context.Context, urlPath string) (string, error) { - homeSetPath, err := b.CalendarHomeSetPath(ctx) - if err != nil { - return "", err - } - - return b.safeLocalPath(homeSetPath, urlPath) -} - -func (b *filesystemBackend) localCardDAVPath(ctx context.Context, urlPath string) (string, error) { - homeSetPath, err := b.AddressbookHomeSetPath(ctx) - if err != nil { - return "", err - } - - return b.safeLocalPath(homeSetPath, urlPath) -} - func etagForFile(path string) (string, error) { f, err := os.Open(path) if err != nil { @@ -150,533 +107,3 @@ func etagForFile(path string) (string, error) { return base64.StdEncoding.EncodeToString(csum[:]), nil } - -func vcardPropFilter(card vcard.Card, props []string) vcard.Card { - if card == nil { - return nil - } - - if len(props) == 0 { - return card - } - - result := make(vcard.Card) - result["VERSION"] = card["VERSION"] - for _, prop := range props { - value, ok := card[prop] - if ok { - result[prop] = value - } - } - - return result -} - -func vcardFromFile(path string, propFilter []string) (vcard.Card, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - - dec := vcard.NewDecoder(f) - card, err := dec.Decode() - if err != nil { - return nil, err - } - - return vcardPropFilter(card, propFilter), nil -} - -func calendarFromFile(path string, propFilter []string) (*ical.Calendar, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - - dec := ical.NewDecoder(f) - cal, err := dec.Decode() - if err != nil { - return nil, err - } - - return cal, nil - // TODO implement - //return icalPropFilter(cal, propFilter), nil -} - -func createDefaultAddressBook(path, localPath string) error { - // TODO what should the default address book look like? - defaultAB := carddav.AddressBook{ - Path: path, - Name: "My contacts", - Description: "Default address book", - MaxResourceSize: 1024, - SupportedAddressData: nil, - } - blob, err := json.MarshalIndent(defaultAB, "", " ") - if err != nil { - return fmt.Errorf("error creating default address book: %s", err.Error()) - } - err = os.WriteFile(localPath, blob, 0644) - if err != nil { - return fmt.Errorf("error writing default address book: %s", err.Error()) - } - return nil -} - -func (b *filesystemBackend) AddressBook(ctx context.Context) (*carddav.AddressBook, error) { - debug.Printf("filesystem.AddressBook()") - localPath, err := b.localCardDAVPath(ctx, "") - if err != nil { - return nil, err - } - localPath = filepath.Join(localPath, "addressbook.json") - - debug.Printf("loading addressbook from %s", localPath) - - data, readErr := ioutil.ReadFile(localPath) - if os.IsNotExist(readErr) { - urlPath, err := b.AddressbookHomeSetPath(ctx) - if err != nil { - return nil, err - } - urlPath = path.Join(urlPath, "default") + "/" - debug.Printf("creating default addressbook (URL:path): %s:%s", urlPath, localPath) - err = createDefaultAddressBook(urlPath, localPath) - if err != nil { - return nil, err - } - data, readErr = ioutil.ReadFile(localPath) - } - if readErr != nil { - return nil, fmt.Errorf("error opening address book: %s", readErr.Error()) - } - var addressBook carddav.AddressBook - err = json.Unmarshal(data, &addressBook) - if err != nil { - return nil, fmt.Errorf("error reading address book: %s", err.Error()) - } - - return &addressBook, nil -} - -func (b *filesystemBackend) GetAddressObject(ctx context.Context, objPath string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) { - debug.Printf("filesystem.GetAddressObject(%s, %v)", objPath, req) - localPath, err := b.localCardDAVPath(ctx, objPath) - if err != nil { - return nil, err - } - - info, err := os.Stat(localPath) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil, webdav.NewHTTPError(404, err) - } - return nil, err - } - - var propFilter []string - if req != nil && !req.AllProp { - propFilter = req.Props - } - - card, err := vcardFromFile(localPath, propFilter) - if err != nil { - return nil, err - } - - etag, err := etagForFile(localPath) - if err != nil { - return nil, err - } - - obj := carddav.AddressObject{ - Path: objPath, - ModTime: info.ModTime(), - ContentLength: info.Size(), - ETag: etag, - Card: card, - } - return &obj, nil -} - -func (b *filesystemBackend) loadAllContacts(ctx context.Context, propFilter []string) ([]carddav.AddressObject, error) { - var result []carddav.AddressObject - - 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 !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, "default", filepath.Base(filename)), - ModTime: info.ModTime(), - ContentLength: info.Size(), - ETag: etag, - Card: card, - } - result = append(result, obj) - return nil - }) - - debug.Printf("filesystem.loadAllContacts() returning %d results from %s", len(result), localPath) - return result, err -} - -func (b *filesystemBackend) loadAllCalendars(ctx context.Context, propFilter []string) ([]caldav.CalendarObject, error) { - var result []caldav.CalendarObject - - localPath, err := b.localCalDAVPath(ctx, "") - if err != nil { - return result, err - } - - homeSetPath, err := b.CalendarHomeSetPath(ctx) - if err != nil { - return result, err - } - - err = filepath.Walk(localPath, func(filename string, info os.FileInfo, err error) error { - // Skip address book meta data files - if !info.Mode().IsRegular() || filepath.Ext(filename) != ".ics" { - return nil - } - - cal, err := calendarFromFile(filename, propFilter) - if err != nil { - fmt.Printf("load calendar error for %s: %v\n", filename, err) - return err - } - - etag, err := etagForFile(filename) - if err != nil { - return err - } - - obj := caldav.CalendarObject{ - Path: path.Join(homeSetPath, "default", filepath.Base(filename)), - ModTime: info.ModTime(), - ContentLength: info.Size(), - ETag: etag, - Data: cal, - } - result = append(result, obj) - return nil - }) - - debug.Printf("filesystem.loadAllCalendars() returning %d results from %s", len(result), localPath) - return result, err -} - -func (b *filesystemBackend) ListAddressObjects(ctx context.Context, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { - debug.Printf("filesystem.ListAddressObjects(%v)", req) - var propFilter []string - if req != nil && !req.AllProp { - propFilter = req.Props - } - - return b.loadAllContacts(ctx, propFilter) -} - -func (b *filesystemBackend) QueryAddressObjects(ctx context.Context, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { - debug.Printf("filesystem.QueryAddressObjects(%v)", query) - var propFilter []string - if query != nil && !query.DataRequest.AllProp { - propFilter = query.DataRequest.Props - } - - result, err := b.loadAllContacts(ctx, propFilter) - if err != nil { - return result, err - } - - return carddav.Filter(query, result) -} - -func (b *filesystemBackend) PutAddressObject(ctx context.Context, objPath string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (loc string, err error) { - debug.Printf("filesystem.PutAddressObject(%v, %v, %v)", objPath, card, opts) - - // Object always get saved as .vcf - dirname, _ := path.Split(objPath) - objPath = path.Join(dirname, card.Value(vcard.FieldUID)+".vcf") - - localPath, err := b.localCardDAVPath(ctx, objPath) - if err != nil { - return "", err - } - - flags := os.O_RDWR | os.O_CREATE | os.O_TRUNC - // TODO handle IfNoneMatch == ETag - if opts.IfNoneMatch.IsWildcard() { - // Make sure we're not overwriting an existing file - flags |= os.O_EXCL - } else if opts.IfMatch.IsWildcard() { - // Make sure we _are_ overwriting an existing file - flags &= ^os.O_CREATE - } else if opts.IfMatch.IsSet() { - // Make sure we overwrite the _right_ file - etag, err := etagForFile(localPath) - if err != nil { - return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) - } - want, err := opts.IfMatch.ETag() - if err != nil { - return "", webdav.NewHTTPError(http.StatusBadRequest, err) - } - if want != etag { - err = fmt.Errorf("If-Match does not match current ETag (%s/%s)", want, etag) - return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) - } - } - - f, err := os.OpenFile(localPath, flags, 0666) - if os.IsExist(err) { - return "", carddav.NewPreconditionError(carddav.PreconditionNoUIDConflict) - } else if err != nil { - return "", err - } - defer f.Close() - - enc := vcard.NewEncoder(f) - err = enc.Encode(card) - if err != nil { - return "", err - } - - return objPath, nil -} - -func (b *filesystemBackend) DeleteAddressObject(ctx context.Context, path string) error { - debug.Printf("filesystem.DeleteAddressObject(%s)", path) - - localPath, err := b.localCardDAVPath(ctx, path) - if err != nil { - return err - } - err = os.Remove(localPath) - if err != nil { - return err - } - return nil -} - -func createDefaultCalendar(path, localPath string) error { - // TODO what should the default calendar look like? - defaultC := caldav.Calendar{ - Path: path, - 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()) - } - err = os.WriteFile(localPath, blob, 0644) - if err != nil { - return fmt.Errorf("error writing default calendar: %s", err.Error()) - } - return nil -} - -func (b *filesystemBackend) Calendar(ctx context.Context) (*caldav.Calendar, error) { - debug.Printf("filesystem.Calendar()") - - localPath, err := b.localCalDAVPath(ctx, "") - if err != nil { - return nil, err - } - localPath = filepath.Join(localPath, "calendar.json") - - debug.Printf("loading calendar from %s", localPath) - - data, readErr := ioutil.ReadFile(localPath) - if os.IsNotExist(readErr) { - urlPath, err := b.CalendarHomeSetPath(ctx) - if err != nil { - return nil, err - } - urlPath = path.Join(urlPath, "default") + "/" - debug.Printf("creating default calendar (URL:path): %s:%s", urlPath, localPath) - err = createDefaultCalendar(urlPath, localPath) - if err != nil { - return nil, err - } - data, readErr = ioutil.ReadFile(localPath) - } - if readErr != nil { - return nil, fmt.Errorf("error opening calendar: %s", readErr.Error()) - } - var calendar caldav.Calendar - err = json.Unmarshal(data, &calendar) - if err != nil { - return nil, fmt.Errorf("error reading calendar: %s", err.Error()) - } - - return &calendar, nil -} - -func (b *filesystemBackend) GetCalendarObject(ctx context.Context, objPath string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { - debug.Printf("filesystem.GetCalendarObject(%s, %v)", objPath, req) - - localPath, err := b.localCalDAVPath(ctx, objPath) - if err != nil { - return nil, err - } - - info, err := os.Stat(localPath) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - debug.Printf("not found: %s", localPath) - return nil, webdav.NewHTTPError(404, err) - } - return nil, err - } - - var propFilter []string - if req != nil && !req.AllProps { - propFilter = req.Props - } - - calendar, err := calendarFromFile(localPath, propFilter) - if err != nil { - debug.Printf("error reading calendar: %v", err) - return nil, err - } - - etag, err := etagForFile(localPath) - if err != nil { - return nil, err - } - - obj := caldav.CalendarObject{ - Path: objPath, - ModTime: info.ModTime(), - ContentLength: info.Size(), - ETag: etag, - Data: calendar, - } - return &obj, nil - return nil, fmt.Errorf("not implemented") -} - -func (b *filesystemBackend) ListCalendarObjects(ctx context.Context, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { - debug.Printf("filesystem.ListCalendarObjects(%v)", req) - - var propFilter []string - if req != nil && !req.AllProps { - propFilter = req.Props - } - - return b.loadAllCalendars(ctx, propFilter) -} - -func (b *filesystemBackend) QueryCalendarObjects(ctx context.Context, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { - debug.Printf("filesystem.QueryCalendarObjects(%v)", query) - - var propFilter []string - if query != nil && !query.CompRequest.AllProps { - propFilter = query.CompRequest.Props - } - - result, err := b.loadAllCalendars(ctx, propFilter) - if err != nil { - return result, err - } - - return caldav.Filter(query, result) -} - -func (b *filesystemBackend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (loc string, err error) { - debug.Printf("filesystem.PutCalendarObject(%s, %v, %v)", objPath, calendar, opts) - - _, uid, err := caldav.ValidateCalendarObject(calendar) - if err != nil { - return "", caldav.NewPreconditionError(caldav.PreconditionValidCalendarObjectResource) - } - - // Object always get saved as .ics - dirname, _ := path.Split(objPath) - objPath = path.Join(dirname, uid+".ics") - - localPath, err := b.localCalDAVPath(ctx, objPath) - if err != nil { - return "", err - } - - flags := os.O_RDWR | os.O_CREATE | os.O_TRUNC - // TODO handle IfNoneMatch == ETag - if opts.IfNoneMatch.IsWildcard() { - // Make sure we're not overwriting an existing file - flags |= os.O_EXCL - } else if opts.IfMatch.IsWildcard() { - // Make sure we _are_ overwriting an existing file - flags &= ^os.O_CREATE - } else if opts.IfMatch.IsSet() { - // Make sure we overwrite the _right_ file - etag, err := etagForFile(localPath) - if err != nil { - return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) - } - want, err := opts.IfMatch.ETag() - if err != nil { - return "", webdav.NewHTTPError(http.StatusBadRequest, err) - } - if want != etag { - err = fmt.Errorf("If-Match does not match current ETag (%s/%s)", want, etag) - return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) - } - } - - f, err := os.OpenFile(localPath, flags, 0666) - if os.IsExist(err) { - return "", caldav.NewPreconditionError(caldav.PreconditionNoUIDConflict) - } else if err != nil { - return "", err - } - defer f.Close() - - enc := ical.NewEncoder(f) - err = enc.Encode(calendar) - if err != nil { - return "", err - } - - return objPath, nil -} - -func (b *filesystemBackend) DeleteCalendarObject(ctx context.Context, path string) error { - debug.Printf("filesystem.DeleteCalendarObject(%s)", path) - - localPath, err := b.localCalDAVPath(ctx, path) - if err != nil { - return err - } - err = os.Remove(localPath) - if err != nil { - return err - } - return nil -} diff --git a/storage/filesystem_caldav.go b/storage/filesystem_caldav.go new file mode 100644 index 0000000..6da1a4b --- /dev/null +++ b/storage/filesystem_caldav.go @@ -0,0 +1,303 @@ +package storage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "io/ioutil" + "net/http" + "os" + "path" + "path/filepath" + + "github.com/emersion/go-ical" + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/caldav" + + "git.sr.ht/~sircmpwn/tokidoki/debug" +) + +func (b *filesystemBackend) CalendarHomeSetPath(ctx context.Context) (string, error) { + upPath, err := b.CurrentUserPrincipal(ctx) + if err != nil { + return "", err + } + + return path.Join(upPath, b.caldavPrefix) + "/", nil +} + +func (b *filesystemBackend) localCalDAVPath(ctx context.Context, urlPath string) (string, error) { + homeSetPath, err := b.CalendarHomeSetPath(ctx) + if err != nil { + return "", err + } + + return b.safeLocalPath(homeSetPath, urlPath) +} + +func calendarFromFile(path string, propFilter []string) (*ical.Calendar, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + dec := ical.NewDecoder(f) + cal, err := dec.Decode() + if err != nil { + return nil, err + } + + return cal, nil + // TODO implement + //return icalPropFilter(cal, propFilter), nil +} + +func (b *filesystemBackend) loadAllCalendars(ctx context.Context, propFilter []string) ([]caldav.CalendarObject, error) { + var result []caldav.CalendarObject + + localPath, err := b.localCalDAVPath(ctx, "") + if err != nil { + return result, err + } + + homeSetPath, err := b.CalendarHomeSetPath(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) + } + + // Skip address book meta data files + if !info.Mode().IsRegular() || filepath.Ext(filename) != ".ics" { + return nil + } + + cal, err := calendarFromFile(filename, propFilter) + if err != nil { + fmt.Printf("load calendar error for %s: %v\n", filename, err) + return err + } + + etag, err := etagForFile(filename) + if err != nil { + return err + } + + obj := caldav.CalendarObject{ + Path: path.Join(homeSetPath, defaultResourceName, filepath.Base(filename)), + ModTime: info.ModTime(), + ContentLength: info.Size(), + ETag: etag, + Data: cal, + } + result = append(result, obj) + return nil + }) + + debug.Printf("filesystem.loadAllCalendars() returning %d results from %s", len(result), localPath) + return result, err +} + +func createDefaultCalendar(path, localPath string) error { + // TODO what should the default calendar look like? + defaultC := caldav.Calendar{ + Path: path, + 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()) + } + err = os.WriteFile(localPath, blob, 0644) + if err != nil { + return fmt.Errorf("error writing default calendar: %s", err.Error()) + } + return nil +} + +func (b *filesystemBackend) Calendar(ctx context.Context) (*caldav.Calendar, error) { + debug.Printf("filesystem.Calendar()") + + localPath, err := b.localCalDAVPath(ctx, "") + if err != nil { + return nil, err + } + localPath = filepath.Join(localPath, "calendar.json") + + debug.Printf("loading calendar from %s", localPath) + + 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) + "/" + debug.Printf("creating default calendar (URL:path): %s:%s", urlPath, localPath) + err = createDefaultCalendar(urlPath, localPath) + if err != nil { + return nil, err + } + data, readErr = ioutil.ReadFile(localPath) + } + if readErr != nil { + return nil, fmt.Errorf("error opening calendar: %s", readErr.Error()) + } + var calendar caldav.Calendar + err = json.Unmarshal(data, &calendar) + if err != nil { + return nil, fmt.Errorf("error reading calendar: %s", err.Error()) + } + + return &calendar, nil +} + +func (b *filesystemBackend) GetCalendarObject(ctx context.Context, objPath string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { + debug.Printf("filesystem.GetCalendarObject(%s, %v)", objPath, req) + + localPath, err := b.localCalDAVPath(ctx, objPath) + if err != nil { + return nil, err + } + + info, err := os.Stat(localPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + debug.Printf("not found: %s", localPath) + return nil, webdav.NewHTTPError(404, err) + } + return nil, err + } + + var propFilter []string + if req != nil && !req.AllProps { + propFilter = req.Props + } + + calendar, err := calendarFromFile(localPath, propFilter) + if err != nil { + debug.Printf("error reading calendar: %v", err) + return nil, err + } + + etag, err := etagForFile(localPath) + if err != nil { + return nil, err + } + + obj := caldav.CalendarObject{ + Path: objPath, + ModTime: info.ModTime(), + ContentLength: info.Size(), + ETag: etag, + Data: calendar, + } + return &obj, nil +} + +func (b *filesystemBackend) ListCalendarObjects(ctx context.Context, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { + debug.Printf("filesystem.ListCalendarObjects(%v)", req) + + var propFilter []string + if req != nil && !req.AllProps { + propFilter = req.Props + } + + return b.loadAllCalendars(ctx, propFilter) +} + +func (b *filesystemBackend) QueryCalendarObjects(ctx context.Context, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { + debug.Printf("filesystem.QueryCalendarObjects(%v)", query) + + var propFilter []string + if query != nil && !query.CompRequest.AllProps { + propFilter = query.CompRequest.Props + } + + result, err := b.loadAllCalendars(ctx, propFilter) + if err != nil { + return result, err + } + + return caldav.Filter(query, result) +} + +func (b *filesystemBackend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (loc string, err error) { + debug.Printf("filesystem.PutCalendarObject(%s, %v, %v)", objPath, calendar, opts) + + _, uid, err := caldav.ValidateCalendarObject(calendar) + if err != nil { + return "", caldav.NewPreconditionError(caldav.PreconditionValidCalendarObjectResource) + } + + // Object always get saved as .ics + dirname, _ := path.Split(objPath) + objPath = path.Join(dirname, uid+".ics") + + localPath, err := b.localCalDAVPath(ctx, objPath) + if err != nil { + return "", err + } + + flags := os.O_RDWR | os.O_CREATE | os.O_TRUNC + // TODO handle IfNoneMatch == ETag + if opts.IfNoneMatch.IsWildcard() { + // Make sure we're not overwriting an existing file + flags |= os.O_EXCL + } else if opts.IfMatch.IsWildcard() { + // Make sure we _are_ overwriting an existing file + flags &= ^os.O_CREATE + } else if opts.IfMatch.IsSet() { + // Make sure we overwrite the _right_ file + etag, err := etagForFile(localPath) + if err != nil { + return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) + } + want, err := opts.IfMatch.ETag() + if err != nil { + return "", webdav.NewHTTPError(http.StatusBadRequest, err) + } + if want != etag { + err = fmt.Errorf("If-Match does not match current ETag (%s/%s)", want, etag) + return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) + } + } + + f, err := os.OpenFile(localPath, flags, 0666) + if os.IsExist(err) { + return "", caldav.NewPreconditionError(caldav.PreconditionNoUIDConflict) + } else if err != nil { + return "", err + } + defer f.Close() + + enc := ical.NewEncoder(f) + err = enc.Encode(calendar) + if err != nil { + return "", err + } + + return objPath, nil +} + +func (b *filesystemBackend) DeleteCalendarObject(ctx context.Context, path string) error { + debug.Printf("filesystem.DeleteCalendarObject(%s)", path) + + localPath, err := b.localCalDAVPath(ctx, path) + if err != nil { + return err + } + err = os.Remove(localPath) + if err != nil { + return err + } + return nil +} diff --git a/storage/filesystem_carddav.go b/storage/filesystem_carddav.go new file mode 100644 index 0000000..0736922 --- /dev/null +++ b/storage/filesystem_carddav.go @@ -0,0 +1,310 @@ +package storage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "io/ioutil" + "net/http" + "os" + "path" + "path/filepath" + + "github.com/emersion/go-vcard" + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/carddav" + + "git.sr.ht/~sircmpwn/tokidoki/debug" +) + +func (b *filesystemBackend) AddressbookHomeSetPath(ctx context.Context) (string, error) { + upPath, err := b.CurrentUserPrincipal(ctx) + if err != nil { + return "", err + } + + return path.Join(upPath, b.carddavPrefix) + "/", nil +} + +func (b *filesystemBackend) localCardDAVPath(ctx context.Context, urlPath string) (string, error) { + homeSetPath, err := b.AddressbookHomeSetPath(ctx) + if err != nil { + return "", err + } + + return b.safeLocalPath(homeSetPath, urlPath) +} + +func vcardPropFilter(card vcard.Card, props []string) vcard.Card { + if card == nil { + return nil + } + + if len(props) == 0 { + return card + } + + result := make(vcard.Card) + result["VERSION"] = card["VERSION"] + for _, prop := range props { + value, ok := card[prop] + if ok { + result[prop] = value + } + } + + return result +} + +func vcardFromFile(path string, propFilter []string) (vcard.Card, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + dec := vcard.NewDecoder(f) + card, err := dec.Decode() + if err != nil { + return nil, err + } + + return vcardPropFilter(card, propFilter), nil +} + +func createDefaultAddressBook(path, localPath string) error { + // TODO what should the default address book look like? + defaultAB := carddav.AddressBook{ + Path: path, + Name: "My contacts", + Description: "Default address book", + MaxResourceSize: 1024, + SupportedAddressData: nil, + } + blob, err := json.MarshalIndent(defaultAB, "", " ") + if err != nil { + return fmt.Errorf("error creating default address book: %s", err.Error()) + } + err = os.WriteFile(localPath, blob, 0644) + if err != nil { + return fmt.Errorf("error writing default address book: %s", err.Error()) + } + return nil +} + +func (b *filesystemBackend) AddressBook(ctx context.Context) (*carddav.AddressBook, error) { + debug.Printf("filesystem.AddressBook()") + localPath, err := b.localCardDAVPath(ctx, "") + if err != nil { + return nil, err + } + localPath = filepath.Join(localPath, "addressbook.json") + + debug.Printf("loading addressbook from %s", localPath) + + 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) + "/" + debug.Printf("creating default addressbook (URL:path): %s:%s", urlPath, localPath) + err = createDefaultAddressBook(urlPath, localPath) + if err != nil { + return nil, err + } + data, readErr = ioutil.ReadFile(localPath) + } + if readErr != nil { + return nil, fmt.Errorf("error opening address book: %s", readErr.Error()) + } + var addressBook carddav.AddressBook + err = json.Unmarshal(data, &addressBook) + if err != nil { + return nil, fmt.Errorf("error reading address book: %s", err.Error()) + } + + return &addressBook, nil +} + +func (b *filesystemBackend) GetAddressObject(ctx context.Context, objPath string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) { + debug.Printf("filesystem.GetAddressObject(%s, %v)", objPath, req) + localPath, err := b.localCardDAVPath(ctx, objPath) + if err != nil { + return nil, err + } + + info, err := os.Stat(localPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, webdav.NewHTTPError(404, err) + } + return nil, err + } + + var propFilter []string + if req != nil && !req.AllProp { + propFilter = req.Props + } + + card, err := vcardFromFile(localPath, propFilter) + if err != nil { + return nil, err + } + + etag, err := etagForFile(localPath) + if err != nil { + return nil, err + } + + obj := carddav.AddressObject{ + Path: objPath, + ModTime: info.ModTime(), + ContentLength: info.Size(), + ETag: etag, + Card: card, + } + return &obj, nil +} + +func (b *filesystemBackend) loadAllContacts(ctx context.Context, propFilter []string) ([]carddav.AddressObject, error) { + var result []carddav.AddressObject + + 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 + }) + + debug.Printf("filesystem.loadAllContacts() returning %d results from %s", len(result), localPath) + return result, err +} + +func (b *filesystemBackend) ListAddressObjects(ctx context.Context, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { + debug.Printf("filesystem.ListAddressObjects(%v)", req) + var propFilter []string + if req != nil && !req.AllProp { + propFilter = req.Props + } + + return b.loadAllContacts(ctx, propFilter) +} + +func (b *filesystemBackend) QueryAddressObjects(ctx context.Context, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { + debug.Printf("filesystem.QueryAddressObjects(%v)", query) + var propFilter []string + if query != nil && !query.DataRequest.AllProp { + propFilter = query.DataRequest.Props + } + + result, err := b.loadAllContacts(ctx, propFilter) + if err != nil { + return result, err + } + + return carddav.Filter(query, result) +} + +func (b *filesystemBackend) PutAddressObject(ctx context.Context, objPath string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (loc string, err error) { + debug.Printf("filesystem.PutAddressObject(%v, %v, %v)", objPath, card, opts) + + // Object always get saved as .vcf + dirname, _ := path.Split(objPath) + objPath = path.Join(dirname, card.Value(vcard.FieldUID)+".vcf") + + localPath, err := b.localCardDAVPath(ctx, objPath) + if err != nil { + return "", err + } + + flags := os.O_RDWR | os.O_CREATE | os.O_TRUNC + // TODO handle IfNoneMatch == ETag + if opts.IfNoneMatch.IsWildcard() { + // Make sure we're not overwriting an existing file + flags |= os.O_EXCL + } else if opts.IfMatch.IsWildcard() { + // Make sure we _are_ overwriting an existing file + flags &= ^os.O_CREATE + } else if opts.IfMatch.IsSet() { + // Make sure we overwrite the _right_ file + etag, err := etagForFile(localPath) + if err != nil { + return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) + } + want, err := opts.IfMatch.ETag() + if err != nil { + return "", webdav.NewHTTPError(http.StatusBadRequest, err) + } + if want != etag { + err = fmt.Errorf("If-Match does not match current ETag (%s/%s)", want, etag) + return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) + } + } + + f, err := os.OpenFile(localPath, flags, 0666) + if os.IsExist(err) { + return "", carddav.NewPreconditionError(carddav.PreconditionNoUIDConflict) + } else if err != nil { + return "", err + } + defer f.Close() + + enc := vcard.NewEncoder(f) + err = enc.Encode(card) + if err != nil { + return "", err + } + + return objPath, nil +} + +func (b *filesystemBackend) DeleteAddressObject(ctx context.Context, path string) error { + debug.Printf("filesystem.DeleteAddressObject(%s)", path) + + localPath, err := b.localCardDAVPath(ctx, path) + if err != nil { + return err + } + err = os.Remove(localPath) + if err != nil { + return err + } + return nil +}