rack_dav_sp 0.2.dev
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.
- data/.gitignore +4 -0
- data/CHANGELOG.md +27 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +31 -0
- data/LICENSE +18 -0
- data/README.md +107 -0
- data/Rakefile +36 -0
- data/bin/rack_dav +23 -0
- data/lib/rack_dav.rb +14 -0
- data/lib/rack_dav/builder_namespace.rb +21 -0
- data/lib/rack_dav/controller.rb +495 -0
- data/lib/rack_dav/file_resource.rb +168 -0
- data/lib/rack_dav/handler.rb +41 -0
- data/lib/rack_dav/http_status.rb +108 -0
- data/lib/rack_dav/resource.rb +185 -0
- data/lib/rack_dav/version.rb +16 -0
- data/rack_dav.gemspec +23 -0
- data/spec/controller_spec.rb +441 -0
- data/spec/file_resource_spec.rb +11 -0
- data/spec/fixtures/folder/01.txt +0 -0
- data/spec/fixtures/folder/02.txt +0 -0
- data/spec/fixtures/requests/lock.xml +12 -0
- data/spec/handler_spec.rb +37 -0
- data/spec/spec_helper.rb +27 -0
- data/spec/support/lockable_file_resource.rb +30 -0
- metadata +104 -0
@@ -0,0 +1,16 @@
|
|
1
|
+
module RackDAV
|
2
|
+
|
3
|
+
# Holds information about library version.
|
4
|
+
module Version
|
5
|
+
MAJOR = 0
|
6
|
+
MINOR = 2
|
7
|
+
PATCH = "dev"
|
8
|
+
BUILD = nil
|
9
|
+
|
10
|
+
STRING = [MAJOR, MINOR, PATCH, BUILD].compact.join(".")
|
11
|
+
end
|
12
|
+
|
13
|
+
# The current library version.
|
14
|
+
VERSION = Version::STRING
|
15
|
+
|
16
|
+
end
|
data/rack_dav.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "rack_dav/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "rack_dav_sp"
|
7
|
+
s.version = RackDAV::VERSION
|
8
|
+
s.author = "Matthias Georgi"
|
9
|
+
s.email = "matti.georgi@gmail.com"
|
10
|
+
s.homepage = "http://www.matthias-georgi.de/rack_dav"
|
11
|
+
s.summary = "WebDAV handler for Rack."
|
12
|
+
s.description = "WebDAV handler for Rack."
|
13
|
+
|
14
|
+
s.files = `git ls-files`.split("\n")
|
15
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
16
|
+
s.require_paths = ["lib"]
|
17
|
+
|
18
|
+
s.extra_rdoc_files = ["README.md"]
|
19
|
+
|
20
|
+
s.add_dependency("rack", ">= 1.2.0")
|
21
|
+
s.add_dependency("builder")
|
22
|
+
s.add_development_dependency("rspec", "~> 2.6.0")
|
23
|
+
end
|
@@ -0,0 +1,441 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
require 'rack/mock'
|
5
|
+
|
6
|
+
require 'spec/support/lockable_file_resource'
|
7
|
+
|
8
|
+
class Rack::MockResponse
|
9
|
+
|
10
|
+
attr_reader :original_response
|
11
|
+
|
12
|
+
def initialize_with_original(*args)
|
13
|
+
status, headers, @original_response = *args
|
14
|
+
initialize_without_original(*args)
|
15
|
+
end
|
16
|
+
|
17
|
+
alias_method :initialize_without_original, :initialize
|
18
|
+
alias_method :initialize, :initialize_with_original
|
19
|
+
end
|
20
|
+
|
21
|
+
describe RackDAV::Handler do
|
22
|
+
|
23
|
+
DOC_ROOT = File.expand_path(File.dirname(__FILE__) + '/htdocs')
|
24
|
+
METHODS = %w(GET PUT POST DELETE PROPFIND PROPPATCH MKCOL COPY MOVE OPTIONS HEAD LOCK UNLOCK)
|
25
|
+
CLASS_2 = METHODS
|
26
|
+
CLASS_1 = CLASS_2 - %w(LOCK UNLOCK)
|
27
|
+
|
28
|
+
before do
|
29
|
+
FileUtils.mkdir(DOC_ROOT) unless File.exists?(DOC_ROOT)
|
30
|
+
end
|
31
|
+
|
32
|
+
after do
|
33
|
+
FileUtils.rm_rf(DOC_ROOT) if File.exists?(DOC_ROOT)
|
34
|
+
end
|
35
|
+
|
36
|
+
attr_reader :response
|
37
|
+
|
38
|
+
context "Given a Lockable resource" do
|
39
|
+
before do
|
40
|
+
@controller = RackDAV::Handler.new(
|
41
|
+
:root => DOC_ROOT,
|
42
|
+
:resource_class => RackDAV::LockableFileResource
|
43
|
+
)
|
44
|
+
end
|
45
|
+
|
46
|
+
describe "OPTIONS" do
|
47
|
+
it "is successful" do
|
48
|
+
options('/').should be_ok
|
49
|
+
end
|
50
|
+
|
51
|
+
it "sets the allow header with class 2 methods" do
|
52
|
+
options('/')
|
53
|
+
CLASS_2.each do |method|
|
54
|
+
response.headers['allow'].should include(method)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
describe "LOCK" do
|
60
|
+
before(:each) do
|
61
|
+
put("/test", :input => "body").should be_ok
|
62
|
+
lock("/test", :input => File.read(fixture("requests/lock.xml")))
|
63
|
+
end
|
64
|
+
|
65
|
+
describe "creation" do
|
66
|
+
it "succeeds" do
|
67
|
+
response.should be_ok
|
68
|
+
end
|
69
|
+
|
70
|
+
it "sets a compliant rack response" do
|
71
|
+
body = response.original_response.body
|
72
|
+
body.should be_a(Array)
|
73
|
+
body.should have(1).part
|
74
|
+
end
|
75
|
+
|
76
|
+
it "prints the lockdiscovery" do
|
77
|
+
lockdiscovery_response response_locktoken
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
describe "refreshing" do
|
82
|
+
context "a valid locktoken" do
|
83
|
+
it "prints the lockdiscovery" do
|
84
|
+
token = response_locktoken
|
85
|
+
lock("/test", 'HTTP_IF' => "(#{token})").should be_ok
|
86
|
+
lockdiscovery_response token
|
87
|
+
end
|
88
|
+
|
89
|
+
it "accepts it without parenthesis" do
|
90
|
+
token = response_locktoken
|
91
|
+
lock("/test", 'HTTP_IF' => token).should be_ok
|
92
|
+
lockdiscovery_response token
|
93
|
+
end
|
94
|
+
|
95
|
+
it "accepts it with excess angular braces (office 2003)" do
|
96
|
+
token = response_locktoken
|
97
|
+
lock("/test", 'HTTP_IF' => "(<#{token}>)").should be_ok
|
98
|
+
lockdiscovery_response token
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
context "an invalid locktoken" do
|
103
|
+
it "bails out" do
|
104
|
+
lock("/test", 'HTTP_IF' => '123')
|
105
|
+
response.should be_forbidden
|
106
|
+
response.body.should be_empty
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
context "no locktoken" do
|
111
|
+
it "bails out" do
|
112
|
+
lock("/test")
|
113
|
+
response.should be_bad_request
|
114
|
+
response.body.should be_empty
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
describe "UNLOCK" do
|
122
|
+
before(:each) do
|
123
|
+
put("/test", :input => "body").should be_ok
|
124
|
+
lock("/test", :input => File.read(fixture("requests/lock.xml"))).should be_ok
|
125
|
+
end
|
126
|
+
|
127
|
+
context "given a valid token" do
|
128
|
+
before(:each) do
|
129
|
+
token = response_locktoken
|
130
|
+
unlock("/test", 'HTTP_LOCK_TOKEN' => "(#{token})")
|
131
|
+
end
|
132
|
+
|
133
|
+
it "unlocks the resource" do
|
134
|
+
response.should be_no_content
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
context "given an invalid token" do
|
139
|
+
before(:each) do
|
140
|
+
unlock("/test", 'HTTP_LOCK_TOKEN' => '(123)')
|
141
|
+
end
|
142
|
+
|
143
|
+
it "bails out" do
|
144
|
+
response.should be_forbidden
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
context "given no token" do
|
149
|
+
before(:each) do
|
150
|
+
unlock("/test")
|
151
|
+
end
|
152
|
+
|
153
|
+
it "bails out" do
|
154
|
+
response.should be_bad_request
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
context "Given a not lockable resource" do
|
162
|
+
before do
|
163
|
+
@controller = RackDAV::Handler.new(
|
164
|
+
:root => DOC_ROOT,
|
165
|
+
:resource_class => RackDAV::FileResource
|
166
|
+
)
|
167
|
+
end
|
168
|
+
|
169
|
+
describe "OPTIONS" do
|
170
|
+
it "is successful" do
|
171
|
+
options('/').should be_ok
|
172
|
+
end
|
173
|
+
|
174
|
+
it "sets the allow header with class 2 methods" do
|
175
|
+
options('/')
|
176
|
+
CLASS_1.each do |method|
|
177
|
+
response.headers['allow'].should include(method)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
it 'should return headers' do
|
183
|
+
put('/test.html', :input => '<html/>').should be_ok
|
184
|
+
head('/test.html').should be_ok
|
185
|
+
|
186
|
+
response.headers['etag'].should_not be_nil
|
187
|
+
response.headers['content-type'].should match(/html/)
|
188
|
+
response.headers['last-modified'].should_not be_nil
|
189
|
+
end
|
190
|
+
|
191
|
+
it 'should not find a nonexistent resource' do
|
192
|
+
get('/not_found').should be_not_found
|
193
|
+
end
|
194
|
+
|
195
|
+
it 'should not allow directory traversal' do
|
196
|
+
get('/../htdocs').should be_forbidden
|
197
|
+
end
|
198
|
+
|
199
|
+
it 'should create a resource and allow its retrieval' do
|
200
|
+
put('/test', :input => 'body').should be_ok
|
201
|
+
get('/test').should be_ok
|
202
|
+
response.body.should == 'body'
|
203
|
+
end
|
204
|
+
it 'should create and find a url with escaped characters' do
|
205
|
+
put(url_escape('/a b'), :input => 'body').should be_ok
|
206
|
+
get(url_escape('/a b')).should be_ok
|
207
|
+
response.body.should == 'body'
|
208
|
+
end
|
209
|
+
|
210
|
+
it 'should delete a single resource' do
|
211
|
+
put('/test', :input => 'body').should be_ok
|
212
|
+
delete('/test').should be_no_content
|
213
|
+
end
|
214
|
+
|
215
|
+
it 'should delete recursively' do
|
216
|
+
mkcol('/folder').should be_created
|
217
|
+
put('/folder/a', :input => 'body').should be_ok
|
218
|
+
put('/folder/b', :input => 'body').should be_ok
|
219
|
+
|
220
|
+
delete('/folder').should be_no_content
|
221
|
+
get('/folder').should be_not_found
|
222
|
+
get('/folder/a').should be_not_found
|
223
|
+
get('/folder/b').should be_not_found
|
224
|
+
end
|
225
|
+
|
226
|
+
it 'should not allow copy to another domain' do
|
227
|
+
put('/test', :input => 'body').should be_ok
|
228
|
+
copy('http://localhost/', 'HTTP_DESTINATION' => 'http://another/').should be_bad_gateway
|
229
|
+
end
|
230
|
+
|
231
|
+
it 'should not allow copy to the same resource' do
|
232
|
+
put('/test', :input => 'body').should be_ok
|
233
|
+
copy('/test', 'HTTP_DESTINATION' => '/test').should be_forbidden
|
234
|
+
end
|
235
|
+
|
236
|
+
it 'should not allow an invalid destination uri' do
|
237
|
+
put('/test', :input => 'body').should be_ok
|
238
|
+
copy('/test', 'HTTP_DESTINATION' => '%').should be_bad_request
|
239
|
+
end
|
240
|
+
|
241
|
+
it 'should copy a single resource' do
|
242
|
+
put('/test', :input => 'body').should be_ok
|
243
|
+
copy('/test', 'HTTP_DESTINATION' => '/copy').should be_created
|
244
|
+
get('/copy').body.should == 'body'
|
245
|
+
end
|
246
|
+
|
247
|
+
it 'should copy a resource with escaped characters' do
|
248
|
+
put(url_escape('/a b'), :input => 'body').should be_ok
|
249
|
+
copy(url_escape('/a b'), 'HTTP_DESTINATION' => url_escape('/a c')).should be_created
|
250
|
+
get(url_escape('/a c')).should be_ok
|
251
|
+
response.body.should == 'body'
|
252
|
+
end
|
253
|
+
|
254
|
+
it 'should deny a copy without overwrite' do
|
255
|
+
put('/test', :input => 'body').should be_ok
|
256
|
+
put('/copy', :input => 'copy').should be_ok
|
257
|
+
copy('/test', 'HTTP_DESTINATION' => '/copy', 'HTTP_OVERWRITE' => 'F')
|
258
|
+
|
259
|
+
multistatus_response('/href').first.text.should == 'http://localhost/test'
|
260
|
+
multistatus_response('/status').first.text.should match(/412 Precondition Failed/)
|
261
|
+
|
262
|
+
get('/copy').body.should == 'copy'
|
263
|
+
end
|
264
|
+
|
265
|
+
it 'should allow a copy with overwrite' do
|
266
|
+
put('/test', :input => 'body').should be_ok
|
267
|
+
put('/copy', :input => 'copy').should be_ok
|
268
|
+
copy('/test', 'HTTP_DESTINATION' => '/copy', 'HTTP_OVERWRITE' => 'T').should be_no_content
|
269
|
+
get('/copy').body.should == 'body'
|
270
|
+
end
|
271
|
+
|
272
|
+
it 'should copy a collection' do
|
273
|
+
mkcol('/folder').should be_created
|
274
|
+
copy('/folder', 'HTTP_DESTINATION' => '/copy').should be_created
|
275
|
+
propfind('/copy', :input => propfind_xml(:resourcetype))
|
276
|
+
multistatus_response('/propstat/prop/resourcetype/collection').should_not be_empty
|
277
|
+
end
|
278
|
+
|
279
|
+
it 'should copy a collection resursively' do
|
280
|
+
mkcol('/folder').should be_created
|
281
|
+
put('/folder/a', :input => 'A').should be_ok
|
282
|
+
put('/folder/b', :input => 'B').should be_ok
|
283
|
+
|
284
|
+
copy('/folder', 'HTTP_DESTINATION' => '/copy').should be_created
|
285
|
+
propfind('/copy', :input => propfind_xml(:resourcetype))
|
286
|
+
multistatus_response('/propstat/prop/resourcetype/collection').should_not be_empty
|
287
|
+
|
288
|
+
get('/copy/a').body.should == 'A'
|
289
|
+
get('/copy/b').body.should == 'B'
|
290
|
+
end
|
291
|
+
|
292
|
+
it 'should move a collection recursively' do
|
293
|
+
mkcol('/folder').should be_created
|
294
|
+
put('/folder/a', :input => 'A').should be_ok
|
295
|
+
put('/folder/b', :input => 'B').should be_ok
|
296
|
+
|
297
|
+
move('/folder', 'HTTP_DESTINATION' => '/move').should be_created
|
298
|
+
propfind('/move', :input => propfind_xml(:resourcetype))
|
299
|
+
multistatus_response('/propstat/prop/resourcetype/collection').should_not be_empty
|
300
|
+
|
301
|
+
get('/move/a').body.should == 'A'
|
302
|
+
get('/move/b').body.should == 'B'
|
303
|
+
get('/folder/a').should be_not_found
|
304
|
+
get('/folder/b').should be_not_found
|
305
|
+
end
|
306
|
+
|
307
|
+
it 'should create a collection' do
|
308
|
+
mkcol('/folder').should be_created
|
309
|
+
propfind('/folder', :input => propfind_xml(:resourcetype))
|
310
|
+
multistatus_response('/propstat/prop/resourcetype/collection').should_not be_empty
|
311
|
+
end
|
312
|
+
|
313
|
+
it 'should not find properties for nonexistent resources' do
|
314
|
+
propfind('/non').should be_not_found
|
315
|
+
end
|
316
|
+
|
317
|
+
it 'should find all properties' do
|
318
|
+
xml = render do |xml|
|
319
|
+
xml.propfind('xmlns:d' => "DAV:") do
|
320
|
+
xml.allprop
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
propfind('http://localhost/', :input => xml)
|
325
|
+
|
326
|
+
multistatus_response('/href').first.text.strip.should == 'http://localhost/'
|
327
|
+
|
328
|
+
props = %w(creationdate displayname getlastmodified getetag resourcetype getcontenttype getcontentlength)
|
329
|
+
props.each do |prop|
|
330
|
+
multistatus_response('/propstat/prop/' + prop).should_not be_empty
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
it 'should find named properties' do
|
335
|
+
put('/test.html', :input => '<html/>').should be_ok
|
336
|
+
propfind('/test.html', :input => propfind_xml(:getcontenttype, :getcontentlength))
|
337
|
+
|
338
|
+
multistatus_response('/propstat/prop/getcontenttype').first.text.should == 'text/html'
|
339
|
+
multistatus_response('/propstat/prop/getcontentlength').first.text.should == '7'
|
340
|
+
end
|
341
|
+
|
342
|
+
it 'should not support LOCK' do
|
343
|
+
put('/test', :input => 'body').should be_ok
|
344
|
+
|
345
|
+
xml = render do |xml|
|
346
|
+
xml.lockinfo('xmlns:d' => "DAV:") do
|
347
|
+
xml.lockscope { xml.exclusive }
|
348
|
+
xml.locktype { xml.write }
|
349
|
+
xml.owner { xml.href "http://test.de/" }
|
350
|
+
end
|
351
|
+
end
|
352
|
+
|
353
|
+
lock('/test', :input => xml).should be_method_not_allowed
|
354
|
+
end
|
355
|
+
|
356
|
+
it 'should not support UNLOCK' do
|
357
|
+
put('/test', :input => 'body').should be_ok
|
358
|
+
unlock('/test', :input => '').should be_method_not_allowed
|
359
|
+
end
|
360
|
+
|
361
|
+
end
|
362
|
+
|
363
|
+
|
364
|
+
private
|
365
|
+
|
366
|
+
def request(method, uri, options={})
|
367
|
+
options = {
|
368
|
+
'HTTP_HOST' => 'localhost',
|
369
|
+
'REMOTE_USER' => 'manni'
|
370
|
+
}.merge(options)
|
371
|
+
request = Rack::MockRequest.new(@controller)
|
372
|
+
@response = request.request(method, uri, options)
|
373
|
+
end
|
374
|
+
|
375
|
+
METHODS.each do |method|
|
376
|
+
define_method(method.downcase) do |*args|
|
377
|
+
request(method, *args)
|
378
|
+
end
|
379
|
+
end
|
380
|
+
|
381
|
+
|
382
|
+
def render
|
383
|
+
xml = Builder::XmlMarkup.new
|
384
|
+
xml.instruct! :xml, :version => "1.0", :encoding => "UTF-8"
|
385
|
+
xml.namespace('d') do
|
386
|
+
yield xml
|
387
|
+
end
|
388
|
+
xml.target!
|
389
|
+
end
|
390
|
+
|
391
|
+
def url_escape(string)
|
392
|
+
string.gsub(/([^ a-zA-Z0-9_.-]+)/n) do
|
393
|
+
'%' + $1.unpack('H2' * $1.size).join('%').upcase
|
394
|
+
end.tr(' ', '+')
|
395
|
+
end
|
396
|
+
|
397
|
+
def response_xml
|
398
|
+
@response_xml ||= REXML::Document.new(@response.body)
|
399
|
+
end
|
400
|
+
|
401
|
+
def response_locktoken
|
402
|
+
REXML::XPath::match(response_xml,
|
403
|
+
"/prop/lockdiscovery/activelock/locktoken/href", '' => 'DAV:'
|
404
|
+
).first.text
|
405
|
+
end
|
406
|
+
|
407
|
+
def lockdiscovery_response(token)
|
408
|
+
match = lambda do |pattern|
|
409
|
+
REXML::XPath::match(response_xml, "/prop/lockdiscovery/activelock" + pattern, '' => 'DAV:')
|
410
|
+
end
|
411
|
+
|
412
|
+
match[''].should_not be_empty
|
413
|
+
|
414
|
+
match['/locktype'].should_not be_empty
|
415
|
+
match['/lockscope'].should_not be_empty
|
416
|
+
match['/depth'].should_not be_empty
|
417
|
+
match['/owner'].should_not be_empty
|
418
|
+
match['/timeout'].should_not be_empty
|
419
|
+
match['/locktoken/href'].should_not be_empty
|
420
|
+
match['/locktoken/href'].first.text.should == token
|
421
|
+
end
|
422
|
+
|
423
|
+
def multistatus_response(pattern)
|
424
|
+
@response.should be_multi_status
|
425
|
+
REXML::XPath::match(response_xml, "/multistatus/response", '' => 'DAV:').should_not be_empty
|
426
|
+
REXML::XPath::match(response_xml, "/multistatus/response" + pattern, '' => 'DAV:')
|
427
|
+
end
|
428
|
+
|
429
|
+
def propfind_xml(*props)
|
430
|
+
render do |xml|
|
431
|
+
xml.propfind('xmlns:d' => "DAV:") do
|
432
|
+
xml.prop do
|
433
|
+
props.each do |prop|
|
434
|
+
xml.tag! prop
|
435
|
+
end
|
436
|
+
end
|
437
|
+
end
|
438
|
+
end
|
439
|
+
end
|
440
|
+
|
441
|
+
end
|