async-caldav 1.2.4 → 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.
@@ -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
@@ -14,105 +10,114 @@ module Async
14
10
  col_path = path.ensure_trailing_slash
15
11
  col = storage.get_collection(col_path.to_s)
16
12
 
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
- xml = Protocol::Caldav::Multistatus.new.to_xml do |x|
41
- Protocol::Caldav::XmlBuilder.response(x, href: col_path.to_s) do
42
- x.tag!("d:propstat") do
43
- x.tag!("d:prop")
44
- x.tag!("d:status", "HTTP/1.1 200 OK")
13
+ if col
14
+ updates = {}
15
+ is_remove = body&.match?(/<[^>]*remove[^>]*>/m)
16
+ dn = Protocol::Caldav::Xml.extract_value(body, 'displayname')
17
+ desc = Protocol::Caldav::Xml.extract_value(body, 'calendar-description')
18
+ color = Protocol::Caldav::Xml.extract_value(body, 'calendar-color')
19
+ if is_remove
20
+ if body.match?(/displayname/i) && !dn
21
+ updates[:displayname] = nil
22
+ end
23
+ if body.match?(/calendar-description/i) && !desc
24
+ updates[:description] = nil
25
+ end
26
+ if body.match?(/calendar-color/i) && !color
27
+ updates[:color] = nil
28
+ end
29
+ end
30
+ if dn
31
+ updates[:displayname] = dn
32
+ end
33
+ if desc
34
+ updates[:description] = desc
35
+ end
36
+ if color
37
+ updates[:color] = color
38
+ end
39
+ storage.update_collection(col_path.to_s, updates)
40
+ xml = Protocol::Caldav::Multistatus.new.to_xml do |x|
41
+ Protocol::Caldav::XmlBuilder.response(x, href: col_path.to_s) do
42
+ x.tag!("d:propstat") do
43
+ x.tag!("d:prop")
44
+ x.tag!("d:status", "HTTP/1.1 200 OK")
45
+ end
45
46
  end
46
47
  end
48
+ [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
49
+ else
50
+ [404, { 'content-type' => 'text/plain' }, ['Not Found']]
47
51
  end
48
- [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
49
52
  end
50
53
  end
51
54
  end
52
55
  end
53
56
  end
54
57
 
55
- test do
56
- describe "Async::Caldav::Handlers::Proppatch" do
57
- def call(**opts)
58
- Async::Caldav::Handlers::Proppatch.call(**opts)
59
- end
58
+ __END__
60
59
 
61
- def path(p, s)
62
- Protocol::Caldav::Path.new(p, storage_class: s)
63
- end
60
+ require_relative "../../caldav"
64
61
 
65
- it "updates displayname and returns 207" do
66
- s = Async::Caldav::Storage::Mock.new
67
- s.create_collection('/cal/', displayname: 'Old')
68
- status, = call(
69
- path: path('/cal/', s), storage: s,
70
- body: '<d:set><d:prop><d:displayname>New</d:displayname></d:prop></d:set>'
71
- )
72
- status.should.equal 207
73
- s.get_collection('/cal/')[:displayname].should.equal 'New'
74
- end
62
+ describe "Async::Caldav::Handlers::Proppatch" do
63
+ def call(**opts)
64
+ Async::Caldav::Handlers::Proppatch.call(**opts)
65
+ end
75
66
 
76
- it "returns 404 for non-existent collection" do
77
- s = Async::Caldav::Storage::Mock.new
78
- status, = call(path: path('/nope/', s), storage: s, body: '')
79
- status.should.equal 404
80
- end
67
+ def path(p, s)
68
+ Protocol::Caldav::Path.new(p, storage_class: s)
69
+ end
81
70
 
82
- it "updates multiple properties at once" do
83
- s = Async::Caldav::Storage::Mock.new
84
- s.create_collection('/cal/', displayname: 'Old', description: 'OldDesc')
85
- status, = call(
86
- path: path('/cal/', s), storage: s,
87
- body: '<d:set><d:prop><d:displayname>New</d:displayname><c:calendar-description>NewDesc</c:calendar-description></d:prop></d:set>'
88
- )
89
- status.should.equal 207
90
- col = s.get_collection('/cal/')
91
- col[:displayname].should.equal 'New'
92
- col[:description].should.equal 'NewDesc'
93
- end
71
+ it "updates displayname and returns 207" do
72
+ s = Async::Caldav::Storage::Mock.new
73
+ s.create_collection('/cal/', displayname: 'Old')
74
+ status, = call(
75
+ path: path('/cal/', s), storage: s,
76
+ body: '<d:set><d:prop><d:displayname>New</d:displayname></d:prop></d:set>'
77
+ )
78
+ status.should.equal 207
79
+ s.get_collection('/cal/')[:displayname].should.equal 'New'
80
+ end
94
81
 
95
- it "handles mixed set and remove in one request" do
96
- s = Async::Caldav::Storage::Mock.new
97
- s.create_collection('/cal/', displayname: 'Keep', description: 'Remove', color: '#ff0000')
98
- call(
99
- path: path('/cal/', s), storage: s,
100
- 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>'
101
- )
102
- col = s.get_collection('/cal/')
103
- col[:displayname].should.equal 'Updated'
104
- col[:description].should.be.nil
105
- col[:color].should.equal '#ff0000'
106
- end
82
+ it "returns 404 for non-existent collection" do
83
+ s = Async::Caldav::Storage::Mock.new
84
+ status, = call(path: path('/nope/', s), storage: s, body: '')
85
+ status.should.equal 404
86
+ end
107
87
 
108
- it "removes a property" do
109
- s = Async::Caldav::Storage::Mock.new
110
- s.create_collection('/cal/', displayname: 'Work')
111
- call(
112
- path: path('/cal/', s), storage: s,
113
- body: '<d:remove><d:prop><d:displayname/></d:prop></d:remove>'
114
- )
115
- s.get_collection('/cal/')[:displayname].should.be.nil
116
- end
88
+ it "updates multiple properties at once" do
89
+ s = Async::Caldav::Storage::Mock.new
90
+ s.create_collection('/cal/', displayname: 'Old', description: 'OldDesc')
91
+ status, = call(
92
+ path: path('/cal/', s), storage: s,
93
+ body: '<d:set><d:prop><d:displayname>New</d:displayname><c:calendar-description>NewDesc</c:calendar-description></d:prop></d:set>'
94
+ )
95
+ status.should.equal 207
96
+ col = s.get_collection('/cal/')
97
+ col[:displayname].should.equal 'New'
98
+ col[:description].should.equal 'NewDesc'
117
99
  end
118
- end
100
+
101
+ it "handles mixed set and remove in one request" do
102
+ s = Async::Caldav::Storage::Mock.new
103
+ s.create_collection('/cal/', displayname: 'Keep', description: 'Remove', color: '#ff0000')
104
+ call(
105
+ path: path('/cal/', s), storage: s,
106
+ 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>'
107
+ )
108
+ col = s.get_collection('/cal/')
109
+ col[:displayname].should.equal 'Updated'
110
+ col[:description].should.be.nil
111
+ col[:color].should.equal '#ff0000'
112
+ end
113
+
114
+ it "removes a property" do
115
+ s = Async::Caldav::Storage::Mock.new
116
+ s.create_collection('/cal/', displayname: 'Work')
117
+ call(
118
+ path: path('/cal/', s), storage: s,
119
+ body: '<d:remove><d:prop><d:displayname/></d:prop></d:remove>'
120
+ )
121
+ s.get_collection('/cal/')[:displayname].should.be.nil
122
+ end
123
+ end
@@ -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
@@ -11,153 +7,177 @@ module Async
11
7
  module_function
12
8
 
13
9
  def call(path:, body:, storage:, headers: {}, resource_type: nil, **)
14
- return [400, { 'content-type' => 'text/plain' }, ['Empty body']] if body.nil? || body.strip.empty?
10
+ rejected = rejection(body, resource_type)
11
+ if rejected
12
+ rejected
13
+ else
14
+ content_type = headers['content-type'] || default_content_type(resource_type)
15
+ store(
16
+ path,
17
+ body,
18
+ content_type,
19
+ storage,
20
+ headers,
21
+ )
22
+ end
23
+ end
15
24
 
16
- # Validate body format
25
+ # Everything judged from the body alone, before anything is looked up or
26
+ # written. nil when the body is acceptable, otherwise the response.
27
+ def rejection(body, resource_type)
28
+ if body.nil? || body.strip.empty?
29
+ [400, { 'content-type' => 'text/plain' }, ['Empty body']]
30
+ elsif resource_type == :calendar && !body.start_with?('BEGIN:VCALENDAR')
31
+ [400, { 'content-type' => 'text/plain' }, ['Invalid calendar data']]
32
+ elsif resource_type == :addressbook && !body.start_with?('BEGIN:VCARD')
33
+ [400, { 'content-type' => 'text/plain' }, ['Invalid vCard data']]
34
+ end
35
+ end
36
+
37
+ def default_content_type(resource_type)
17
38
  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'
39
+ 'text/calendar'
20
40
  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'
41
+ 'text/vcard'
23
42
  else
24
- content_type = headers['content-type'] || 'application/octet-stream'
43
+ 'application/octet-stream'
25
44
  end
45
+ end
26
46
 
47
+ def store(path, body, content_type, storage, headers)
27
48
  existing = storage.get_item(path.to_s)
28
-
29
- # Precondition checks
30
49
  if_match = headers['if-match']
31
50
  if_none_match = headers['if-none-match']
32
-
33
51
  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
52
+ [412, { 'content-type' => 'text/plain' }, ['Precondition Failed']]
53
+ elsif if_none_match == '*' && existing
54
+ [412, { 'content-type' => 'text/plain' }, ['Precondition Failed']]
55
+ elsif !existing && uid_conflict?(path, body, storage)
56
+ [409, { 'content-type' => 'text/plain' }, ['UID conflict']]
57
+ else
58
+ item, is_new = storage.put_item(path.to_s, body, content_type)
59
+ if is_new
60
+ [201, { 'etag' => item[:etag], 'content-type' => 'text/plain' }, ['']]
61
+ else
62
+ [204, { 'etag' => item[:etag] }, ['']]
53
63
  end
54
64
  end
65
+ end
55
66
 
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' }, ['']]
67
+ # A UID may appear once per collection. The item at `path` itself is
68
+ # skipped: replacing it with a body carrying its own UID is not a clash.
69
+ def uid_conflict?(path, body, storage)
70
+ uid = extract_uid(body)
71
+ if uid
72
+ storage.list_items(path.parent.to_s).any? do |item_path, item_data|
73
+ item_path != path.to_s && extract_uid(item_data[:body]) == uid
74
+ end
60
75
  else
61
- [204, { 'etag' => item[:etag] }, ['']]
76
+ false
62
77
  end
63
78
  end
64
79
 
65
80
  def extract_uid(body)
66
- return nil unless body
67
- match = body.match(/^UID:(.+)/i)
68
- match ? match[1].strip : nil
81
+ if body
82
+ match = body.match(/^UID:(.+)/i)
83
+ match ? match[1].strip : nil
84
+ else
85
+ nil
86
+ end
69
87
  end
70
88
 
71
- private_class_method :extract_uid
89
+ private_class_method :extract_uid, :rejection, :default_content_type, :store, :uid_conflict?
72
90
  end
73
91
  end
74
92
  end
75
93
  end
76
94
 
77
- test do
78
- describe "Async::Caldav::Handlers::Put" do
79
- def call(**opts)
80
- Async::Caldav::Handlers::Put.call(**opts)
81
- end
95
+ __END__
82
96
 
83
- def path(p, s)
84
- Protocol::Caldav::Path.new(p, storage_class: s)
85
- end
97
+ require_relative "../../caldav"
86
98
 
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
99
+ describe "Async::Caldav::Handlers::Put" do
100
+ def call(**opts)
101
+ Async::Caldav::Handlers::Put.call(**opts)
102
+ end
98
103
 
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
104
+ def path(p, s)
105
+ Protocol::Caldav::Path.new(p, storage_class: s)
106
+ end
110
107
 
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
108
+ it "creates a new calendar item and returns 201" do
109
+ s = Async::Caldav::Storage::Mock.new
110
+ s.create_collection('/calendars/admin/cal/')
111
+ status, headers, = call(
112
+ path: path('/calendars/admin/cal/ev.ics', s), storage: s,
113
+ body: "BEGIN:VCALENDAR\r\nUID:123\r\nEND:VCALENDAR",
114
+ resource_type: :calendar
115
+ )
116
+ status.should.equal 201
117
+ headers['etag'].should.not.be.nil
118
+ end
116
119
 
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
120
+ it "updates an existing item and returns 204" do
121
+ s = Async::Caldav::Storage::Mock.new
122
+ s.create_collection('/calendars/admin/cal/')
123
+ s.put_item('/calendars/admin/cal/ev.ics', "BEGIN:VCALENDAR\r\nUID:123\r\nEND:VCALENDAR", 'text/calendar')
124
+ status, = call(
125
+ path: path('/calendars/admin/cal/ev.ics', s), storage: s,
126
+ body: "BEGIN:VCALENDAR\r\nUID:123\r\nSUMMARY:Updated\r\nEND:VCALENDAR",
127
+ resource_type: :calendar
128
+ )
129
+ status.should.equal 204
130
+ end
122
131
 
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
132
+ it "returns 400 for empty body" do
133
+ s = Async::Caldav::Storage::Mock.new
134
+ status, = call(path: path('/cal/ev.ics', s), storage: s, body: "")
135
+ status.should.equal 400
136
+ end
128
137
 
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
138
+ it "returns 400 for invalid calendar data" do
139
+ s = Async::Caldav::Storage::Mock.new
140
+ status, = call(path: path('/cal/ev.ics', s), storage: s, body: "NOT ICAL", resource_type: :calendar)
141
+ status.should.equal 400
142
+ end
139
143
 
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
144
+ it "returns 400 for invalid vCard data" do
145
+ s = Async::Caldav::Storage::Mock.new
146
+ status, = call(path: path('/addr/c.vcf', s), storage: s, body: "NOT VCARD", resource_type: :addressbook)
147
+ status.should.equal 400
148
+ end
150
149
 
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
150
+ it "returns 412 on If-Match mismatch" do
151
+ s = Async::Caldav::Storage::Mock.new
152
+ s.put_item('/cal/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
153
+ status, = call(
154
+ path: path('/cal/ev.ics', s), storage: s,
155
+ body: "BEGIN:VCALENDAR\r\nNEW", resource_type: :calendar,
156
+ headers: { 'if-match' => '"wrong"' }
157
+ )
158
+ status.should.equal 412
162
159
  end
163
- end
160
+
161
+ it "returns 412 on If-None-Match=* when item exists" do
162
+ s = Async::Caldav::Storage::Mock.new
163
+ s.put_item('/cal/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
164
+ status, = call(
165
+ path: path('/cal/ev.ics', s), storage: s,
166
+ body: "BEGIN:VCALENDAR\r\nNEW", resource_type: :calendar,
167
+ headers: { 'if-none-match' => '*' }
168
+ )
169
+ status.should.equal 412
170
+ end
171
+
172
+ it "returns 409 on UID conflict" do
173
+ s = Async::Caldav::Storage::Mock.new
174
+ s.create_collection('/cal/')
175
+ s.put_item('/cal/a.ics', "BEGIN:VCALENDAR\r\nUID:same\r\nEND:VCALENDAR", 'text/calendar')
176
+ status, = call(
177
+ path: path('/cal/b.ics', s), storage: s,
178
+ body: "BEGIN:VCALENDAR\r\nUID:same\r\nEND:VCALENDAR",
179
+ resource_type: :calendar
180
+ )
181
+ status.should.equal 409
182
+ end
183
+ end