tokidoki/storage/filesystem.go
Simon Ser 228384530e storage/filesystem: atomically check for IfNoneMatch
Using a separate os.Stat() call may result in a race where another
request handler running concurrently creates the file in-between
the os.Stat() call and the os.Create() call.

Use O_EXCL to avoid this situation.
2022-06-03 10:15:23 +02:00

630 lines
16 KiB
Go

package storage
import (
"context"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"io/ioutil"
"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"
"git.sr.ht/~sircmpwn/tokidoki/debug"
)
type filesystemBackend struct {
webdav.UserPrincipalBackend
path string
caldavPrefix string
carddavPrefix string
}
var (
validFilenameRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]+(.[a-zA-Z]+)?$`)
)
func NewFilesystem(fsPath, caldavPrefix, carddavPrefix string, userPrincipalBackend webdav.UserPrincipalBackend) (caldav.Backend, carddav.Backend, error) {
info, err := os.Stat(fsPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to create filesystem backend: %s", err.Error())
}
if !info.IsDir() {
return nil, nil, fmt.Errorf("base path for filesystem backend must be a directory")
}
backend := &filesystemBackend{
UserPrincipalBackend: userPrincipalBackend,
path: fsPath,
caldavPrefix: caldavPrefix,
carddavPrefix: carddavPrefix,
}
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)
if err != nil {
return fmt.Errorf("error creating '%s': %s", path, err.Error())
}
}
return nil
}
// 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)
if err := ensureLocalDir(localPath); err != nil {
return "", err
}
if urlPath == "" {
return localPath, nil
}
// 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 prefix, no subdirs for now
if dir != homeSetPath {
if strings.HasPrefix(dir, homeSetPath+"/") {
err := fmt.Errorf("invalid request path: %s", urlPath)
return "", webdav.NewHTTPError(400, err)
} else {
err := fmt.Errorf("Access to resource outside of home set: %s", urlPath)
return "", webdav.NewHTTPError(403, err)
}
}
// only accept simple file names for now
if !validFilenameRegex.MatchString(file) {
debug.Printf("%s does not match regex!\n", file)
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
}
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 {
return "", err
}
defer f.Close()
h := sha1.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
csum := h.Sum(nil)
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()")
path, err := b.localCardDAVPath(ctx, "")
if err != nil {
return nil, err
}
path = filepath.Join(path, "addressbook.json")
debug.Printf("loading addressbook from %s", path)
data, err := ioutil.ReadFile(path)
if os.IsNotExist(err) {
urlPath, err := b.AddressbookHomeSetPath(ctx)
if err != nil {
return nil, err
}
debug.Printf("creating default addressbook (URL:path): %s:%s", urlPath, path)
err = createDefaultAddressBook(urlPath, path)
if err != nil {
return nil, err
}
data, err = ioutil.ReadFile(path)
}
if err != nil {
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) 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, 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, 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 <UID>.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
if opts.IfNoneMatch {
flags |= os.O_EXCL
}
// TODO handle IfMatch
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()")
path, err := b.localCalDAVPath(ctx, "")
if err != nil {
return nil, err
}
path = filepath.Join(path, "calendar.json")
debug.Printf("loading calendar from %s", path)
data, err := ioutil.ReadFile(path)
if os.IsNotExist(err) {
homeSetPath, err := b.CalendarHomeSetPath(ctx)
if err != nil {
return nil, err
}
debug.Printf("creating default calendar (URL:path): %s:%s", homeSetPath, path)
err = createDefaultCalendar(homeSetPath, path)
if err != nil {
return nil, err
}
data, err = ioutil.ReadFile(path)
}
if err != nil {
return nil, fmt.Errorf("error opening calendar: %s", err.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 <UID>.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
if opts.IfNoneMatch {
flags |= os.O_EXCL
}
// TODO handle IfMatch
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
}