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,60 @@
|
|
|
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 Delete
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(path:, storage:, **)
|
|
14
|
+
item = storage.get_item(path.to_s)
|
|
15
|
+
|
|
16
|
+
if item
|
|
17
|
+
storage.delete_item(path.to_s)
|
|
18
|
+
[204, {}, ['']]
|
|
19
|
+
elsif storage.collection_exists?(path.ensure_trailing_slash.to_s)
|
|
20
|
+
storage.delete_collection(path.ensure_trailing_slash.to_s)
|
|
21
|
+
[204, {}, ['']]
|
|
22
|
+
else
|
|
23
|
+
[404, { 'content-type' => 'text/plain' }, ['Not Found']]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
test do
|
|
32
|
+
describe "Async::Caldav::Handlers::Delete" do
|
|
33
|
+
def call(**opts)
|
|
34
|
+
Async::Caldav::Handlers::Delete.call(**opts)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "deletes an item and returns 204" do
|
|
38
|
+
s = Async::Caldav::Storage::Mock.new
|
|
39
|
+
s.create_collection('/calendars/admin/cal/', type: :calendar)
|
|
40
|
+
s.put_item('/calendars/admin/cal/event.ics', 'data', 'text/calendar')
|
|
41
|
+
status, = call(path: Protocol::Caldav::Path.new('/calendars/admin/cal/event.ics', storage_class: s), storage: s)
|
|
42
|
+
status.should.equal 204
|
|
43
|
+
s.get_item('/calendars/admin/cal/event.ics').should.be.nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "deletes a collection and returns 204" do
|
|
47
|
+
s = Async::Caldav::Storage::Mock.new
|
|
48
|
+
s.create_collection('/calendars/admin/cal/', type: :calendar)
|
|
49
|
+
status, = call(path: Protocol::Caldav::Path.new('/calendars/admin/cal/', storage_class: s), storage: s)
|
|
50
|
+
status.should.equal 204
|
|
51
|
+
s.collection_exists?('/calendars/admin/cal/').should.equal false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "returns 404 for non-existent resource" do
|
|
55
|
+
s = Async::Caldav::Storage::Mock.new
|
|
56
|
+
status, = call(path: Protocol::Caldav::Path.new('/calendars/admin/nope/', storage_class: s), storage: s)
|
|
57
|
+
status.should.equal 404
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
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 Get
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(path:, storage:, headers: {}, **)
|
|
14
|
+
item = storage.get_item(path.to_s)
|
|
15
|
+
col_path = path.ensure_trailing_slash.to_s
|
|
16
|
+
|
|
17
|
+
if item
|
|
18
|
+
if headers['if-none-match'] == item[:etag]
|
|
19
|
+
[304, { 'etag' => item[:etag], 'cache-control' => 'private, no-cache' }, []]
|
|
20
|
+
else
|
|
21
|
+
[200, { 'content-type' => item[:content_type], 'etag' => item[:etag], 'cache-control' => 'private, no-cache' }, [item[:body]]]
|
|
22
|
+
end
|
|
23
|
+
elsif storage.collection_exists?(col_path)
|
|
24
|
+
items = storage.list_items(col_path)
|
|
25
|
+
body = items.map { |_, data| data[:body] }.join("\n")
|
|
26
|
+
[200, { 'content-type' => 'text/plain' }, [body]]
|
|
27
|
+
elsif path.depth <= 2
|
|
28
|
+
[200, { 'content-type' => 'text/html' }, ['<html><body>CalDAV</body></html>']]
|
|
29
|
+
else
|
|
30
|
+
[404, { 'content-type' => 'text/plain' }, ['Not Found']]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
test do
|
|
39
|
+
describe "Async::Caldav::Handlers::Get" do
|
|
40
|
+
def call(**opts)
|
|
41
|
+
Async::Caldav::Handlers::Get.call(**opts)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def path(p, s)
|
|
45
|
+
Protocol::Caldav::Path.new(p, storage_class: s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "returns item body with 200" do
|
|
49
|
+
s = Async::Caldav::Storage::Mock.new
|
|
50
|
+
s.put_item('/cal/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
|
|
51
|
+
status, headers, body = call(path: path('/cal/ev.ics', s), storage: s)
|
|
52
|
+
status.should.equal 200
|
|
53
|
+
headers['content-type'].should.equal 'text/calendar'
|
|
54
|
+
body[0].should.equal 'BEGIN:VCALENDAR'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "returns 304 on If-None-Match hit" do
|
|
58
|
+
s = Async::Caldav::Storage::Mock.new
|
|
59
|
+
item, = s.put_item('/cal/ev.ics', 'data', 'text/calendar')
|
|
60
|
+
status, = call(path: path('/cal/ev.ics', s), storage: s, headers: { 'if-none-match' => item[:etag] })
|
|
61
|
+
status.should.equal 304
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "returns collection contents" do
|
|
65
|
+
s = Async::Caldav::Storage::Mock.new
|
|
66
|
+
s.create_collection('/cal/')
|
|
67
|
+
s.put_item('/cal/a.ics', 'A', 'text/calendar')
|
|
68
|
+
s.put_item('/cal/b.ics', 'B', 'text/calendar')
|
|
69
|
+
status, _, body = call(path: path('/cal/', s), storage: s)
|
|
70
|
+
status.should.equal 200
|
|
71
|
+
body[0].should.include 'A'
|
|
72
|
+
body[0].should.include 'B'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "returns 404 for deep non-existent path" do
|
|
76
|
+
s = Async::Caldav::Storage::Mock.new
|
|
77
|
+
status, = call(path: path('/calendars/admin/nope/item.ics', s), storage: s)
|
|
78
|
+
status.should.equal 404
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "returns 200 for shallow path" do
|
|
82
|
+
s = Async::Caldav::Storage::Mock.new
|
|
83
|
+
status, = call(path: path('/calendars/admin/', s), storage: s)
|
|
84
|
+
status.should.equal 200
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
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 Head
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(**opts)
|
|
14
|
+
status, headers, _body = Get.call(**opts)
|
|
15
|
+
[status, headers, []]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
test do
|
|
23
|
+
describe "Async::Caldav::Handlers::Head" do
|
|
24
|
+
it "returns same status and headers as GET but empty body" do
|
|
25
|
+
s = Async::Caldav::Storage::Mock.new
|
|
26
|
+
s.put_item('/cal/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
|
|
27
|
+
status, headers, body = Async::Caldav::Handlers::Head.call(
|
|
28
|
+
path: Protocol::Caldav::Path.new('/cal/ev.ics', storage_class: s),
|
|
29
|
+
storage: s
|
|
30
|
+
)
|
|
31
|
+
status.should.equal 200
|
|
32
|
+
headers['content-type'].should.equal 'text/calendar'
|
|
33
|
+
body.should.equal []
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
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 Mkcol
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# method: 'MKCALENDAR' or 'MKCOL'
|
|
14
|
+
def call(path:, body:, storage:, method: 'MKCOL', resource_type: nil, **)
|
|
15
|
+
col_path = path.ensure_trailing_slash
|
|
16
|
+
|
|
17
|
+
if storage.collection_exists?(col_path.to_s)
|
|
18
|
+
return [405, { 'content-type' => 'text/plain' }, ['Collection already exists']]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
unless col_path.parent_exists?
|
|
22
|
+
return [409, { 'content-type' => 'text/plain' }, ['Parent collection does not exist']]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
displayname = Protocol::Caldav::Xml.extract_value(body, 'displayname')
|
|
26
|
+
description = Protocol::Caldav::Xml.extract_value(body, 'calendar-description')
|
|
27
|
+
color = Protocol::Caldav::Xml.extract_value(body, 'calendar-color')
|
|
28
|
+
|
|
29
|
+
type = if method == 'MKCALENDAR'
|
|
30
|
+
:calendar
|
|
31
|
+
elsif body && body.include?('addressbook')
|
|
32
|
+
:addressbook
|
|
33
|
+
else
|
|
34
|
+
resource_type || :collection
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
storage.create_collection(col_path.to_s, type: type, displayname: displayname, description: description, color: color)
|
|
38
|
+
[201, {}, ['']]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
test do
|
|
46
|
+
describe "Async::Caldav::Handlers::Mkcol" do
|
|
47
|
+
def call(**opts)
|
|
48
|
+
Async::Caldav::Handlers::Mkcol.call(**opts)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def path(p, s)
|
|
52
|
+
Protocol::Caldav::Path.new(p, storage_class: s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "creates a calendar and returns 201" do
|
|
56
|
+
s = Async::Caldav::Storage::Mock.new
|
|
57
|
+
s.create_collection('/calendars/admin/')
|
|
58
|
+
status, = call(
|
|
59
|
+
path: path('/calendars/admin/work', s), storage: s,
|
|
60
|
+
body: '<d:displayname>Work</d:displayname>', method: 'MKCALENDAR'
|
|
61
|
+
)
|
|
62
|
+
status.should.equal 201
|
|
63
|
+
col = s.get_collection('/calendars/admin/work/')
|
|
64
|
+
col[:type].should.equal :calendar
|
|
65
|
+
col[:displayname].should.equal 'Work'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "creates an addressbook via MKCOL" do
|
|
69
|
+
s = Async::Caldav::Storage::Mock.new
|
|
70
|
+
s.create_collection('/addressbooks/admin/')
|
|
71
|
+
status, = call(
|
|
72
|
+
path: path('/addressbooks/admin/contacts', s), storage: s,
|
|
73
|
+
body: '<resourcetype><addressbook/></resourcetype>', method: 'MKCOL'
|
|
74
|
+
)
|
|
75
|
+
status.should.equal 201
|
|
76
|
+
s.get_collection('/addressbooks/admin/contacts/')[:type].should.equal :addressbook
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "returns 405 if collection exists" do
|
|
80
|
+
s = Async::Caldav::Storage::Mock.new
|
|
81
|
+
s.create_collection('/cal/')
|
|
82
|
+
status, = call(path: path('/cal/', s), storage: s, body: '', method: 'MKCALENDAR')
|
|
83
|
+
status.should.equal 405
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "returns 409 if parent does not exist" do
|
|
87
|
+
s = Async::Caldav::Storage::Mock.new
|
|
88
|
+
status, = call(
|
|
89
|
+
path: path('/calendars/admin/deep/nested/cal', s), storage: s,
|
|
90
|
+
body: '', method: 'MKCALENDAR'
|
|
91
|
+
)
|
|
92
|
+
status.should.equal 409
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
require "async/caldav"
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
module Async
|
|
9
|
+
module Caldav
|
|
10
|
+
module Handlers
|
|
11
|
+
module Move
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(path:, storage:, headers: {}, **)
|
|
15
|
+
destination = headers['destination']
|
|
16
|
+
return [400, { 'content-type' => 'text/plain' }, ['Missing Destination header']] unless destination
|
|
17
|
+
|
|
18
|
+
to_path = URI.parse(destination).path
|
|
19
|
+
overwrite = headers['overwrite'] != 'F'
|
|
20
|
+
|
|
21
|
+
source = storage.get_item(path.to_s)
|
|
22
|
+
return [404, { 'content-type' => 'text/plain' }, ['Not Found']] unless source
|
|
23
|
+
|
|
24
|
+
existing = storage.get_item(to_path)
|
|
25
|
+
|
|
26
|
+
if existing && !overwrite
|
|
27
|
+
return [412, { 'content-type' => 'text/plain' }, ['Precondition Failed']]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# UID conflict check when destination doesn't already exist
|
|
31
|
+
if !existing
|
|
32
|
+
uid = extract_uid(source[:body])
|
|
33
|
+
if uid
|
|
34
|
+
dest_col = Protocol::Caldav::Path.new(to_path).parent.to_s
|
|
35
|
+
items = storage.list_items(dest_col)
|
|
36
|
+
items.each do |item_path, item_data|
|
|
37
|
+
next if item_path == path.to_s
|
|
38
|
+
if extract_uid(item_data[:body]) == uid
|
|
39
|
+
return [409, { 'content-type' => 'text/plain' }, ['UID conflict']]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
storage.move_item(path.to_s, to_path)
|
|
46
|
+
|
|
47
|
+
if existing
|
|
48
|
+
[204, {}, ['']]
|
|
49
|
+
else
|
|
50
|
+
[201, {}, ['']]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def extract_uid(body)
|
|
55
|
+
return nil unless body
|
|
56
|
+
match = body.match(/^UID:(.+)/i)
|
|
57
|
+
match ? match[1].strip : nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private_class_method :extract_uid
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
test do
|
|
67
|
+
describe "Async::Caldav::Handlers::Move" do
|
|
68
|
+
def call(**opts)
|
|
69
|
+
Async::Caldav::Handlers::Move.call(**opts)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def path(p, s)
|
|
73
|
+
Protocol::Caldav::Path.new(p, storage_class: s)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "moves an item and returns 201" do
|
|
77
|
+
s = Async::Caldav::Storage::Mock.new
|
|
78
|
+
s.create_collection('/cal/')
|
|
79
|
+
s.put_item('/cal/a.ics', 'BEGIN:VCALENDAR', 'text/calendar')
|
|
80
|
+
status, = call(
|
|
81
|
+
path: path('/cal/a.ics', s), storage: s,
|
|
82
|
+
headers: { 'destination' => 'http://localhost/cal/b.ics' }
|
|
83
|
+
)
|
|
84
|
+
status.should.equal 201
|
|
85
|
+
s.get_item('/cal/a.ics').should.be.nil
|
|
86
|
+
s.get_item('/cal/b.ics').should.not.be.nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "overwrites and returns 204" do
|
|
90
|
+
s = Async::Caldav::Storage::Mock.new
|
|
91
|
+
s.put_item('/cal/a.ics', 'A', 'text/calendar')
|
|
92
|
+
s.put_item('/cal/b.ics', 'B', 'text/calendar')
|
|
93
|
+
status, = call(
|
|
94
|
+
path: path('/cal/a.ics', s), storage: s,
|
|
95
|
+
headers: { 'destination' => 'http://localhost/cal/b.ics' }
|
|
96
|
+
)
|
|
97
|
+
status.should.equal 204
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "returns 400 without Destination header" do
|
|
101
|
+
s = Async::Caldav::Storage::Mock.new
|
|
102
|
+
status, = call(path: path('/cal/a.ics', s), storage: s, headers: {})
|
|
103
|
+
status.should.equal 400
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "returns 404 when source missing" do
|
|
107
|
+
s = Async::Caldav::Storage::Mock.new
|
|
108
|
+
status, = call(
|
|
109
|
+
path: path('/cal/nope.ics', s), storage: s,
|
|
110
|
+
headers: { 'destination' => 'http://localhost/cal/b.ics' }
|
|
111
|
+
)
|
|
112
|
+
status.should.equal 404
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "returns 412 when Overwrite=F and destination exists" do
|
|
116
|
+
s = Async::Caldav::Storage::Mock.new
|
|
117
|
+
s.put_item('/cal/a.ics', 'A', 'text/calendar')
|
|
118
|
+
s.put_item('/cal/b.ics', 'B', 'text/calendar')
|
|
119
|
+
status, = call(
|
|
120
|
+
path: path('/cal/a.ics', s), storage: s,
|
|
121
|
+
headers: { 'destination' => 'http://localhost/cal/b.ics', 'overwrite' => 'F' }
|
|
122
|
+
)
|
|
123
|
+
status.should.equal 412
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
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 Options
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(path:, storage:, **)
|
|
14
|
+
[200, Protocol::Caldav::Constants::DAV_HEADERS.merge('content-length' => '0'), []]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
test do
|
|
22
|
+
describe "Async::Caldav::Handlers::Options" do
|
|
23
|
+
it "returns 200 with DAV headers" do
|
|
24
|
+
status, headers, = Async::Caldav::Handlers::Options.call(
|
|
25
|
+
path: Protocol::Caldav::Path.new("/calendars/admin/cal/"),
|
|
26
|
+
storage: Async::Caldav::Storage::Mock.new
|
|
27
|
+
)
|
|
28
|
+
status.should.equal 200
|
|
29
|
+
headers['dav'].should.include 'calendar-access'
|
|
30
|
+
headers['allow'].should.include 'PROPFIND'
|
|
31
|
+
headers['content-length'].should.equal '0'
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
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 Propfind
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(path:, storage:, user:, headers: {}, body: nil, **)
|
|
14
|
+
depth = headers['depth'] || '1'
|
|
15
|
+
propname = body&.include?('propname')
|
|
16
|
+
|
|
17
|
+
col_path = path.ensure_trailing_slash
|
|
18
|
+
collection = storage.get_collection(col_path.to_s)
|
|
19
|
+
item_data = storage.get_item(path.to_s)
|
|
20
|
+
|
|
21
|
+
# Non-existent deep path
|
|
22
|
+
if !collection && !item_data && path.depth > 2
|
|
23
|
+
return [404, { 'content-type' => 'text/plain' }, ['Not Found']]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
responses = []
|
|
27
|
+
|
|
28
|
+
if collection
|
|
29
|
+
col = Protocol::Caldav::Collection.new(
|
|
30
|
+
path: col_path,
|
|
31
|
+
type: collection[:type],
|
|
32
|
+
displayname: collection[:displayname],
|
|
33
|
+
description: collection[:description],
|
|
34
|
+
color: collection[:color],
|
|
35
|
+
props: collection[:props]
|
|
36
|
+
)
|
|
37
|
+
responses << (propname ? col.to_propname_xml : col.to_propfind_xml)
|
|
38
|
+
|
|
39
|
+
if depth != '0'
|
|
40
|
+
# Child collections
|
|
41
|
+
storage.list_collections(col_path.to_s).each do |child_path, child_data|
|
|
42
|
+
child_p = Protocol::Caldav::Path.new(child_path, storage_class: storage)
|
|
43
|
+
child_col = Protocol::Caldav::Collection.new(
|
|
44
|
+
path: child_p,
|
|
45
|
+
type: child_data[:type],
|
|
46
|
+
displayname: child_data[:displayname],
|
|
47
|
+
description: child_data[:description],
|
|
48
|
+
color: child_data[:color],
|
|
49
|
+
props: child_data[:props]
|
|
50
|
+
)
|
|
51
|
+
responses << (propname ? child_col.to_propname_xml : child_col.to_propfind_xml)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Child items
|
|
55
|
+
storage.list_items(col_path.to_s).each do |item_path, data|
|
|
56
|
+
item_p = Protocol::Caldav::Path.new(item_path, storage_class: storage)
|
|
57
|
+
item = Protocol::Caldav::Item.new(
|
|
58
|
+
path: item_p,
|
|
59
|
+
body: data[:body],
|
|
60
|
+
content_type: data[:content_type],
|
|
61
|
+
etag: data[:etag]
|
|
62
|
+
)
|
|
63
|
+
responses << (propname ? item.to_propname_xml : item.to_propfind_xml)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
elsif item_data
|
|
67
|
+
item = Protocol::Caldav::Item.new(
|
|
68
|
+
path: path,
|
|
69
|
+
body: item_data[:body],
|
|
70
|
+
content_type: item_data[:content_type],
|
|
71
|
+
etag: item_data[:etag]
|
|
72
|
+
)
|
|
73
|
+
responses << (propname ? item.to_propname_xml : item.to_propfind_xml)
|
|
74
|
+
else
|
|
75
|
+
# Shallow path with no collection — return basic discovery info
|
|
76
|
+
responses << build_discovery_xml(path, user)
|
|
77
|
+
|
|
78
|
+
# Still list child collections/items for depth=1
|
|
79
|
+
if depth != '0'
|
|
80
|
+
storage.list_collections(col_path.to_s).each do |child_path, child_data|
|
|
81
|
+
child_p = Protocol::Caldav::Path.new(child_path, storage_class: storage)
|
|
82
|
+
child_col = Protocol::Caldav::Collection.new(
|
|
83
|
+
path: child_p,
|
|
84
|
+
type: child_data[:type],
|
|
85
|
+
displayname: child_data[:displayname],
|
|
86
|
+
description: child_data[:description],
|
|
87
|
+
color: child_data[:color],
|
|
88
|
+
props: child_data[:props]
|
|
89
|
+
)
|
|
90
|
+
responses << (propname ? child_col.to_propname_xml : child_col.to_propfind_xml)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
xml = Protocol::Caldav::Multistatus.new(responses).to_xml
|
|
96
|
+
[207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_discovery_xml(path, user)
|
|
100
|
+
<<~XML
|
|
101
|
+
<d:response>
|
|
102
|
+
<d:href>#{Protocol::Caldav::Xml.escape(path.to_s)}</d:href>
|
|
103
|
+
<d:propstat>
|
|
104
|
+
<d:prop>
|
|
105
|
+
<d:resourcetype><d:collection/></d:resourcetype>
|
|
106
|
+
<d:current-user-principal><d:href>/#{user}/</d:href></d:current-user-principal>
|
|
107
|
+
<c:calendar-home-set><d:href>/calendars/#{user}/</d:href></c:calendar-home-set>
|
|
108
|
+
<cr:addressbook-home-set><d:href>/addressbooks/#{user}/</d:href></cr:addressbook-home-set>
|
|
109
|
+
</d:prop>
|
|
110
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
111
|
+
</d:propstat>
|
|
112
|
+
</d:response>
|
|
113
|
+
XML
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private_class_method :build_discovery_xml
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
test do
|
|
123
|
+
def normalize(xml)
|
|
124
|
+
xml.gsub(/>\s+</, '><').strip
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
describe "Async::Caldav::Handlers::Propfind" do
|
|
128
|
+
def call(**opts)
|
|
129
|
+
Async::Caldav::Handlers::Propfind.call(**opts)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def path(p, s)
|
|
133
|
+
Protocol::Caldav::Path.new(p, storage_class: s)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "returns 207 with collection properties" do
|
|
137
|
+
s = Async::Caldav::Storage::Mock.new
|
|
138
|
+
s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
|
|
139
|
+
status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin', headers: { 'depth' => '0' })
|
|
140
|
+
status.should.equal 207
|
|
141
|
+
body[0].should.include 'Work'
|
|
142
|
+
body[0].should.include 'calendar'
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "depth=1 includes child items" do
|
|
146
|
+
s = Async::Caldav::Storage::Mock.new
|
|
147
|
+
s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
|
|
148
|
+
s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
|
|
149
|
+
_, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin', headers: { 'depth' => '1' })
|
|
150
|
+
body[0].should.include 'ev.ics'
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "returns 404 for deep non-existent path" do
|
|
154
|
+
s = Async::Caldav::Storage::Mock.new
|
|
155
|
+
status, = call(path: path('/calendars/admin/nope/deep/', s), storage: s, user: 'admin')
|
|
156
|
+
status.should.equal 404
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "returns discovery info for shallow path" do
|
|
160
|
+
s = Async::Caldav::Storage::Mock.new
|
|
161
|
+
status, _, body = call(path: path('/', s), storage: s, user: 'admin')
|
|
162
|
+
status.should.equal 207
|
|
163
|
+
body[0].should.include 'current-user-principal'
|
|
164
|
+
body[0].should.include '/admin/'
|
|
165
|
+
body[0].should.include 'calendar-home-set'
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "propname request returns property names without values" do
|
|
169
|
+
s = Async::Caldav::Storage::Mock.new
|
|
170
|
+
s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
|
|
171
|
+
s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
|
|
172
|
+
propname_body = '<d:propfind xmlns:d="DAV:"><d:propname/></d:propfind>'
|
|
173
|
+
status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin',
|
|
174
|
+
headers: { 'depth' => '1' }, body: propname_body)
|
|
175
|
+
status.should.equal 207
|
|
176
|
+
body[0].should.include '<d:resourcetype/>'
|
|
177
|
+
body[0].should.include '<d:getetag/>'
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it "allprop request returns all properties with values" do
|
|
181
|
+
s = Async::Caldav::Storage::Mock.new
|
|
182
|
+
s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
|
|
183
|
+
s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
|
|
184
|
+
allprop_body = '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>'
|
|
185
|
+
status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin',
|
|
186
|
+
headers: { 'depth' => '1' }, body: allprop_body)
|
|
187
|
+
status.should.equal 207
|
|
188
|
+
body[0].should.include 'displayname'
|
|
189
|
+
body[0].should.include 'Work'
|
|
190
|
+
body[0].should.include 'getetag'
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "returns item propfind for a single item" do
|
|
194
|
+
s = Async::Caldav::Storage::Mock.new
|
|
195
|
+
s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
|
|
196
|
+
status, _, body = call(path: path('/calendars/admin/work/ev.ics', s), storage: s, user: 'admin')
|
|
197
|
+
status.should.equal 207
|
|
198
|
+
body[0].should.include 'getetag'
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|