diff options
| -rw-r--r-- | pim/cmd/caldavfs/main.go | 90 | ||||
| -rw-r--r-- | pim/cmd/caldavfs/rsvp.go | 151 | ||||
| -rw-r--r-- | pim/lib/cal/tree.go | 6 |
3 files changed, 232 insertions, 15 deletions
diff --git a/pim/cmd/caldavfs/main.go b/pim/cmd/caldavfs/main.go index c3306de..7a0a4d3 100644 --- a/pim/cmd/caldavfs/main.go +++ b/pim/cmd/caldavfs/main.go @@ -33,20 +33,33 @@ func fatal(format string, a ...interface{}) { } type backend struct { - name string - client *caldav.Client - path string // the calendar collection - window time.Duration - refresh time.Duration - sched bool // server does the scheduling for us + name string + endpoint string + client *caldav.Client + path string // the calendar collection + window time.Duration + refresh time.Duration + sched bool // server does the scheduling for us + + user string + pass string + me []string mu sync.Mutex evs []*cal.Event sig string + obj map[string]objref // uid -> where it lives on the server last time.Time err string } +// objref is what a write needs: the path to PUT back to, and the etag +// that says nobody else has touched it since we looked. +type objref struct { + path string + etag string +} + func (b *backend) Name() string { return b.name } // autoschedule is reported so a dry run shows it; it decides whether @@ -54,12 +67,18 @@ func (b *backend) Name() string { return b.name } func (b *backend) Sched() bool { return b.sched } func (b *backend) Caps() string { - // Writing is not implemented yet, so say so rather than promise it. - // When it is: "read write rsvp" plus "schedule" when the server - // advertises calendar-auto-schedule. - return "read" + c := "read rsvp" + if b.sched { + // the server sends the iMIP for us; one PUT does both halves + c += " schedule" + } + return c } +// Me is whose calendar this is. Without it nothing can tell which of an +// event's attendees to change. +func (b *backend) Me() []string { return b.me } + func (b *backend) Refresh() time.Duration { return b.refresh } func (b *backend) Status() (time.Time, string) { @@ -120,15 +139,22 @@ func (b *backend) Sync() (bool, error) { } cals := make([]*ical.Calendar, 0, len(objs)) + ref := make(map[string]objref, len(objs)) for _, o := range objs { - if o.Data != nil { - cals = append(cals, o.Data) + if o.Data == nil { + continue + } + cals = append(cals, o.Data) + for _, e := range o.Data.Events() { + if uid, err := e.Props.Text(ical.PropUID); err == nil && uid != "" { + ref[uid] = objref{o.Path, o.ETag} + } } } evs := cal.FromCalendars(cals) b.mu.Lock() - b.evs, b.sig = evs, sig + b.evs, b.sig, b.obj = evs, sig, ref b.mu.Unlock() cal.Warnf("%s: %d objects", b.name, len(objs)) return true, nil @@ -182,11 +208,13 @@ func main() { srv = flag.String("s", "", "service name to post in /srv (default caldav.$user.$pid)") days = flag.Int("w", 400, "expansion window in days") poll = flag.Duration("r", 15*time.Minute, "how often to re-query") + me = flag.String("m", "", "your addresses on this calendar, comma separated (default: -u)") dry = flag.Bool("n", false, "load and report, do not serve") + once = flag.String("R", "", "answer one invitation: uid:ACCEPTED|DECLINED|TENTATIVE, then exit") ) flag.Usage = func() { fmt.Fprintf(os.Stderr, - "usage: caldav/fs -e endpoint -u user -p pwfile [-C calendar] [-N name] [-s srv] [-w days] [-r poll] [-n]\n") + "usage: caldav/fs -e endpoint -u user -p pwfile [-C calendar] [-N name] [-m me] [-s srv] [-w days] [-r poll] [-n]\n") os.Exit(2) } flag.Parse() @@ -218,7 +246,9 @@ func main() { } be := &backend{ - name: label, client: client, path: coll.Path, sched: sched, + name: label, endpoint: *endpoint, client: client, + path: coll.Path, sched: sched, + user: *user, pass: pass, me: mefrom(*me, *user), window: time.Duration(*days) * 24 * time.Hour, refresh: *poll, } @@ -227,6 +257,20 @@ func main() { User: who, Srv: *srv, Conf: *pwfile, Window: time.Duration(*days) * 24 * time.Hour, }) + if *once != "" { + uid, want, ok := strings.Cut(*once, ":") + if !ok { + fatal("-R wants uid:PARTSTAT") + } + if _, err := be.Sync(); err != nil { + fatal("%v", err) + } + if err := be.RSVP(uid, time.Time{}, strings.ToUpper(want)); err != nil { + fatal("%v", err) + } + cal.Warnf("%s: %s", uid, strings.ToUpper(want)) + return + } if *dry { if err := s.Report(); err != nil { fatal("%v", err) @@ -237,3 +281,19 @@ func main() { fatal("%v", err) } } + +// mefrom decides which addresses count as us. The account name is the +// obvious default, but you may be invited at an alias and have to be +// told about it. +func mefrom(list, user string) []string { + if list == "" { + return []string{user} + } + var out []string + for _, a := range strings.Split(list, ",") { + if a = strings.TrimSpace(a); a != "" { + out = append(out, a) + } + } + return out +} diff --git a/pim/cmd/caldavfs/rsvp.go b/pim/cmd/caldavfs/rsvp.go new file mode 100644 index 0000000..47d7425 --- /dev/null +++ b/pim/cmd/caldavfs/rsvp.go @@ -0,0 +1,151 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/emersion/go-ical" + + "pim/lib/cal" +) + +// RSVP answers an invitation. +// +// Accepting is two writes -- update your own copy, and tell the +// organiser -- and a server advertising calendar-auto-schedule does +// both from this one PUT. Without that we would have to send the iMIP +// ourselves, which is why Caps only claims schedule when the server +// said so. +// +// The etag goes back as If-Match. If somebody else has touched the +// event since we synced, the server refuses and we say so, rather than +// overwriting a change we never saw. +func (b *backend) RSVP(uid string, recurID time.Time, partstat string) error { + b.mu.Lock() + ref, ok := b.obj[uid] + me := b.me + b.mu.Unlock() + if !ok { + return fmt.Errorf("rsvp: no object for %s; try refresh", uid) + } + if len(me) == 0 { + return fmt.Errorf("rsvp: no identity for this calendar") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + obj, err := b.client.GetCalendarObject(ctx, ref.path) + if err != nil { + return fmt.Errorf("rsvp: fetch: %v", err) + } + if obj.Data == nil { + return fmt.Errorf("rsvp: %s is empty", ref.path) + } + // Match against the version we just read, not the one the last + // report mentioned: those can differ, and the point of If-Match is + // to guard the edit we actually made. + if obj.ETag != "" { + ref.etag = obj.ETag + } + + n := 0 + for _, ev := range obj.Data.Events() { + if id, err := ev.Props.Text(ical.PropUID); err != nil || id != uid { + continue + } + if !recurID.IsZero() { + // answering one occurrence, not the series + got, err := ev.Props.DateTime(ical.PropRecurrenceID, time.Local) + if err != nil || !got.Equal(recurID) { + continue + } + } + n += setPartstat(ev.Props, me, partstat) + } + if n == 0 { + return fmt.Errorf("rsvp: %s does not list any of %s as an attendee", + uid, strings.Join(me, ", ")) + } + + if err := b.put(ref, obj.Data); err != nil { + return err + } + // our copy is stale the moment the server accepts it + if _, err := b.Sync(); err != nil { + cal.Warnf("rsvp: resync: %v", err) + } + return nil +} + +// setPartstat rewrites our own ATTENDEE line and leaves every other one +// alone, which is what iTIP wants in a REPLY. +func setPartstat(props ical.Props, me []string, want string) int { + n := 0 + for _, p := range props.Values(ical.PropAttendee) { + addr := strings.TrimPrefix(p.Value, "mailto:") + cn := p.Params.Get(ical.ParamCommonName) + mine := false + for _, m := range me { + if strings.EqualFold(addr, m) || strings.EqualFold(cn, m) { + mine = true + } + } + if !mine { + continue + } + q := p + q.Params.Set(ical.ParamParticipationStatus, want) + props.Set(&q) + n++ + } + return n +} + +// put writes the object back, refusing to clobber a newer version. +func (b *backend) put(ref objref, c *ical.Calendar) error { + var buf bytes.Buffer + if err := ical.NewEncoder(&buf).Encode(c); err != nil { + return fmt.Errorf("rsvp: encode: %v", err) + } + req, err := http.NewRequest("PUT", urlJoin(b.endpoint, ref.path), bytes.NewReader(buf.Bytes())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "text/calendar; charset=utf-8") + if e := quoteETag(ref.etag); e != "" { + req.Header.Set("If-Match", e) + } + req.SetBasicAuth(b.user, b.pass) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated, http.StatusNoContent: + return nil + case http.StatusPreconditionFailed: + return fmt.Errorf("rsvp: the event changed on the server; refresh and try again") + default: + return fmt.Errorf("rsvp: put: %s", resp.Status) + } +} + +// quoteETag puts back the quotes go-webdav strips when it parses the +// header. If-Match wants an entity-tag, and a bare hex string is not +// one: the server answers 412 and it reads exactly like a lost race. +func quoteETag(s string) string { + if s == "" { + return "" + } + if strings.HasPrefix(s, `"`) || strings.HasPrefix(s, "W/") { + return s + } + return `"` + s + `"` +} diff --git a/pim/lib/cal/tree.go b/pim/lib/cal/tree.go index f78f2eb..4c65877 100644 --- a/pim/lib/cal/tree.go +++ b/pim/lib/cal/tree.go @@ -269,6 +269,12 @@ func (s *Server) addPartstat(dir *fs.StaticDir, e *Event) { if err := rsvp.RSVP(uid, time.Time{}, want); err != nil { return 0, err } + // The tree still holds the old answer until it is rebuilt, + // and a file that lies right after you wrote it is worse + // than one that is slow. + if err := s.Reload(); err != nil { + Warnf("rsvp: reload: %v", err) + } return uint32(len(data)), nil }, }) |
