tokidoki/storage/filesystem_carddav.go
Conrad Hoffmann 665b206709 storage: streamline ETag calculation
This commit introduces some helpers so that ETags can be calculated at
the same time that files get read or written. Besides looking nicer, it
should also help reduce lock contention around file access, as files do
not need to be opened twice anymore.
2024-11-07 17:55:56 +01:00

467 lines
13 KiB
Go

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 vcardAndEtagFromFile(path string, propFilter []string) (vcard.Card, string, error) {
f, err := os.Open(path)
if err != nil {
return nil, "", err
}
defer f.Close()
src := NewETagReader(f)
dec := vcard.NewDecoder(src)
card, err := dec.Decode()
if err != nil {
return nil, "", err
}
return vcardPropFilter(card, propFilter), src.ETag(), 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
}
b.RLock(filename)
defer b.RUnlock(filename)
card, etag, err := vcardAndEtagFromFile(filename, propFilter)
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,
}
log.Debug().Str("path", obj.Path).Str("etag", etag).Int64("size", info.Size()).Msg("address object loaded")
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
}
filename := path.Join(localPath, addressBookFileName)
b.Lock(filename)
defer b.Unlock(filename)
err = os.WriteFile(filename, 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)
b.RLock(abPath)
defer b.RUnlock(abPath)
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")
b.RLock(localPath)
defer b.RUnlock(localPath)
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
}
b.RLock(localPath)
defer b.RUnlock(localPath)
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, etag, err := vcardAndEtagFromFile(localPath, propFilter)
if err != nil {
log.Debug().Str("path", localPath).Err(err).Msg("error reading calendar")
return nil, err
}
obj := carddav.AddressObject{
Path: objPath,
ModTime: info.ModTime(),
ContentLength: info.Size(),
ETag: etag,
Card: card,
}
log.Debug().Str("path", objPath).Str("etag", etag).Int64("size", info.Size()).Msg("returning address object")
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 <UID>.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
}
b.Lock(localPath)
defer b.Unlock(localPath)
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()
out := NewETagWriter(f)
enc := vcard.NewEncoder(out)
if err := enc.Encode(card); 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: out.ETag(),
Card: card,
}
log.Debug().Str("path", r.Path).Str("etag", r.ETag).Int64("size", info.Size()).Msg("address object updated")
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
}
b.Lock(localPath)
defer b.Unlock(localPath)
err = os.Remove(localPath)
if err != nil {
return err
}
return nil
}