1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
|
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 + `"`
}
// Create adds an event to the collection.
//
// The path is ours to choose, so it is derived from the UID: a server
// that already has that object will replace it, which is what a second
// PUT of the same event should do. With calendar-auto-schedule the
// server mails every ATTENDEE for us, so creating an event with
// attendees is the whole of sending an invitation.
func (b *backend) Create(c *ical.Calendar) error {
var uid string
for _, e := range c.Events() {
if u, err := e.Props.Text(ical.PropUID); err == nil && u != "" {
uid = u
break
}
}
if uid == "" {
return fmt.Errorf("create: the event has no UID")
}
name := slugUID(uid) + ".ics"
path := strings.TrimSuffix(b.path, "/") + "/" + name
// no etag: this is a create, and If-Match on nothing is meaningless
return b.put(objref{path: path}, c)
}
// slugUID makes a UID safe as one path element.
func slugUID(s string) string {
var out []rune
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
out = append(out, r)
case r == '-', r == '.', r == '_':
out = append(out, r)
default:
out = append(out, '-')
}
}
if len(out) > 100 {
out = out[:100]
}
return string(out)
}
|