summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pim/cmd/caldavfs/main.go90
-rw-r--r--pim/cmd/caldavfs/rsvp.go151
-rw-r--r--pim/lib/cal/tree.go6
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
},
})