dav4rack 0.0.1

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.
@@ -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