dav4rack 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,62 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+
4
+ module DAV4Rack
5
+
6
+ class RemoteFile
7
+
8
+ attr_accessor :path
9
+
10
+ alias :to_path :path
11
+
12
+ # path:: Path to remote file
13
+ # args:: Optional argument hash. Allowed keys: :size, :mime_type, :last_modified
14
+ # Create a reference to a remote file.
15
+ # NOTE: HTTPError will be raised if path does not return 200 result
16
+ def initialize(path, args={})
17
+ @path = path
18
+ @size = args[:size] || nil
19
+ @mime_type = args[:mime_type] || 'text/plain'
20
+ @modified = args[:last_modified] || nil
21
+ @uri = URI.parse(@path)
22
+ @con = Net::HTTP.new(@uri.host, @uri.port)
23
+ @call_path = @uri.path + (@uri.query ? "?#{@uri.query}" : '')
24
+ res = @con.request_get(@call_path)
25
+ @heads = res.to_hash
26
+ res.value
27
+ @store = nil
28
+ end
29
+
30
+ def size
31
+ @heads['content-length'] || @size
32
+ end
33
+
34
+ def content_type
35
+ @mime_type || @heads['content-type']
36
+ end
37
+
38
+ def last_modified
39
+ @heads['last-modified'] || @modified
40
+ end
41
+
42
+ def call(env)
43
+ dup._call(env)
44
+ end
45
+
46
+ def _call(env)
47
+ serving
48
+ end
49
+
50
+ def each
51
+ if(@store)
52
+ yield @store
53
+ else
54
+ @con.request_get(@call_path) do |res|
55
+ res.read_body(@store) do |part|
56
+ yield part
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,250 @@
1
+ module DAV4Rack
2
+
3
+ class LockFailure < RuntimeError
4
+ attr_reader :path_status
5
+ def initialize(*args)
6
+ super(*args)
7
+ @path_status = {}
8
+ end
9
+
10
+ def add_failure(path, status)
11
+ @path_status[path] = status
12
+ end
13
+ end
14
+
15
+ class Resource
16
+ attr_reader :path, :options, :public_path, :request
17
+
18
+ include DAV4Rack::HTTPStatus
19
+
20
+ # public_path:: Path received via request
21
+ # path:: Internal resource path (Only different from public path when using root_uri's for webdav)
22
+ # request:: Rack::Request
23
+ # options:: Any options provided for this resource
24
+ # Creates a new instance of the resource.
25
+ # NOTE: path and public_path will only differ if the root_uri has been set for the resource. The
26
+ # controller will strip out the starting path so the resource can easily determine what
27
+ # it is working on. For example:
28
+ # request -> /my/webdav/directory/actual/path
29
+ # public_path -> /my/webdav/directory/actual/path
30
+ # path -> /actual/path
31
+ def initialize(public_path, path, request, options)
32
+ @public_path = public_path.dup
33
+ @path = path.dup
34
+ @request = request
35
+ @options = options.dup
36
+ end
37
+
38
+ # If this is a collection, return the child resources.
39
+ def children
40
+ raise NotImplementedError
41
+ end
42
+
43
+ # Is this resource a collection?
44
+ def collection?
45
+ raise NotImplementedError
46
+ end
47
+
48
+ # Does this recource exist?
49
+ def exist?
50
+ raise NotImplementedError
51
+ end
52
+
53
+ # Return the creation time.
54
+ def creation_date
55
+ raise NotImplementedError
56
+ end
57
+
58
+ # Return the time of last modification.
59
+ def last_modified
60
+ raise NotImplementedError
61
+ end
62
+
63
+ # Set the time of last modification.
64
+ def last_modified=(time)
65
+ raise NotImplementedError
66
+ end
67
+
68
+ # Return an Etag, an unique hash value for this resource.
69
+ def etag
70
+ raise NotImplementedError
71
+ end
72
+
73
+ # Return the resource type. Generally only used to specify
74
+ # resource is a collection.
75
+ def resource_type
76
+ :collection if collection?
77
+ end
78
+
79
+ # Return the mime type of this resource.
80
+ def content_type
81
+ raise NotImplementedError
82
+ end
83
+
84
+ # Return the size in bytes for this resource.
85
+ def content_length
86
+ raise NotImplementedError
87
+ end
88
+
89
+ # HTTP GET request.
90
+ #
91
+ # Write the content of the resource to the response.body.
92
+ def get(request, response)
93
+ raise NotImplementedError
94
+ end
95
+
96
+ # HTTP PUT request.
97
+ #
98
+ # Save the content of the request.body.
99
+ def put(request, response)
100
+ raise NotImplementedError
101
+ end
102
+
103
+ # HTTP POST request.
104
+ #
105
+ # Usually forbidden.
106
+ def post(request, response)
107
+ raise NotImplementedError
108
+ end
109
+
110
+ # HTTP DELETE request.
111
+ #
112
+ # Delete this resource.
113
+ def delete
114
+ raise NotImplementedError
115
+ end
116
+
117
+ # HTTP COPY request.
118
+ #
119
+ # Copy this resource to given destination resource.
120
+ def copy(dest)
121
+ raise NotImplementedError
122
+ end
123
+
124
+ # HTTP MOVE request.
125
+ #
126
+ # Move this resource to given destination resource.
127
+ def move(dest)
128
+ raise NotImplemented
129
+ end
130
+
131
+ # args:: Hash of lock arguments
132
+ # Request for a lock on the given resource. A valid lock should lock
133
+ # all descendents. Failures should be noted and returned as an exception
134
+ # using LockFailure.
135
+ # Valid args keys: :timeout -> requested timeout
136
+ # :depth -> lock depth
137
+ # :scope -> lock scope
138
+ # :type -> lock type
139
+ # :owner -> lock owner
140
+ # Should return a tuple: [lock_time, locktoken] where lock_time is the
141
+ # given timeout
142
+ # NOTE: See section 9.10 of RFC 4918 for guidance about
143
+ # how locks should be generated and the expected responses
144
+ # (http://www.webdav.org/specs/rfc4918.html#rfc.section.9.10)
145
+ def lock(args)
146
+ raise NotImplemented
147
+ end
148
+
149
+ def unlock(token)
150
+ raise NotImplemented
151
+ end
152
+
153
+ # Create this resource as collection.
154
+ def make_collection
155
+ raise NotImplementedError
156
+ end
157
+
158
+ # other:: Resource
159
+ # Returns if current resource is equal to other resource
160
+ def ==(other)
161
+ path == other.path
162
+ end
163
+
164
+ # Name of the resource
165
+ def name
166
+ File.basename(path)
167
+ end
168
+
169
+ # Name of the resource to be displayed to the client
170
+ def display_name
171
+ name
172
+ end
173
+
174
+ # Available properties
175
+ def property_names
176
+ %w(creationdate displayname getlastmodified getetag resourcetype getcontenttype getcontentlength)
177
+ end
178
+
179
+ # name:: String - Property name
180
+ # Returns the value of the given property
181
+ def get_property(name)
182
+ case name
183
+ when 'resourcetype' then resource_type
184
+ when 'displayname' then display_name
185
+ when 'creationdate' then creation_date.xmlschema
186
+ when 'getcontentlength' then content_length.to_s
187
+ when 'getcontenttype' then content_type
188
+ when 'getetag' then etag
189
+ when 'getlastmodified' then last_modified.httpdate
190
+ end
191
+ end
192
+
193
+ # name:: String - Property name
194
+ # value:: New value
195
+ # Set the property to the given value
196
+ def set_property(name, value)
197
+ case name
198
+ when 'resourcetype' then self.resource_type = value
199
+ when 'getcontenttype' then self.content_type = value
200
+ when 'getetag' then self.etag = value
201
+ when 'getlastmodified' then self.last_modified = Time.httpdate(value)
202
+ end
203
+ rescue ArgumentError
204
+ raise HTTPStatus::Conflict
205
+ end
206
+
207
+ # name:: Property name
208
+ # Remove the property from the resource
209
+ def remove_property(name)
210
+ raise HTTPStatus::Forbidden
211
+ end
212
+
213
+ # name:: Name of child
214
+ # Create a new child with the given name
215
+ # NOTE:: Include trailing '/' if child is collection
216
+ def child(name)
217
+ new_public = public_path.dup
218
+ new_public = new_public + '/' unless new_public[-1,1] == '/'
219
+ new_public = '/' + new_public unless new_public[0,1] == '/'
220
+ new_path = path.dup
221
+ new_path = new_path + '/' unless new_path[-1,1] == '/'
222
+ new_path = '/' + new_path unless new_path[0,1] == '/'
223
+ self.class.new("#{new_public}#{name}", "#{new_path}#{name}", request, options)
224
+ end
225
+
226
+ # Return parent of this resource
227
+ def parent
228
+ elements = @path.scan(/[^\/]+/)
229
+ return nil if elements.empty?
230
+ self.class.new(('/' + @public_path.scan(/[^\/]+/)[0..-2].join('/')), ('/' + elements[0..-2].to_a.join('/')), @request, @options)
231
+ end
232
+
233
+ # Return list of descendants
234
+ def descendants
235
+ list = []
236
+ children.each do |child|
237
+ list << child
238
+ list.concat(child.descendants)
239
+ end
240
+ list
241
+ end
242
+
243
+ # Does client allow GET redirection
244
+ def allows_redirect?
245
+ %w(webdrive cyberduck konqueror).any?{|x| (request.respond_to?(:user_agent) ? request.user_agent.downcase : request.env['HTTP_USER_AGENT'].downcase) =~ /#{Regexp.escape(x)}/}
246
+ end
247
+
248
+ end
249
+
250
+ end
@@ -0,0 +1,270 @@
1
+ $:.unshift(File.expand_path(File.dirname(__FILE__) + '/../lib'))
2
+
3
+ require 'rubygems'
4
+ require 'rack_dav'
5
+ require 'fileutils'
6
+
7
+ describe RackDAV::Handler do
8
+ DOC_ROOT = File.expand_path(File.dirname(__FILE__) + '/htdocs')
9
+ METHODS = %w(GET PUT POST DELETE PROPFIND PROPPATCH MKCOL COPY MOVE OPTIONS HEAD LOCK UNLOCK)
10
+
11
+ before do
12
+ FileUtils.mkdir(DOC_ROOT) unless File.exists?(DOC_ROOT)
13
+ @controller = RackDAV::Handler.new(:root => DOC_ROOT)
14
+ end
15
+
16
+ after do
17
+ FileUtils.rm_rf(DOC_ROOT) if File.exists?(DOC_ROOT)
18
+ end
19
+
20
+ attr_reader :response
21
+
22
+ def request(method, uri, options={})
23
+ options = {
24
+ 'HTTP_HOST' => 'localhost',
25
+ 'REMOTE_USER' => 'manni'
26
+ }.merge(options)
27
+ request = Rack::MockRequest.new(@controller)
28
+ @response = request.request(method, uri, options)
29
+ end
30
+
31
+ METHODS.each do |method|
32
+ define_method(method.downcase) do |*args|
33
+ request(method, *args)
34
+ end
35
+ end
36
+
37
+ def render
38
+ xml = Builder::XmlMarkup.new
39
+ xml.instruct! :xml, :version => "1.0", :encoding => "UTF-8"
40
+ xml.namespace('d') do
41
+ yield xml
42
+ end
43
+ xml.target!
44
+ end
45
+
46
+ def url_escape(string)
47
+ string.gsub(/([^ a-zA-Z0-9_.-]+)/n) do
48
+ '%' + $1.unpack('H2' * $1.size).join('%').upcase
49
+ end.tr(' ', '+')
50
+ end
51
+
52
+ def response_xml
53
+ REXML::Document.new(@response.body)
54
+ end
55
+
56
+ def multistatus_response(pattern)
57
+ @response.should be_multi_status
58
+ REXML::XPath::match(response_xml, "/multistatus/response", '' => 'DAV:').should_not be_empty
59
+ REXML::XPath::match(response_xml, "/multistatus/response" + pattern, '' => 'DAV:')
60
+ end
61
+
62
+ def propfind_xml(*props)
63
+ render do |xml|
64
+ xml.propfind('xmlns:d' => "DAV:") do
65
+ xml.prop do
66
+ props.each do |prop|
67
+ xml.tag! prop
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ it 'should return all options' do
75
+ options('/').should be_ok
76
+
77
+ METHODS.each do |method|
78
+ response.headers['allow'].should include(method)
79
+ end
80
+ end
81
+
82
+ it 'should return headers' do
83
+ put('/test.html', :input => '<html/>').should be_ok
84
+ head('/test.html').should be_ok
85
+
86
+ response.headers['etag'].should_not be_nil
87
+ response.headers['content-type'].should match(/html/)
88
+ response.headers['last-modified'].should_not be_nil
89
+ end
90
+
91
+ it 'should not find a nonexistent resource' do
92
+ get('/not_found').should be_not_found
93
+ end
94
+
95
+ it 'should not allow directory traversal' do
96
+ get('/../htdocs').should be_forbidden
97
+ end
98
+
99
+ it 'should create a resource and allow its retrieval' do
100
+ put('/test', :input => 'body').should be_ok
101
+ get('/test').should be_ok
102
+ response.body.should == 'body'
103
+ end
104
+ it 'should create and find a url with escaped characters' do
105
+ put(url_escape('/a b'), :input => 'body').should be_ok
106
+ get(url_escape('/a b')).should be_ok
107
+ response.body.should == 'body'
108
+ end
109
+
110
+ it 'should delete a single resource' do
111
+ put('/test', :input => 'body').should be_ok
112
+ delete('/test').should be_no_content
113
+ end
114
+
115
+ it 'should delete recursively' do
116
+ mkcol('/folder').should be_created
117
+ put('/folder/a', :input => 'body').should be_ok
118
+ put('/folder/b', :input => 'body').should be_ok
119
+
120
+ delete('/folder').should be_no_content
121
+ get('/folder').should be_not_found
122
+ get('/folder/a').should be_not_found
123
+ get('/folder/b').should be_not_found
124
+ end
125
+
126
+ it 'should not allow copy to another domain' do
127
+ put('/test', :input => 'body').should be_ok
128
+ copy('http://localhost/', 'HTTP_DESTINATION' => 'http://another/').should be_bad_gateway
129
+ end
130
+
131
+ it 'should not allow copy to the same resource' do
132
+ put('/test', :input => 'body').should be_ok
133
+ copy('/test', 'HTTP_DESTINATION' => '/test').should be_forbidden
134
+ end
135
+
136
+ it 'should not allow an invalid destination uri' do
137
+ put('/test', :input => 'body').should be_ok
138
+ copy('/test', 'HTTP_DESTINATION' => '%').should be_bad_request
139
+ end
140
+
141
+ it 'should copy a single resource' do
142
+ put('/test', :input => 'body').should be_ok
143
+ copy('/test', 'HTTP_DESTINATION' => '/copy').should be_created
144
+ get('/copy').body.should == 'body'
145
+ end
146
+
147
+ it 'should copy a resource with escaped characters' do
148
+ put(url_escape('/a b'), :input => 'body').should be_ok
149
+ copy(url_escape('/a b'), 'HTTP_DESTINATION' => url_escape('/a c')).should be_created
150
+ get(url_escape('/a c')).should be_ok
151
+ response.body.should == 'body'
152
+ end
153
+
154
+ it 'should deny a copy without overwrite' do
155
+ put('/test', :input => 'body').should be_ok
156
+ put('/copy', :input => 'copy').should be_ok
157
+ copy('/test', 'HTTP_DESTINATION' => '/copy', 'HTTP_OVERWRITE' => 'F')
158
+
159
+ multistatus_response('/href').first.text.should == 'http://localhost/test'
160
+ multistatus_response('/status').first.text.should match(/412 Precondition Failed/)
161
+
162
+ get('/copy').body.should == 'copy'
163
+ end
164
+
165
+ it 'should allow a copy with overwrite' do
166
+ put('/test', :input => 'body').should be_ok
167
+ put('/copy', :input => 'copy').should be_ok
168
+ copy('/test', 'HTTP_DESTINATION' => '/copy', 'HTTP_OVERWRITE' => 'T').should be_no_content
169
+ get('/copy').body.should == 'body'
170
+ end
171
+
172
+ it 'should copy a collection' do
173
+ mkcol('/folder').should be_created
174
+ copy('/folder', 'HTTP_DESTINATION' => '/copy').should be_created
175
+ propfind('/copy', :input => propfind_xml(:resourcetype))
176
+ multistatus_response('/propstat/prop/resourcetype/collection').should_not be_empty
177
+ end
178
+
179
+ it 'should copy a collection resursively' do
180
+ mkcol('/folder').should be_created
181
+ put('/folder/a', :input => 'A').should be_ok
182
+ put('/folder/b', :input => 'B').should be_ok
183
+
184
+ copy('/folder', 'HTTP_DESTINATION' => '/copy').should be_created
185
+ propfind('/copy', :input => propfind_xml(:resourcetype))
186
+ multistatus_response('/propstat/prop/resourcetype/collection').should_not be_empty
187
+
188
+ get('/copy/a').body.should == 'A'
189
+ get('/copy/b').body.should == 'B'
190
+ end
191
+
192
+ it 'should move a collection recursively' do
193
+ mkcol('/folder').should be_created
194
+ put('/folder/a', :input => 'A').should be_ok
195
+ put('/folder/b', :input => 'B').should be_ok
196
+
197
+ move('/folder', 'HTTP_DESTINATION' => '/move').should be_created
198
+ propfind('/move', :input => propfind_xml(:resourcetype))
199
+ multistatus_response('/propstat/prop/resourcetype/collection').should_not be_empty
200
+
201
+ get('/move/a').body.should == 'A'
202
+ get('/move/b').body.should == 'B'
203
+ get('/folder/a').should be_not_found
204
+ get('/folder/b').should be_not_found
205
+ end
206
+
207
+ it 'should create a collection' do
208
+ mkcol('/folder').should be_created
209
+ propfind('/folder', :input => propfind_xml(:resourcetype))
210
+ multistatus_response('/propstat/prop/resourcetype/collection').should_not be_empty
211
+ end
212
+
213
+ it 'should not find properties for nonexistent resources' do
214
+ propfind('/non').should be_not_found
215
+ end
216
+
217
+ it 'should find all properties' do
218
+ xml = render do |xml|
219
+ xml.propfind('xmlns:d' => "DAV:") do
220
+ xml.allprop
221
+ end
222
+ end
223
+
224
+ propfind('http://localhost/', :input => xml)
225
+
226
+ multistatus_response('/href').first.text.strip.should == 'http://localhost/'
227
+
228
+ props = %w(creationdate displayname getlastmodified getetag resourcetype getcontenttype getcontentlength)
229
+ props.each do |prop|
230
+ multistatus_response('/propstat/prop/' + prop).should_not be_empty
231
+ end
232
+ end
233
+
234
+ it 'should find named properties' do
235
+ put('/test.html', :input => '<html/>').should be_ok
236
+ propfind('/test.html', :input => propfind_xml(:getcontenttype, :getcontentlength))
237
+
238
+ multistatus_response('/propstat/prop/getcontenttype').first.text.should == 'text/html'
239
+ multistatus_response('/propstat/prop/getcontentlength').first.text.should == '7'
240
+ end
241
+
242
+ it 'should lock a resource' do
243
+ put('/test', :input => 'body').should be_ok
244
+
245
+ xml = render do |xml|
246
+ xml.lockinfo('xmlns:d' => "DAV:") do
247
+ xml.lockscope { xml.exclusive }
248
+ xml.locktype { xml.write }
249
+ xml.owner { xml.href "http://test.de/" }
250
+ end
251
+ end
252
+
253
+ lock('/test', :input => xml)
254
+
255
+ response.should be_ok
256
+
257
+ match = lambda do |pattern|
258
+ REXML::XPath::match(response_xml, "/prop/lockdiscovery/activelock" + pattern, '' => 'DAV:')
259
+ end
260
+
261
+ match[''].should_not be_empty
262
+
263
+ match['/locktype'].should_not be_empty
264
+ match['/lockscope'].should_not be_empty
265
+ match['/depth'].should_not be_empty
266
+ match['/owner'].should_not be_empty
267
+ match['/timeout'].should_not be_empty
268
+ match['/locktoken'].should_not be_empty
269
+ end
270
+ end