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,375 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
require "async/caldav"
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'fileutils'
|
|
8
|
+
|
|
9
|
+
module Async
|
|
10
|
+
module Caldav
|
|
11
|
+
module Storage
|
|
12
|
+
class Filesystem < Protocol::Caldav::Storage
|
|
13
|
+
def initialize(root)
|
|
14
|
+
@root = root
|
|
15
|
+
@sync_snapshots = {}
|
|
16
|
+
FileUtils.mkdir_p(@root)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# --- Collections ---
|
|
20
|
+
|
|
21
|
+
def create_collection(path, props = {})
|
|
22
|
+
dir = full_path(path)
|
|
23
|
+
FileUtils.mkdir_p(dir)
|
|
24
|
+
meta = {
|
|
25
|
+
"type" => (props[:type] || :collection).to_s,
|
|
26
|
+
"displayname" => props[:displayname],
|
|
27
|
+
"description" => props[:description],
|
|
28
|
+
"color" => props[:color],
|
|
29
|
+
"props" => props[:props] || {}
|
|
30
|
+
}
|
|
31
|
+
File.write(File.join(dir, ".collection.json"), JSON.pretty_generate(meta))
|
|
32
|
+
symbolize(meta)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get_collection(path)
|
|
36
|
+
meta_file = File.join(full_path(path), ".collection.json")
|
|
37
|
+
return nil unless File.exist?(meta_file)
|
|
38
|
+
symbolize(JSON.parse(File.read(meta_file)))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def delete_collection(path)
|
|
42
|
+
dir = full_path(path)
|
|
43
|
+
if File.directory?(dir)
|
|
44
|
+
FileUtils.rm_rf(dir)
|
|
45
|
+
true
|
|
46
|
+
else
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def list_collections(parent_path)
|
|
52
|
+
dir = full_path(parent_path)
|
|
53
|
+
return [] unless File.directory?(dir)
|
|
54
|
+
|
|
55
|
+
Dir.children(dir).filter_map do |name|
|
|
56
|
+
child_dir = File.join(dir, name)
|
|
57
|
+
meta_file = File.join(child_dir, ".collection.json")
|
|
58
|
+
next unless File.directory?(child_dir) && File.exist?(meta_file)
|
|
59
|
+
|
|
60
|
+
child_path = File.join(parent_path, name).sub(%r{/*$}, "/")
|
|
61
|
+
[child_path, symbolize(JSON.parse(File.read(meta_file)))]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def update_collection(path, props)
|
|
66
|
+
col = get_collection(path)
|
|
67
|
+
return nil unless col
|
|
68
|
+
|
|
69
|
+
col[:displayname] = props[:displayname] if props.key?(:displayname)
|
|
70
|
+
col[:description] = props[:description] if props.key?(:description)
|
|
71
|
+
col[:color] = props[:color] if props.key?(:color)
|
|
72
|
+
col[:props] = (col[:props] || {}).merge(props[:props]) if props.key?(:props)
|
|
73
|
+
|
|
74
|
+
meta = {
|
|
75
|
+
"type" => col[:type].to_s,
|
|
76
|
+
"displayname" => col[:displayname],
|
|
77
|
+
"description" => col[:description],
|
|
78
|
+
"color" => col[:color],
|
|
79
|
+
"props" => col[:props] || {}
|
|
80
|
+
}
|
|
81
|
+
File.write(File.join(full_path(path), ".collection.json"), JSON.pretty_generate(meta))
|
|
82
|
+
col
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def collection_exists?(path)
|
|
86
|
+
File.exist?(File.join(full_path(path), ".collection.json"))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# --- Items ---
|
|
90
|
+
|
|
91
|
+
def get_item(path)
|
|
92
|
+
file = full_path(path)
|
|
93
|
+
return nil unless File.file?(file)
|
|
94
|
+
|
|
95
|
+
body = File.read(file)
|
|
96
|
+
content_type = guess_content_type(path)
|
|
97
|
+
etag = Protocol::Caldav::ETag.compute(body)
|
|
98
|
+
{ body: body, content_type: content_type, etag: etag }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def put_item(path, body, content_type)
|
|
102
|
+
file = full_path(path)
|
|
103
|
+
is_new = !File.exist?(file)
|
|
104
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
105
|
+
File.write(file, body)
|
|
106
|
+
etag = Protocol::Caldav::ETag.compute(body)
|
|
107
|
+
item = { body: body, content_type: content_type, etag: etag }
|
|
108
|
+
[item, is_new]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def delete_item(path)
|
|
112
|
+
file = full_path(path)
|
|
113
|
+
if File.file?(file)
|
|
114
|
+
File.delete(file)
|
|
115
|
+
true
|
|
116
|
+
else
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def list_items(collection_path)
|
|
122
|
+
dir = full_path(collection_path)
|
|
123
|
+
return [] unless File.directory?(dir)
|
|
124
|
+
|
|
125
|
+
Dir.children(dir).filter_map do |name|
|
|
126
|
+
next if name.start_with?(".")
|
|
127
|
+
file = File.join(dir, name)
|
|
128
|
+
next unless File.file?(file)
|
|
129
|
+
|
|
130
|
+
item_path = File.join(collection_path, name)
|
|
131
|
+
body = File.read(file)
|
|
132
|
+
content_type = guess_content_type(name)
|
|
133
|
+
etag = Protocol::Caldav::ETag.compute(body)
|
|
134
|
+
[item_path, { body: body, content_type: content_type, etag: etag }]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def move_item(from_path, to_path)
|
|
139
|
+
src = full_path(from_path)
|
|
140
|
+
dst = full_path(to_path)
|
|
141
|
+
return nil unless File.file?(src)
|
|
142
|
+
|
|
143
|
+
FileUtils.mkdir_p(File.dirname(dst))
|
|
144
|
+
FileUtils.mv(src, dst)
|
|
145
|
+
get_item(to_path)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def get_multi(paths)
|
|
149
|
+
paths.map { |p| [p, get_item(p)] }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# --- General ---
|
|
153
|
+
|
|
154
|
+
def exists?(path)
|
|
155
|
+
fp = full_path(path)
|
|
156
|
+
File.exist?(fp) || collection_exists?(path)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def etag(path)
|
|
160
|
+
item = get_item(path)
|
|
161
|
+
item ? item[:etag] : nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# --- Sync ---
|
|
165
|
+
|
|
166
|
+
def snapshot_sync(collection_path)
|
|
167
|
+
items = list_items(collection_path)
|
|
168
|
+
snapshot = {}
|
|
169
|
+
items.each { |path, data| snapshot[path] = data[:etag] }
|
|
170
|
+
|
|
171
|
+
col = get_collection(collection_path) || {}
|
|
172
|
+
item_etags = items.map { |_, data| data[:etag] }
|
|
173
|
+
ctag = Protocol::Caldav::CTag.compute(
|
|
174
|
+
path: collection_path,
|
|
175
|
+
displayname: col[:displayname],
|
|
176
|
+
description: col[:description],
|
|
177
|
+
color: col[:color],
|
|
178
|
+
item_etags: item_etags
|
|
179
|
+
)
|
|
180
|
+
token = "http://caldav.local/sync/#{ctag}"
|
|
181
|
+
@sync_snapshots[token] = { collection_path => snapshot }
|
|
182
|
+
token
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def sync_changes(collection_path, token)
|
|
186
|
+
old_snapshot_entry = @sync_snapshots[token]
|
|
187
|
+
return nil unless old_snapshot_entry
|
|
188
|
+
|
|
189
|
+
old_snapshot = old_snapshot_entry[collection_path] || {}
|
|
190
|
+
new_token = snapshot_sync(collection_path)
|
|
191
|
+
current_items = list_items(collection_path)
|
|
192
|
+
current = {}
|
|
193
|
+
current_items.each { |path, data| current[path] = data[:etag] }
|
|
194
|
+
|
|
195
|
+
changes = []
|
|
196
|
+
current.each do |path, etag|
|
|
197
|
+
if !old_snapshot.key?(path) || old_snapshot[path] != etag
|
|
198
|
+
changes << [path, :modified]
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
old_snapshot.each_key do |path|
|
|
202
|
+
changes << [path, :deleted] unless current.key?(path)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
[new_token, changes]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def full_path(path)
|
|
211
|
+
File.join(@root, path)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def guess_content_type(path)
|
|
215
|
+
case File.extname(path).downcase
|
|
216
|
+
when ".ics" then "text/calendar"
|
|
217
|
+
when ".vcf" then "text/vcard"
|
|
218
|
+
else "application/octet-stream"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def symbolize(hash)
|
|
223
|
+
{
|
|
224
|
+
type: hash["type"]&.to_sym || :collection,
|
|
225
|
+
displayname: hash["displayname"],
|
|
226
|
+
description: hash["description"],
|
|
227
|
+
color: hash["color"],
|
|
228
|
+
props: hash["props"] || {}
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
require 'tmpdir'
|
|
237
|
+
|
|
238
|
+
test do
|
|
239
|
+
describe "Async::Caldav::Storage::Filesystem" do
|
|
240
|
+
it "creates and retrieves a collection" do
|
|
241
|
+
Dir.mktmpdir do |dir|
|
|
242
|
+
s = Async::Caldav::Storage::Filesystem.new(dir)
|
|
243
|
+
s.create_collection("/cal/", type: :calendar, displayname: "Cal")
|
|
244
|
+
col = s.get_collection("/cal/")
|
|
245
|
+
col[:displayname].should.equal "Cal"
|
|
246
|
+
col[:type].should.equal :calendar
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
it "deletes collection and its items" do
|
|
251
|
+
Dir.mktmpdir do |dir|
|
|
252
|
+
s = Async::Caldav::Storage::Filesystem.new(dir)
|
|
253
|
+
s.create_collection("/cal/")
|
|
254
|
+
s.put_item("/cal/ev.ics", "data", "text/calendar")
|
|
255
|
+
s.delete_collection("/cal/")
|
|
256
|
+
s.get_collection("/cal/").should.be.nil
|
|
257
|
+
s.get_item("/cal/ev.ics").should.be.nil
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it "lists direct child collections" do
|
|
262
|
+
Dir.mktmpdir do |dir|
|
|
263
|
+
s = Async::Caldav::Storage::Filesystem.new(dir)
|
|
264
|
+
s.create_collection("/admin/a/")
|
|
265
|
+
s.create_collection("/admin/b/")
|
|
266
|
+
s.list_collections("/admin/").length.should.equal 2
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "updates collection properties" do
|
|
271
|
+
Dir.mktmpdir do |dir|
|
|
272
|
+
s = Async::Caldav::Storage::Filesystem.new(dir)
|
|
273
|
+
s.create_collection("/cal/", displayname: "Old")
|
|
274
|
+
s.update_collection("/cal/", displayname: "New")
|
|
275
|
+
s.get_collection("/cal/")[:displayname].should.equal "New"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it "puts and retrieves an item with etag" do
|
|
280
|
+
Dir.mktmpdir do |dir|
|
|
281
|
+
s = Async::Caldav::Storage::Filesystem.new(dir)
|
|
282
|
+
item, is_new = s.put_item("/cal/ev.ics", "BEGIN:VCALENDAR", "text/calendar")
|
|
283
|
+
is_new.should.equal true
|
|
284
|
+
item[:etag].should.not.be.nil
|
|
285
|
+
s.get_item("/cal/ev.ics")[:body].should.equal "BEGIN:VCALENDAR"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
it "persists across instances" do
|
|
290
|
+
Dir.mktmpdir do |dir|
|
|
291
|
+
s1 = Async::Caldav::Storage::Filesystem.new(dir)
|
|
292
|
+
s1.create_collection("/cal/", type: :calendar, displayname: "Persist")
|
|
293
|
+
s1.put_item("/cal/ev.ics", "body", "text/calendar")
|
|
294
|
+
|
|
295
|
+
s2 = Async::Caldav::Storage::Filesystem.new(dir)
|
|
296
|
+
s2.get_collection("/cal/")[:displayname].should.equal "Persist"
|
|
297
|
+
s2.get_item("/cal/ev.ics")[:body].should.equal "body"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
it "list_items excludes hidden files" do
|
|
302
|
+
Dir.mktmpdir do |dir|
|
|
303
|
+
s = Async::Caldav::Storage::Filesystem.new(dir)
|
|
304
|
+
s.create_collection("/cal/")
|
|
305
|
+
s.put_item("/cal/ev.ics", "EVENT", "text/calendar")
|
|
306
|
+
items = s.list_items("/cal/")
|
|
307
|
+
items.map(&:first).any? { |p| p.include?(".collection.json") }.should.equal false
|
|
308
|
+
items.map(&:first).any? { |p| p.include?("ev.ics") }.should.equal true
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it "deletes an item" do
|
|
313
|
+
Dir.mktmpdir do |dir|
|
|
314
|
+
s = Async::Caldav::Storage::Filesystem.new(dir)
|
|
315
|
+
s.put_item("/cal/ev.ics", "data", "text/calendar")
|
|
316
|
+
s.delete_item("/cal/ev.ics").should.equal true
|
|
317
|
+
s.get_item("/cal/ev.ics").should.be.nil
|
|
318
|
+
s.delete_item("/cal/ev.ics").should.equal false
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it "moves an item" do
|
|
323
|
+
Dir.mktmpdir do |dir|
|
|
324
|
+
s = Async::Caldav::Storage::Filesystem.new(dir)
|
|
325
|
+
s.put_item("/cal/a.ics", "data", "text/calendar")
|
|
326
|
+
s.move_item("/cal/a.ics", "/cal/b.ics")
|
|
327
|
+
s.get_item("/cal/a.ics").should.be.nil
|
|
328
|
+
s.get_item("/cal/b.ics")[:body].should.equal "data"
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
it "reports existence correctly" do
|
|
333
|
+
Dir.mktmpdir do |dir|
|
|
334
|
+
s = Async::Caldav::Storage::Filesystem.new(dir)
|
|
335
|
+
s.exists?("/nope").should.equal false
|
|
336
|
+
s.create_collection("/col/")
|
|
337
|
+
s.exists?("/col/").should.equal true
|
|
338
|
+
s.put_item("/col/x.ics", "d", "text/calendar")
|
|
339
|
+
s.exists?("/col/x.ics").should.equal true
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
it "get_multi returns items and nils" do
|
|
344
|
+
Dir.mktmpdir do |dir|
|
|
345
|
+
s = Async::Caldav::Storage::Filesystem.new(dir)
|
|
346
|
+
s.put_item("/cal/a.ics", "A", "text/calendar")
|
|
347
|
+
result = s.get_multi(["/cal/a.ics", "/cal/nope.ics"])
|
|
348
|
+
result.length.should.equal 2
|
|
349
|
+
result[0][1][:body].should.equal "A"
|
|
350
|
+
result[1][1].should.be.nil
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it "guesses content types from extension" do
|
|
355
|
+
Dir.mktmpdir do |dir|
|
|
356
|
+
s = Async::Caldav::Storage::Filesystem.new(dir)
|
|
357
|
+
s.put_item("/cal/ev.ics", "cal", "text/calendar")
|
|
358
|
+
s.put_item("/addr/c.vcf", "card", "text/vcard")
|
|
359
|
+
s.get_item("/cal/ev.ics")[:content_type].should.equal "text/calendar"
|
|
360
|
+
s.get_item("/addr/c.vcf")[:content_type].should.equal "text/vcard"
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it "same body produces same etag across backends" do
|
|
365
|
+
body = "BEGIN:VCALENDAR\r\nEND:VCALENDAR"
|
|
366
|
+
mock = Async::Caldav::Storage::Mock.new
|
|
367
|
+
mock.put_item("/a.ics", body, "text/calendar")
|
|
368
|
+
Dir.mktmpdir do |dir|
|
|
369
|
+
fs = Async::Caldav::Storage::Filesystem.new(dir)
|
|
370
|
+
fs.put_item("/a.ics", body, "text/calendar")
|
|
371
|
+
mock.etag("/a.ics").should.equal fs.etag("/a.ics")
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|