package storage import ( "context" "encoding/json" "errors" "fmt" "io/fs" "net/http" "os" "path" "path/filepath" "github.com/rs/zerolog/log" "github.com/emersion/go-vcard" "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/carddav" ) const addressBookFileName = "addressbook.json" 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) 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 } 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 (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) writeAddressBook(ctx context.Context, ab *carddav.AddressBook) error { localPath, err := b.safeLocalCardDAVPath(ctx, ab.Path) if err != nil { return err } log.Debug().Str("local", localPath).Str("url", ab.Path).Msg("filesystem.writeAddressBook()") blob, err := json.MarshalIndent(ab, "", " ") if err != nil { return err } return os.WriteFile(path.Join(localPath, addressBookFileName), blob, 0644) if err != nil { return fmt.Errorf("error writing address book: %s", err.Error()) } return nil } func (b *filesystemBackend) createAddressBook(ctx context.Context, ab *carddav.AddressBook) error { localPath, err := b.safeLocalCardDAVPath(ctx, ab.Path) if err != nil { return err } log.Debug().Str("local", localPath).Str("url", ab.Path).Msg("filesystem.createAddressBook()") err = os.Mkdir(localPath, 0755) if err != nil { return fmt.Errorf("error creating address book: %s", err.Error()) } return b.writeAddressBook(ctx, ab) } func (b *filesystemBackend) createDefaultAddressBook(ctx context.Context) (*carddav.AddressBook, error) { log.Debug().Msg("filesystem.createDefaultAddressBook()") 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) + "/" // TODO what should the default address book look like? defaultAB := carddav.AddressBook{ Path: urlPath, Name: "My contacts", Description: "Default address book", MaxResourceSize: 1024, SupportedAddressData: nil, } err = b.createAddressBook(ctx, &defaultAB) log.Debug().Err(err).Msg("filesystem.createDefaultAddressBook() done") return &defaultAB, nil } 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 } 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 { result = append(result, *ab) } } log.Debug().Int("results", len(result)).Err(err).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("path", localPath).Msg("loading addressbook") data, err := os.ReadFile(localPath) if err != nil { if os.IsNotExist(err) { return nil, webdav.NewHTTPError(404, err) } return nil, fmt.Errorf("error opening address book: %s", err.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) CreateAddressBook(ctx context.Context, ab *carddav.AddressBook) error { log.Debug().Str("path", ab.Path).Msg("filesystem.CreateAddressBook()") ab.MaxResourceSize = 4096 err := b.createAddressBook(ctx, ab) log.Debug().Err(err).Msg("filesystem.CreateAddressBook() done") return err } func (b *filesystemBackend) UpdateAddressBook(ctx context.Context, ab *carddav.AddressBook) error { log.Debug().Str("path", ab.Path).Msg("filesystem.UpdateAddressBook()") ab.MaxResourceSize = 4096 err := b.writeAddressBook(ctx, ab) log.Debug().Err(err).Msg("filesystem.UpdateAddressBook() done") return err } func (b *filesystemBackend) DeleteAddressBook(ctx context.Context, urlPath string) error { log.Debug().Str("path", urlPath).Msg("filesystem.DeleteAddressBook()") localPath, err := b.safeLocalCardDAVPath(ctx, urlPath) if err != nil { return err } log.Debug().Str("path", localPath).Msg("deleting addressbook") err = os.RemoveAll(localPath) log.Debug().Err(err).Msg("filesystem.DeleteAddressBook() done") return err } func (b *filesystemBackend) GetAddressObject(ctx context.Context, objPath string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) { log.Debug().Str("path", objPath).Msg("filesystem.GetAddressObject()") localPath, err := b.safeLocalCardDAVPath(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 { log.Debug().Str("path", localPath).Err(err).Msg("error reading calendar") 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) ListAddressObjects(ctx context.Context, urlPath string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { log.Debug().Str("path", urlPath).Msg("filesystem.ListAddressObjects()") var propFilter []string if req != nil && !req.AllProp { propFilter = req.Props } result, err := b.loadAllAddressObjects(ctx, urlPath, propFilter) log.Debug().Int("results", len(result)).Err(err).Msg("filesystem.ListAddressObjects() done") return result, err } 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.loadAllAddressObjects(ctx, urlPath, propFilter) log.Debug().Int("results", len(result)).Err(err).Msg("filesystem.QueryAddressObjects() load done") if err != nil { return result, err } filtered, err := carddav.Filter(query, result) log.Debug().Int("results", len(filtered)).Err(err).Msg("filesystem.QueryAddressObjects() filter done") return filtered, err } func (b *filesystemBackend) PutAddressObject(ctx context.Context, objPath string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) { log.Debug().Str("path", objPath).Msg("filesystem.PutAddressObject()") // Object always get saved as .vcf dirname, _ := path.Split(objPath) objPath = path.Join(dirname, card.Value(vcard.FieldUID)+".vcf") localPath, err := b.safeLocalCardDAVPath(ctx, objPath) if err != nil { return nil, 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 nil, webdav.NewHTTPError(http.StatusPreconditionFailed, err) } want, err := opts.IfMatch.ETag() if err != nil { return nil, webdav.NewHTTPError(http.StatusBadRequest, err) } if want != etag { err = fmt.Errorf("If-Match does not match current ETag (%s/%s)", want, etag) return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, err) } } f, err := os.OpenFile(localPath, flags, 0666) if os.IsExist(err) { return nil, carddav.NewPreconditionError(carddav.PreconditionNoUIDConflict) } else if err != nil { return nil, err } defer f.Close() enc := vcard.NewEncoder(f) if err := enc.Encode(card); err != nil { return nil, err } etag, err := etagForFile(localPath) if err != nil { return nil, err } info, err := f.Stat() if err != nil { return nil, err } r := carddav.AddressObject{ Path: objPath, ModTime: info.ModTime(), ContentLength: info.Size(), ETag: etag, Card: card, } return &r, nil } func (b *filesystemBackend) DeleteAddressObject(ctx context.Context, path string) error { log.Debug().Str("path", path).Msg("filesystem.DeleteAddressObject()") localPath, err := b.safeLocalCardDAVPath(ctx, path) if err != nil { return err } err = os.Remove(localPath) if err != nil { return err } return nil }