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.
- checksums.yaml +7 -0
- data/lib/async/caldav/client/addressbook.rb +117 -0
- data/lib/async/caldav/client/calendar.rb +152 -0
- data/lib/async/caldav/client.rb +580 -0
- data/lib/async/caldav/forward_auth.rb +94 -0
- data/lib/async/caldav/handlers/delete.rb +60 -0
- data/lib/async/caldav/handlers/get.rb +87 -0
- data/lib/async/caldav/handlers/head.rb +36 -0
- data/lib/async/caldav/handlers/mkcol.rb +95 -0
- data/lib/async/caldav/handlers/move.rb +126 -0
- data/lib/async/caldav/handlers/options.rb +34 -0
- data/lib/async/caldav/handlers/propfind.rb +201 -0
- data/lib/async/caldav/handlers/proppatch.rb +121 -0
- data/lib/async/caldav/handlers/put.rb +163 -0
- data/lib/async/caldav/handlers/report.rb +257 -0
- data/lib/async/caldav/server.rb +1152 -0
- data/lib/async/caldav/storage/filesystem.rb +375 -0
- data/lib/async/caldav/storage/mock.rb +402 -0
- data/lib/async/caldav/version.rb +7 -0
- data/lib/async/caldav.rb +24 -0
- metadata +90 -0
|
@@ -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
|