async-caldav 1.2.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/async/caldav/client/addressbook.rb +21 -9
- data/lib/async/caldav/client/calendar.rb +39 -15
- data/lib/async/caldav/client.rb +423 -371
- data/lib/async/caldav/forward_auth.rb +61 -54
- data/lib/async/caldav/handlers/delete.rb +28 -30
- data/lib/async/caldav/handlers/get.rb +45 -47
- data/lib/async/caldav/handlers/head.rb +16 -18
- data/lib/async/caldav/handlers/mkcol.rb +64 -68
- data/lib/async/caldav/handlers/move.rb +99 -90
- data/lib/async/caldav/handlers/options.rb +15 -17
- data/lib/async/caldav/handlers/propfind.rb +140 -139
- data/lib/async/caldav/handlers/proppatch.rb +95 -90
- data/lib/async/caldav/handlers/put.rb +139 -119
- data/lib/async/caldav/handlers/report.rb +192 -161
- data/lib/async/caldav/server.rb +1029 -994
- data/lib/async/caldav/storage/filesystem.rb +247 -220
- data/lib/async/caldav/storage/mock.rb +254 -246
- data/lib/async/caldav/version.rb +1 -1
- metadata +19 -5
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "scampi"
|
|
5
|
-
require "async/caldav"
|
|
6
|
-
|
|
7
3
|
module Async
|
|
8
4
|
module Caldav
|
|
9
5
|
module Handlers
|
|
@@ -13,99 +9,108 @@ module Async
|
|
|
13
9
|
def call(path:, body:, storage:, resource_type: nil, **)
|
|
14
10
|
# Detect sync-collection report
|
|
15
11
|
if body&.include?('sync-collection')
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
data_tag = resource_type == :addressbook ? 'cr:address-data' : 'c:calendar-data'
|
|
22
|
-
|
|
23
|
-
# Parse expand-property if present
|
|
24
|
-
expand_range = parse_expand(body)
|
|
25
|
-
|
|
26
|
-
# Parse filter if present (multiget requests have no filter — ignore parse errors)
|
|
27
|
-
filter = begin
|
|
12
|
+
handle_sync_collection(path: path, body: body, storage: storage)
|
|
13
|
+
else
|
|
14
|
+
col_path = path.ensure_trailing_slash
|
|
15
|
+
items = storage.list_items(col_path.to_s)
|
|
28
16
|
if resource_type == :addressbook
|
|
29
|
-
|
|
17
|
+
data_tag = 'cr:address-data'
|
|
30
18
|
else
|
|
31
|
-
|
|
19
|
+
data_tag = 'c:calendar-data'
|
|
32
20
|
end
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
xml = Protocol::Caldav::Multistatus.new.to_xml do |x|
|
|
47
|
-
items.each do |item_path, data|
|
|
48
|
-
next unless data
|
|
21
|
+
expand_range = parse_expand(body)
|
|
22
|
+
filter = parse_filter(body, resource_type)
|
|
23
|
+
hrefs = extract_hrefs(body)
|
|
24
|
+
if hrefs && !hrefs.empty?
|
|
25
|
+
# Calendar-multiget / addressbook-multiget
|
|
26
|
+
multi = storage.get_multi(hrefs)
|
|
27
|
+
items = multi.select { |_, data| data }
|
|
28
|
+
end
|
|
29
|
+
xml = Protocol::Caldav::Multistatus.new.to_xml do |x|
|
|
30
|
+
items.each do |item_path, data|
|
|
31
|
+
unless data
|
|
32
|
+
next
|
|
33
|
+
end
|
|
49
34
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
35
|
+
# Apply filter
|
|
36
|
+
if filter
|
|
37
|
+
if resource_type == :addressbook
|
|
38
|
+
card = Protocol::Caldav::Vcard::Parser.parse(data[:body])
|
|
39
|
+
unless card && Protocol::Caldav::Filter::Match.addressbook?(filter, card)
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
else
|
|
43
|
+
component = Protocol::Caldav::Ical::Parser.parse(data[:body])
|
|
44
|
+
unless component && Protocol::Caldav::Filter::Match.calendar?(filter, component)
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
end
|
|
58
48
|
end
|
|
59
|
-
end
|
|
60
49
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
50
|
+
item_body = data[:body]
|
|
51
|
+
|
|
52
|
+
# Apply expand if requested (calendar items only)
|
|
53
|
+
if expand_range && resource_type != :addressbook
|
|
54
|
+
component = Protocol::Caldav::Ical::Parser.parse(item_body)
|
|
55
|
+
if component
|
|
56
|
+
item_body = Protocol::Caldav::Ical::Expand.expand(
|
|
57
|
+
component,
|
|
58
|
+
range_start: expand_range[:start],
|
|
59
|
+
range_end: expand_range[:end],
|
|
60
|
+
)
|
|
61
|
+
end
|
|
72
62
|
end
|
|
73
|
-
end
|
|
74
63
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
64
|
+
item_p = Protocol::Caldav::Path.new(item_path, storage_class: storage)
|
|
65
|
+
item = Protocol::Caldav::Item.new(
|
|
66
|
+
path: item_p,
|
|
67
|
+
body: item_body,
|
|
68
|
+
content_type: data[:content_type],
|
|
69
|
+
etag: data[:etag],
|
|
70
|
+
)
|
|
71
|
+
item.build_report(x, data_tag: data_tag)
|
|
72
|
+
end
|
|
83
73
|
end
|
|
74
|
+
[207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
|
|
84
75
|
end
|
|
76
|
+
end
|
|
85
77
|
|
|
86
|
-
|
|
78
|
+
def parse_filter(body, resource_type)
|
|
79
|
+
if resource_type == :addressbook
|
|
80
|
+
Protocol::Caldav::Filter::Parser.parse_addressbook(body)
|
|
81
|
+
else
|
|
82
|
+
Protocol::Caldav::Filter::Parser.parse_calendar(body)
|
|
83
|
+
end
|
|
84
|
+
rescue Protocol::Caldav::ParseError
|
|
85
|
+
nil
|
|
87
86
|
end
|
|
88
87
|
|
|
89
88
|
def handle_sync_collection(path:, body:, storage:)
|
|
90
89
|
col_path = path.ensure_trailing_slash.to_s
|
|
91
|
-
|
|
92
|
-
# Extract sync-token from request
|
|
93
|
-
token_match = body.match(/<[^>]*sync-token[^>]*>(?:<!\[CDATA\[)?([^<\]]*?)(?:\]\]>)?</)
|
|
94
|
-
old_token = token_match ? token_match[1].strip : nil
|
|
95
|
-
old_token = nil if old_token&.empty?
|
|
90
|
+
old_token = extract_sync_token(body)
|
|
96
91
|
|
|
97
92
|
if old_token
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
93
|
+
incremental_sync(col_path, old_token, storage)
|
|
94
|
+
else
|
|
95
|
+
initial_sync(col_path, storage)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def extract_sync_token(body)
|
|
100
|
+
token_match = body.match(/<[^>]*sync-token[^>]*>(?:<!\[CDATA\[)?([^<\]]*?)(?:\]\]>)?</)
|
|
101
|
+
if token_match
|
|
102
|
+
token = token_match[1].strip
|
|
103
|
+
if token.empty?
|
|
104
|
+
nil
|
|
105
|
+
else
|
|
106
|
+
token
|
|
107
107
|
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
108
110
|
|
|
111
|
+
def incremental_sync(col_path, old_token, storage)
|
|
112
|
+
result = storage.sync_changes(col_path, old_token)
|
|
113
|
+
if result
|
|
109
114
|
new_token, changes = result
|
|
110
115
|
xml = Protocol::Caldav::XmlBuilder.multistatus do |x|
|
|
111
116
|
changes.each do |item_path, status|
|
|
@@ -124,116 +129,142 @@ module Async
|
|
|
124
129
|
end
|
|
125
130
|
x.tag!("d:sync-token", new_token)
|
|
126
131
|
end
|
|
132
|
+
[207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
|
|
127
133
|
else
|
|
128
|
-
#
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
xml
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
# The token names a snapshot the storage no longer holds; RFC 6578
|
|
135
|
+
# says to answer with valid-sync-token so the client resyncs whole.
|
|
136
|
+
error_xml = Builder::XmlMarkup.new
|
|
137
|
+
error_xml.instruct! :xml, version: "1.0", encoding: "UTF-8"
|
|
138
|
+
error_xml.tag!("d:error", "xmlns:d" => "DAV:") do
|
|
139
|
+
error_xml.tag!("d:valid-sync-token")
|
|
140
|
+
end
|
|
141
|
+
[403, { 'content-type' => 'application/xml' }, [error_xml.target!]]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def initial_sync(col_path, storage)
|
|
146
|
+
new_token = storage.snapshot_sync(col_path)
|
|
147
|
+
items = storage.list_items(col_path)
|
|
148
|
+
xml = Protocol::Caldav::XmlBuilder.multistatus do |x|
|
|
149
|
+
items.each do |item_path, data|
|
|
150
|
+
Protocol::Caldav::XmlBuilder.response(x, href: item_path) do
|
|
151
|
+
Protocol::Caldav::XmlBuilder.propstat_ok(x) do
|
|
152
|
+
x.tag!("d:getetag", data[:etag])
|
|
137
153
|
end
|
|
138
154
|
end
|
|
139
|
-
x.tag!("d:sync-token", new_token)
|
|
140
155
|
end
|
|
156
|
+
x.tag!("d:sync-token", new_token)
|
|
141
157
|
end
|
|
142
158
|
|
|
143
159
|
[207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
|
|
144
160
|
end
|
|
145
161
|
|
|
146
162
|
def parse_expand(body)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
163
|
+
if body
|
|
164
|
+
match = body.match(/<[^>]*expand[^>]*start\s*=\s*["']([^"']+)["'][^>]*end\s*=\s*["']([^"']+)["']/)
|
|
165
|
+
if match
|
|
166
|
+
start_time = Protocol::Caldav::Filter::Match.send(:parse_datetime_string, match[1])
|
|
167
|
+
end_time = Protocol::Caldav::Filter::Match.send(:parse_datetime_string, match[2])
|
|
168
|
+
if start_time && end_time
|
|
169
|
+
{ start: start_time, end: end_time }
|
|
170
|
+
else
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
else
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
else
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
154
179
|
end
|
|
155
180
|
|
|
156
181
|
def extract_hrefs(body)
|
|
157
|
-
|
|
158
|
-
|
|
182
|
+
if body
|
|
183
|
+
body.scan(/<[^>]*href[^>]*>([^<]+)</).map { |m| m[0].strip }
|
|
184
|
+
else
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
159
187
|
end
|
|
160
188
|
|
|
161
189
|
private_class_method :extract_hrefs, :handle_sync_collection, :parse_expand
|
|
190
|
+
private_class_method :parse_filter, :extract_sync_token, :incremental_sync, :initial_sync
|
|
162
191
|
end
|
|
163
192
|
end
|
|
164
193
|
end
|
|
165
194
|
end
|
|
166
195
|
|
|
167
|
-
|
|
168
|
-
def normalize(xml)
|
|
169
|
-
xml.gsub(/>\s+</, '><').strip
|
|
170
|
-
end
|
|
196
|
+
__END__
|
|
171
197
|
|
|
172
|
-
|
|
173
|
-
def call(**opts)
|
|
174
|
-
Async::Caldav::Handlers::Report.call(**opts)
|
|
175
|
-
end
|
|
198
|
+
require_relative "../../caldav"
|
|
176
199
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
200
|
+
def normalize(xml)
|
|
201
|
+
xml.gsub(/>\s+</, '><').strip
|
|
202
|
+
end
|
|
180
203
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
s.put_item('/cal/b.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:B\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
|
|
186
|
-
status, _, body = call(path: path('/cal/', s), storage: s, body: '', resource_type: :calendar)
|
|
187
|
-
status.should.equal 207
|
|
188
|
-
body[0].should.include 'a.ics'
|
|
189
|
-
body[0].should.include 'b.ics'
|
|
190
|
-
end
|
|
204
|
+
describe "Async::Caldav::Handlers::Report" do
|
|
205
|
+
def call(**opts)
|
|
206
|
+
Async::Caldav::Handlers::Report.call(**opts)
|
|
207
|
+
end
|
|
191
208
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
s.put_item('/cal/ev.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
|
|
196
|
-
s.put_item('/cal/td.ics', "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nSUMMARY:Task\r\nEND:VTODO\r\nEND:VCALENDAR", 'text/calendar')
|
|
197
|
-
|
|
198
|
-
filter_xml = <<~XML
|
|
199
|
-
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
200
|
-
<c:comp-filter name="VCALENDAR">
|
|
201
|
-
<c:comp-filter name="VEVENT"/>
|
|
202
|
-
</c:comp-filter>
|
|
203
|
-
</c:filter>
|
|
204
|
-
XML
|
|
205
|
-
|
|
206
|
-
_, _, body = call(path: path('/cal/', s), storage: s, body: filter_xml, resource_type: :calendar)
|
|
207
|
-
body[0].should.include 'ev.ics'
|
|
208
|
-
body[0].should.not.include 'td.ics'
|
|
209
|
-
end
|
|
209
|
+
def path(p, s)
|
|
210
|
+
Protocol::Caldav::Path.new(p, storage_class: s)
|
|
211
|
+
end
|
|
210
212
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
213
|
+
it "returns 207 with all items when no filter" do
|
|
214
|
+
s = Async::Caldav::Storage::Mock.new
|
|
215
|
+
s.create_collection('/cal/')
|
|
216
|
+
s.put_item('/cal/a.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:A\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
|
|
217
|
+
s.put_item('/cal/b.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:B\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
|
|
218
|
+
status, _, body = call(path: path('/cal/', s), storage: s, body: '', resource_type: :calendar)
|
|
219
|
+
status.should.equal 207
|
|
220
|
+
body[0].should.include 'a.ics'
|
|
221
|
+
body[0].should.include 'b.ics'
|
|
222
|
+
end
|
|
218
223
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
it "filters items by comp-filter" do
|
|
225
|
+
s = Async::Caldav::Storage::Mock.new
|
|
226
|
+
s.create_collection('/cal/')
|
|
227
|
+
s.put_item('/cal/ev.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
|
|
228
|
+
s.put_item('/cal/td.ics', "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nSUMMARY:Task\r\nEND:VTODO\r\nEND:VCALENDAR", 'text/calendar')
|
|
229
|
+
|
|
230
|
+
filter_xml = <<~XML
|
|
231
|
+
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
232
|
+
<c:comp-filter name="VCALENDAR">
|
|
233
|
+
<c:comp-filter name="VEVENT"/>
|
|
234
|
+
</c:comp-filter>
|
|
235
|
+
</c:filter>
|
|
236
|
+
XML
|
|
237
|
+
|
|
238
|
+
_, _, body = call(path: path('/cal/', s), storage: s, body: filter_xml, resource_type: :calendar)
|
|
239
|
+
body[0].should.include 'ev.ics'
|
|
240
|
+
body[0].should.not.include 'td.ics'
|
|
241
|
+
end
|
|
226
242
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
243
|
+
it "uses c:calendar-data tag for calendars" do
|
|
244
|
+
s = Async::Caldav::Storage::Mock.new
|
|
245
|
+
s.create_collection('/cal/')
|
|
246
|
+
s.put_item('/cal/ev.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
|
|
247
|
+
_, _, body = call(path: path('/cal/', s), storage: s, body: '', resource_type: :calendar)
|
|
248
|
+
body[0].should.include 'c:calendar-data'
|
|
249
|
+
end
|
|
232
250
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
251
|
+
it "uses cr:address-data tag for addressbooks" do
|
|
252
|
+
s = Async::Caldav::Storage::Mock.new
|
|
253
|
+
s.create_collection('/addr/')
|
|
254
|
+
s.put_item('/addr/c.vcf', "BEGIN:VCARD\r\nFN:John\r\nEND:VCARD", 'text/vcard')
|
|
255
|
+
_, _, body = call(path: path('/addr/', s), storage: s, body: '', resource_type: :addressbook)
|
|
256
|
+
body[0].should.include 'cr:address-data'
|
|
238
257
|
end
|
|
239
|
-
|
|
258
|
+
|
|
259
|
+
it "handles calendar-multiget with hrefs" do
|
|
260
|
+
s = Async::Caldav::Storage::Mock.new
|
|
261
|
+
s.create_collection('/cal/')
|
|
262
|
+
s.put_item('/cal/a.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
|
|
263
|
+
s.put_item('/cal/b.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
|
|
264
|
+
|
|
265
|
+
multiget_body = '<d:href>/cal/a.ics</d:href>'
|
|
266
|
+
_, _, body = call(path: path('/cal/', s), storage: s, body: multiget_body, resource_type: :calendar)
|
|
267
|
+
body[0].should.include 'a.ics'
|
|
268
|
+
body[0].should.not.include 'b.ics'
|
|
269
|
+
end
|
|
270
|
+
end
|