mirror of
https://git.sr.ht/~sircmpwn/tokidoki
synced 2025-12-12 14:17:21 +01:00
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.
408 lines
11 KiB
Go
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
|
|
}
|