async-caldav 1.0.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.
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+
7
+ module Async
8
+ module Caldav
9
+ module Handlers
10
+ module Proppatch
11
+ module_function
12
+
13
+ def call(path:, body:, storage:, **)
14
+ col_path = path.ensure_trailing_slash
15
+ col = storage.get_collection(col_path.to_s)
16
+
17
+ return [404, { 'content-type' => 'text/plain' }, ['Not Found']] unless col
18
+
19
+ updates = {}
20
+
21
+ # Check for removals
22
+ is_remove = body&.match?(/<[^>]*remove[^>]*>/m)
23
+
24
+ dn = Protocol::Caldav::Xml.extract_value(body, 'displayname')
25
+ desc = Protocol::Caldav::Xml.extract_value(body, 'calendar-description')
26
+ color = Protocol::Caldav::Xml.extract_value(body, 'calendar-color')
27
+
28
+ if is_remove
29
+ updates[:displayname] = nil if body.match?(/displayname/i) && !dn
30
+ updates[:description] = nil if body.match?(/calendar-description/i) && !desc
31
+ updates[:color] = nil if body.match?(/calendar-color/i) && !color
32
+ end
33
+
34
+ updates[:displayname] = dn if dn
35
+ updates[:description] = desc if desc
36
+ updates[:color] = color if color
37
+
38
+ storage.update_collection(col_path.to_s, updates)
39
+
40
+ response_xml = <<~XML
41
+ <d:response>
42
+ <d:href>#{Protocol::Caldav::Xml.escape(col_path.to_s)}</d:href>
43
+ <d:propstat>
44
+ <d:prop/>
45
+ <d:status>HTTP/1.1 200 OK</d:status>
46
+ </d:propstat>
47
+ </d:response>
48
+ XML
49
+
50
+ xml = Protocol::Caldav::Multistatus.new([response_xml]).to_xml
51
+ [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ test do
59
+ describe "Async::Caldav::Handlers::Proppatch" do
60
+ def call(**opts)
61
+ Async::Caldav::Handlers::Proppatch.call(**opts)
62
+ end
63
+
64
+ def path(p, s)
65
+ Protocol::Caldav::Path.new(p, storage_class: s)
66
+ end
67
+
68
+ it "updates displayname and returns 207" do
69
+ s = Async::Caldav::Storage::Mock.new
70
+ s.create_collection('/cal/', displayname: 'Old')
71
+ status, = call(
72
+ path: path('/cal/', s), storage: s,
73
+ body: '<d:set><d:prop><d:displayname>New</d:displayname></d:prop></d:set>'
74
+ )
75
+ status.should.equal 207
76
+ s.get_collection('/cal/')[:displayname].should.equal 'New'
77
+ end
78
+
79
+ it "returns 404 for non-existent collection" do
80
+ s = Async::Caldav::Storage::Mock.new
81
+ status, = call(path: path('/nope/', s), storage: s, body: '')
82
+ status.should.equal 404
83
+ end
84
+
85
+ it "updates multiple properties at once" do
86
+ s = Async::Caldav::Storage::Mock.new
87
+ s.create_collection('/cal/', displayname: 'Old', description: 'OldDesc')
88
+ status, = call(
89
+ path: path('/cal/', s), storage: s,
90
+ body: '<d:set><d:prop><d:displayname>New</d:displayname><c:calendar-description>NewDesc</c:calendar-description></d:prop></d:set>'
91
+ )
92
+ status.should.equal 207
93
+ col = s.get_collection('/cal/')
94
+ col[:displayname].should.equal 'New'
95
+ col[:description].should.equal 'NewDesc'
96
+ end
97
+
98
+ it "handles mixed set and remove in one request" do
99
+ s = Async::Caldav::Storage::Mock.new
100
+ s.create_collection('/cal/', displayname: 'Keep', description: 'Remove', color: '#ff0000')
101
+ call(
102
+ path: path('/cal/', s), storage: s,
103
+ body: '<d:set><d:prop><d:displayname>Updated</d:displayname></d:prop></d:set><d:remove><d:prop><c:calendar-description/></d:prop></d:remove>'
104
+ )
105
+ col = s.get_collection('/cal/')
106
+ col[:displayname].should.equal 'Updated'
107
+ col[:description].should.be.nil
108
+ col[:color].should.equal '#ff0000'
109
+ end
110
+
111
+ it "removes a property" do
112
+ s = Async::Caldav::Storage::Mock.new
113
+ s.create_collection('/cal/', displayname: 'Work')
114
+ call(
115
+ path: path('/cal/', s), storage: s,
116
+ body: '<d:remove><d:prop><d:displayname/></d:prop></d:remove>'
117
+ )
118
+ s.get_collection('/cal/')[:displayname].should.be.nil
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+
7
+ module Async
8
+ module Caldav
9
+ module Handlers
10
+ module Put
11
+ module_function
12
+
13
+ def call(path:, body:, storage:, headers: {}, resource_type: nil, **)
14
+ return [400, { 'content-type' => 'text/plain' }, ['Empty body']] if body.nil? || body.strip.empty?
15
+
16
+ # Validate body format
17
+ if resource_type == :calendar
18
+ return [400, { 'content-type' => 'text/plain' }, ['Invalid calendar data']] unless body.start_with?('BEGIN:VCALENDAR')
19
+ content_type = headers['content-type'] || 'text/calendar'
20
+ elsif resource_type == :addressbook
21
+ return [400, { 'content-type' => 'text/plain' }, ['Invalid vCard data']] unless body.start_with?('BEGIN:VCARD')
22
+ content_type = headers['content-type'] || 'text/vcard'
23
+ else
24
+ content_type = headers['content-type'] || 'application/octet-stream'
25
+ end
26
+
27
+ existing = storage.get_item(path.to_s)
28
+
29
+ # Precondition checks
30
+ if_match = headers['if-match']
31
+ if_none_match = headers['if-none-match']
32
+
33
+ if if_match && (!existing || existing[:etag] != if_match)
34
+ return [412, { 'content-type' => 'text/plain' }, ['Precondition Failed']]
35
+ end
36
+
37
+ if if_none_match == '*' && existing
38
+ return [412, { 'content-type' => 'text/plain' }, ['Precondition Failed']]
39
+ end
40
+
41
+ # UID conflict check for new items
42
+ if !existing
43
+ uid = extract_uid(body)
44
+ if uid
45
+ collection_path = path.parent.to_s
46
+ items = storage.list_items(collection_path)
47
+ items.each do |item_path, item_data|
48
+ next if item_path == path.to_s
49
+ if extract_uid(item_data[:body]) == uid
50
+ return [409, { 'content-type' => 'text/plain' }, ['UID conflict']]
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ item, is_new = storage.put_item(path.to_s, body, content_type)
57
+
58
+ if is_new
59
+ [201, { 'etag' => item[:etag], 'content-type' => 'text/plain' }, ['']]
60
+ else
61
+ [204, { 'etag' => item[:etag] }, ['']]
62
+ end
63
+ end
64
+
65
+ def extract_uid(body)
66
+ return nil unless body
67
+ match = body.match(/^UID:(.+)/i)
68
+ match ? match[1].strip : nil
69
+ end
70
+
71
+ private_class_method :extract_uid
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ test do
78
+ describe "Async::Caldav::Handlers::Put" do
79
+ def call(**opts)
80
+ Async::Caldav::Handlers::Put.call(**opts)
81
+ end
82
+
83
+ def path(p, s)
84
+ Protocol::Caldav::Path.new(p, storage_class: s)
85
+ end
86
+
87
+ it "creates a new calendar item and returns 201" do
88
+ s = Async::Caldav::Storage::Mock.new
89
+ s.create_collection('/calendars/admin/cal/')
90
+ status, headers, = call(
91
+ path: path('/calendars/admin/cal/ev.ics', s), storage: s,
92
+ body: "BEGIN:VCALENDAR\r\nUID:123\r\nEND:VCALENDAR",
93
+ resource_type: :calendar
94
+ )
95
+ status.should.equal 201
96
+ headers['etag'].should.not.be.nil
97
+ end
98
+
99
+ it "updates an existing item and returns 204" do
100
+ s = Async::Caldav::Storage::Mock.new
101
+ s.create_collection('/calendars/admin/cal/')
102
+ s.put_item('/calendars/admin/cal/ev.ics', "BEGIN:VCALENDAR\r\nUID:123\r\nEND:VCALENDAR", 'text/calendar')
103
+ status, = call(
104
+ path: path('/calendars/admin/cal/ev.ics', s), storage: s,
105
+ body: "BEGIN:VCALENDAR\r\nUID:123\r\nSUMMARY:Updated\r\nEND:VCALENDAR",
106
+ resource_type: :calendar
107
+ )
108
+ status.should.equal 204
109
+ end
110
+
111
+ it "returns 400 for empty body" do
112
+ s = Async::Caldav::Storage::Mock.new
113
+ status, = call(path: path('/cal/ev.ics', s), storage: s, body: "")
114
+ status.should.equal 400
115
+ end
116
+
117
+ it "returns 400 for invalid calendar data" do
118
+ s = Async::Caldav::Storage::Mock.new
119
+ status, = call(path: path('/cal/ev.ics', s), storage: s, body: "NOT ICAL", resource_type: :calendar)
120
+ status.should.equal 400
121
+ end
122
+
123
+ it "returns 400 for invalid vCard data" do
124
+ s = Async::Caldav::Storage::Mock.new
125
+ status, = call(path: path('/addr/c.vcf', s), storage: s, body: "NOT VCARD", resource_type: :addressbook)
126
+ status.should.equal 400
127
+ end
128
+
129
+ it "returns 412 on If-Match mismatch" do
130
+ s = Async::Caldav::Storage::Mock.new
131
+ s.put_item('/cal/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
132
+ status, = call(
133
+ path: path('/cal/ev.ics', s), storage: s,
134
+ body: "BEGIN:VCALENDAR\r\nNEW", resource_type: :calendar,
135
+ headers: { 'if-match' => '"wrong"' }
136
+ )
137
+ status.should.equal 412
138
+ end
139
+
140
+ it "returns 412 on If-None-Match=* when item exists" do
141
+ s = Async::Caldav::Storage::Mock.new
142
+ s.put_item('/cal/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
143
+ status, = call(
144
+ path: path('/cal/ev.ics', s), storage: s,
145
+ body: "BEGIN:VCALENDAR\r\nNEW", resource_type: :calendar,
146
+ headers: { 'if-none-match' => '*' }
147
+ )
148
+ status.should.equal 412
149
+ end
150
+
151
+ it "returns 409 on UID conflict" do
152
+ s = Async::Caldav::Storage::Mock.new
153
+ s.create_collection('/cal/')
154
+ s.put_item('/cal/a.ics', "BEGIN:VCALENDAR\r\nUID:same\r\nEND:VCALENDAR", 'text/calendar')
155
+ status, = call(
156
+ path: path('/cal/b.ics', s), storage: s,
157
+ body: "BEGIN:VCALENDAR\r\nUID:same\r\nEND:VCALENDAR",
158
+ resource_type: :calendar
159
+ )
160
+ status.should.equal 409
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+
7
+ module Async
8
+ module Caldav
9
+ module Handlers
10
+ module Report
11
+ module_function
12
+
13
+ def call(path:, body:, storage:, resource_type: nil, **)
14
+ # Detect sync-collection report
15
+ if body&.include?('sync-collection')
16
+ return handle_sync_collection(path: path, body: body, storage: storage)
17
+ end
18
+ col_path = path.ensure_trailing_slash
19
+ items = storage.list_items(col_path.to_s)
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
28
+ if resource_type == :addressbook
29
+ Protocol::Caldav::Filter::Parser.parse_addressbook(body)
30
+ else
31
+ Protocol::Caldav::Filter::Parser.parse_calendar(body)
32
+ end
33
+ rescue Protocol::Caldav::ParseError
34
+ nil
35
+ end
36
+
37
+ # Check for multiget (href list)
38
+ hrefs = extract_hrefs(body)
39
+
40
+ if hrefs && !hrefs.empty?
41
+ # Calendar-multiget / addressbook-multiget
42
+ multi = storage.get_multi(hrefs)
43
+ items = multi.select { |_, data| data }
44
+ end
45
+
46
+ responses = items.filter_map do |item_path, data|
47
+ next unless data
48
+
49
+ # Apply filter
50
+ if filter
51
+ if resource_type == :addressbook
52
+ card = Protocol::Caldav::Vcard::Parser.parse(data[:body])
53
+ next unless card && Protocol::Caldav::Filter::Match.addressbook?(filter, card)
54
+ else
55
+ component = Protocol::Caldav::Ical::Parser.parse(data[:body])
56
+ next unless component && Protocol::Caldav::Filter::Match.calendar?(filter, component)
57
+ end
58
+ end
59
+
60
+ item_body = data[:body]
61
+
62
+ # Apply expand if requested (calendar items only)
63
+ if expand_range && resource_type != :addressbook
64
+ component = Protocol::Caldav::Ical::Parser.parse(item_body)
65
+ if component
66
+ item_body = Protocol::Caldav::Ical::Expand.expand(
67
+ component,
68
+ range_start: expand_range[:start],
69
+ range_end: expand_range[:end]
70
+ )
71
+ end
72
+ end
73
+
74
+ item_p = Protocol::Caldav::Path.new(item_path, storage_class: storage)
75
+ item = Protocol::Caldav::Item.new(
76
+ path: item_p,
77
+ body: item_body,
78
+ content_type: data[:content_type],
79
+ etag: data[:etag]
80
+ )
81
+ item.to_report_xml(data_tag: data_tag)
82
+ end
83
+
84
+ xml = Protocol::Caldav::Multistatus.new(responses).to_xml
85
+ [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
86
+ end
87
+
88
+ def handle_sync_collection(path:, body:, storage:)
89
+ col_path = path.ensure_trailing_slash.to_s
90
+
91
+ # Extract sync-token from request
92
+ token_match = body.match(/<[^>]*sync-token[^>]*>(?:<!\[CDATA\[)?([^<\]]*?)(?:\]\]>)?</)
93
+ old_token = token_match ? token_match[1].strip : nil
94
+ old_token = nil if old_token&.empty?
95
+
96
+ if old_token
97
+ # Incremental sync
98
+ result = storage.sync_changes(col_path, old_token)
99
+ unless result
100
+ # Invalid token
101
+ error_xml = <<~XML
102
+ <?xml version="1.0" encoding="UTF-8"?>
103
+ <d:error xmlns:d="DAV:">
104
+ <d:valid-sync-token/>
105
+ </d:error>
106
+ XML
107
+ return [403, { 'content-type' => 'application/xml' }, [error_xml]]
108
+ end
109
+
110
+ new_token, changes = result
111
+ responses = changes.map do |item_path, status|
112
+ if status == :deleted
113
+ <<~XML
114
+ <d:response>
115
+ <d:href>#{Protocol::Caldav::Xml.escape(item_path)}</d:href>
116
+ <d:status>HTTP/1.1 404 Not Found</d:status>
117
+ </d:response>
118
+ XML
119
+ else
120
+ etag = storage.etag(item_path)
121
+ <<~XML
122
+ <d:response>
123
+ <d:href>#{Protocol::Caldav::Xml.escape(item_path)}</d:href>
124
+ <d:propstat>
125
+ <d:prop>
126
+ <d:getetag>#{Protocol::Caldav::Xml.escape(etag)}</d:getetag>
127
+ </d:prop>
128
+ <d:status>HTTP/1.1 200 OK</d:status>
129
+ </d:propstat>
130
+ </d:response>
131
+ XML
132
+ end
133
+ end
134
+ else
135
+ # Initial sync — return all items
136
+ new_token = storage.snapshot_sync(col_path)
137
+ items = storage.list_items(col_path)
138
+ responses = items.map do |item_path, data|
139
+ <<~XML
140
+ <d:response>
141
+ <d:href>#{Protocol::Caldav::Xml.escape(item_path)}</d:href>
142
+ <d:propstat>
143
+ <d:prop>
144
+ <d:getetag>#{Protocol::Caldav::Xml.escape(data[:etag])}</d:getetag>
145
+ </d:prop>
146
+ <d:status>HTTP/1.1 200 OK</d:status>
147
+ </d:propstat>
148
+ </d:response>
149
+ XML
150
+ end
151
+ end
152
+
153
+ xml = <<~XML
154
+ <?xml version="1.0" encoding="UTF-8"?>
155
+ <d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cr="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/" xmlns:x="http://apple.com/ns/ical/">
156
+ #{responses.join}
157
+ <d:sync-token>#{Protocol::Caldav::Xml.escape(new_token)}</d:sync-token>
158
+ </d:multistatus>
159
+ XML
160
+
161
+ [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
162
+ end
163
+
164
+ def parse_expand(body)
165
+ return nil unless body
166
+ match = body.match(/<[^>]*expand[^>]*start\s*=\s*["']([^"']+)["'][^>]*end\s*=\s*["']([^"']+)["']/)
167
+ return nil unless match
168
+ start_time = Protocol::Caldav::Filter::Match.send(:parse_datetime_string, match[1])
169
+ end_time = Protocol::Caldav::Filter::Match.send(:parse_datetime_string, match[2])
170
+ return nil unless start_time && end_time
171
+ { start: start_time, end: end_time }
172
+ end
173
+
174
+ def extract_hrefs(body)
175
+ return nil unless body
176
+ body.scan(/<[^>]*href[^>]*>([^<]+)</).map { |m| m[0].strip }
177
+ end
178
+
179
+ private_class_method :extract_hrefs, :handle_sync_collection, :parse_expand
180
+ end
181
+ end
182
+ end
183
+ end
184
+
185
+ test do
186
+ def normalize(xml)
187
+ xml.gsub(/>\s+</, '><').strip
188
+ end
189
+
190
+ describe "Async::Caldav::Handlers::Report" do
191
+ def call(**opts)
192
+ Async::Caldav::Handlers::Report.call(**opts)
193
+ end
194
+
195
+ def path(p, s)
196
+ Protocol::Caldav::Path.new(p, storage_class: s)
197
+ end
198
+
199
+ it "returns 207 with all items when no filter" do
200
+ s = Async::Caldav::Storage::Mock.new
201
+ s.create_collection('/cal/')
202
+ s.put_item('/cal/a.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:A\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
203
+ s.put_item('/cal/b.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:B\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
204
+ status, _, body = call(path: path('/cal/', s), storage: s, body: '', resource_type: :calendar)
205
+ status.should.equal 207
206
+ body[0].should.include 'a.ics'
207
+ body[0].should.include 'b.ics'
208
+ end
209
+
210
+ it "filters items by comp-filter" do
211
+ s = Async::Caldav::Storage::Mock.new
212
+ s.create_collection('/cal/')
213
+ s.put_item('/cal/ev.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
214
+ s.put_item('/cal/td.ics', "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nSUMMARY:Task\r\nEND:VTODO\r\nEND:VCALENDAR", 'text/calendar')
215
+
216
+ filter_xml = <<~XML
217
+ <c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
218
+ <c:comp-filter name="VCALENDAR">
219
+ <c:comp-filter name="VEVENT"/>
220
+ </c:comp-filter>
221
+ </c:filter>
222
+ XML
223
+
224
+ _, _, body = call(path: path('/cal/', s), storage: s, body: filter_xml, resource_type: :calendar)
225
+ body[0].should.include 'ev.ics'
226
+ body[0].should.not.include 'td.ics'
227
+ end
228
+
229
+ it "uses c:calendar-data tag for calendars" do
230
+ s = Async::Caldav::Storage::Mock.new
231
+ s.create_collection('/cal/')
232
+ s.put_item('/cal/ev.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
233
+ _, _, body = call(path: path('/cal/', s), storage: s, body: '', resource_type: :calendar)
234
+ body[0].should.include 'c:calendar-data'
235
+ end
236
+
237
+ it "uses cr:address-data tag for addressbooks" do
238
+ s = Async::Caldav::Storage::Mock.new
239
+ s.create_collection('/addr/')
240
+ s.put_item('/addr/c.vcf', "BEGIN:VCARD\r\nFN:John\r\nEND:VCARD", 'text/vcard')
241
+ _, _, body = call(path: path('/addr/', s), storage: s, body: '', resource_type: :addressbook)
242
+ body[0].should.include 'cr:address-data'
243
+ end
244
+
245
+ it "handles calendar-multiget with hrefs" do
246
+ s = Async::Caldav::Storage::Mock.new
247
+ s.create_collection('/cal/')
248
+ s.put_item('/cal/a.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
249
+ s.put_item('/cal/b.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
250
+
251
+ multiget_body = '<d:href>/cal/a.ics</d:href>'
252
+ _, _, body = call(path: path('/cal/', s), storage: s, body: multiget_body, resource_type: :calendar)
253
+ body[0].should.include 'a.ics'
254
+ body[0].should.not.include 'b.ics'
255
+ end
256
+ end
257
+ end