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 + `"` }