tokidoki/storage/filesystem_caldav.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

408 lines
11 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-ical"
"github.com/emersion/go-webdav"
"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 {
return "", err
}
return path.Join(upPath, b.caldavPrefix) + "/", nil
}
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
}
return b.safeLocalPath(homeSetPath, urlPath)
}
func calendarAndEtagFromFile(path string, propFilter []string) (*ical.Calendar, string, error) {
f, err := os.Open(path)
if err != nil {
return nil, "", err
}
defer f.Close()
src := NewETagReader(f)
dec := ical.NewDecoder(src)
cal, err := dec.Decode()
if err != nil {
return nil, "", err
}
// TODO implement
//return icalPropFilter(cal, propFilter), nil
return cal, src.ETag(), nil
}
func (b *filesystemBackend) loadAllCalendarObjects(ctx context.Context, urlPath string, propFilter []string) ([]caldav.CalendarObject, error) {
var result []caldav.CalendarObject
localPath, err := b.safeLocalCalDAVPath(ctx, urlPath)
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 {
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
}
b.RLock(filename)
defer b.RUnlock(filename)
cal, etag, err := calendarAndEtagFromFile(filename, propFilter)
if err != nil {
fmt.Printf("load calendar error for %s: %v\n", filename, err)
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(urlPath, filepath.Base(filename)),
ModTime: info.ModTime(),
ContentLength: info.Size(),
ETag: etag,
Data: cal,
}
log.Debug().Str("path", obj.Path).Str("etag", etag).Int64("size", info.Size()).Msg("calendar object loaded")
result = append(result, obj)
return nil
})
return result, err
}
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: urlPath,
Name: "My calendar",
Description: "Default calendar",
MaxResourceSize: 4096,
}
blob, err := json.MarshalIndent(defaultC, "", " ")
if err != nil {
return nil, fmt.Errorf("error creating default calendar: %s", err.Error())
}
filename := path.Join(localPath, calendarFileName)
b.Lock(filename)
defer b.Unlock(filename)
err = os.WriteFile(filename, blob, 0644)
if err != nil {
return nil, fmt.Errorf("error writing default calendar: %s", err.Error())
}
return &defaultC, nil
}
func (b *filesystemBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
log.Debug().Msg("filesystem.ListCalendars()")
localPath, err := b.localCalDAVDir(ctx)
if err != nil {
return nil, err
}
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)
b.RLock(calPath)
defer b.RUnlock(calPath)
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 {
result = append(result, *cal)
}
}
log.Debug().Int("results", len(result)).Err(err).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("path", localPath).Msg("loading calendar")
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 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) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
panic("TODO")
}
func (b *filesystemBackend) GetCalendarObject(ctx context.Context, objPath string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
log.Debug().Str("path", objPath).Msg("filesystem.GetCalendarObject()")
localPath, err := b.safeLocalCalDAVPath(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.AllProps {
propFilter = req.Props
}
calendar, etag, err := calendarAndEtagFromFile(localPath, propFilter)
if err != nil {
log.Debug().Str("path", localPath).Err(err).Msg("error reading calendar")
return nil, err
}
obj := caldav.CalendarObject{
Path: objPath,
ModTime: info.ModTime(),
ContentLength: info.Size(),
ETag: etag,
Data: calendar,
}
log.Debug().Str("path", objPath).Str("etag", etag).Int64("size", info.Size()).Msg("returning calendar object")
return &obj, nil
}
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
}
result, err := b.loadAllCalendarObjects(ctx, urlPath, propFilter)
log.Debug().Int("results", len(result)).Err(err).Msg("filesystem.ListCalendarObjects() done")
return result, err
}
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.loadAllCalendarObjects(ctx, urlPath, propFilter)
log.Debug().Int("results", len(result)).Err(err).Msg("filesystem.QueryCalendarObjects() load done")
if err != nil {
return result, err
}
filtered, err := caldav.Filter(query, result)
log.Debug().Int("results", len(filtered)).Err(err).Msg("filesystem.QueryCalendarObjects() filter done")
return filtered, err
}
func (b *filesystemBackend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
log.Debug().Str("path", objPath).Msg("filesystem.PutCalendarObject()")
_, uid, err := caldav.ValidateCalendarObject(calendar)
if err != nil {
return nil, caldav.NewPreconditionError(caldav.PreconditionValidCalendarObjectResource)
}
// Object always get saved as <UID>.ics
dirname, _ := path.Split(objPath)
objPath = path.Join(dirname, uid+".ics")
localPath, err := b.safeLocalCalDAVPath(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, caldav.NewPreconditionError(caldav.PreconditionNoUIDConflict)
} else if err != nil {
return nil, err
}
defer f.Close()
out := NewETagWriter(f)
enc := ical.NewEncoder(out)
err = enc.Encode(calendar)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
return nil, err
}
r := caldav.CalendarObject{
Path: objPath,
ModTime: info.ModTime(),
ContentLength: info.Size(),
ETag: out.ETag(),
Data: calendar,
}
log.Debug().Str("path", r.Path).Str("etag", r.ETag).Int64("size", info.Size()).Msg("calendar object updated")
return &r, nil
}
func (b *filesystemBackend) DeleteCalendarObject(ctx context.Context, path string) error {
log.Debug().Str("path", path).Msg("filesystem.DeleteCalendarObject()")
localPath, err := b.safeLocalCalDAVPath(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
}