rack-webdav 0.4.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.
@@ -0,0 +1,14 @@
1
+ require 'time'
2
+ require 'uri'
3
+ require 'nokogiri'
4
+
5
+ require 'rack'
6
+ require 'rack-webdav/utils'
7
+ require 'rack-webdav/http_status'
8
+ require 'rack-webdav/resource'
9
+ require 'rack-webdav/handler'
10
+ require 'rack-webdav/controller'
11
+
12
+ module RackWebDAV
13
+ IS_18 = RUBY_VERSION[0,3] == '1.8'
14
+ end
@@ -0,0 +1,601 @@
1
+ require 'uri'
2
+
3
+ module RackWebDAV
4
+
5
+ class Controller
6
+ include RackWebDAV::HTTPStatus
7
+ include RackWebDAV::Utils
8
+
9
+ attr_reader :request, :response, :resource
10
+
11
+ # request:: Rack::Request
12
+ # response:: Rack::Response
13
+ # options:: Options hash
14
+ # Create a new Controller.
15
+ # NOTE: options will be passed to Resource
16
+ def initialize(request, response, options={})
17
+ raise Forbidden if request.path_info.include?('..')
18
+ @request = request
19
+ @response = response
20
+ @options = options
21
+
22
+ @dav_extensions = options.delete(:dav_extensions) || []
23
+ @always_include_dav_header = options.delete(:always_include_dav_header)
24
+
25
+ @resource = resource_class.new(actual_path, implied_path, @request, @response, @options)
26
+
27
+ if(@always_include_dav_header)
28
+ add_dav_header
29
+ end
30
+ end
31
+
32
+ # s:: string
33
+ # Escape URL string
34
+ def url_format(resource)
35
+ ret = URI.escape(resource.public_path)
36
+ if resource.collection? and ret[-1,1] != '/'
37
+ ret += '/'
38
+ end
39
+ ret
40
+ end
41
+
42
+ # s:: string
43
+ # Unescape URL string
44
+ def url_unescape(s)
45
+ URI.unescape(s)
46
+ end
47
+
48
+ def add_dav_header
49
+ unless(response['Dav'])
50
+ dav_support = %w(1 2) + @dav_extensions
51
+ response['Dav'] = dav_support.join(', ')
52
+ end
53
+ end
54
+
55
+ # Return response to OPTIONS
56
+ def options
57
+ add_dav_header
58
+ response['Allow'] = 'OPTIONS,HEAD,GET,PUT,POST,DELETE,PROPFIND,PROPPATCH,MKCOL,COPY,MOVE,LOCK,UNLOCK'
59
+ response['Ms-Author-Via'] = 'DAV'
60
+ OK
61
+ end
62
+
63
+ # Return response to HEAD
64
+ def head
65
+ if(resource.exist?)
66
+ response['Etag'] = resource.etag
67
+ response['Content-Type'] = resource.content_type
68
+ response['Last-Modified'] = resource.last_modified.httpdate
69
+ OK
70
+ else
71
+ NotFound
72
+ end
73
+ end
74
+
75
+ # Return response to GET
76
+ def get
77
+ if(resource.exist?)
78
+ res = resource.get(request, response)
79
+ if(res == OK && !resource.collection?)
80
+ response['Etag'] = resource.etag
81
+ response['Content-Type'] = resource.content_type
82
+ response['Content-Length'] = resource.content_length.to_s
83
+ response['Last-Modified'] = resource.last_modified.httpdate
84
+ end
85
+ res
86
+ else
87
+ NotFound
88
+ end
89
+ end
90
+
91
+ # Return response to PUT
92
+ def put
93
+ if(resource.collection?)
94
+ Forbidden
95
+ elsif(!resource.parent_exists? || !resource.parent_collection?)
96
+ Conflict
97
+ else
98
+ resource.lock_check if resource.supports_locking?
99
+ status = resource.put(request, response)
100
+ response['Location'] = "#{scheme}://#{host}:#{port}#{url_format(resource)}" if status == Created
101
+ response.body = response['Location']
102
+ status
103
+ end
104
+ end
105
+
106
+ # Return response to POST
107
+ def post
108
+ resource.post(request, response)
109
+ end
110
+
111
+ # Return response to DELETE
112
+ def delete
113
+ if(resource.exist?)
114
+ resource.lock_check if resource.supports_locking?
115
+ resource.delete
116
+ else
117
+ NotFound
118
+ end
119
+ end
120
+
121
+ # Return response to MKCOL
122
+ def mkcol
123
+ resource.lock_check if resource.supports_locking?
124
+ status = resource.make_collection
125
+ gen_url = "#{scheme}://#{host}:#{port}#{url_format(resource)}" if status == Created
126
+ if(resource.use_compat_mkcol_response?)
127
+ multistatus do |xml|
128
+ xml.response do
129
+ xml.href gen_url
130
+ xml.status "#{http_version} #{status.status_line}"
131
+ end
132
+ end
133
+ else
134
+ response['Location'] = gen_url
135
+ status
136
+ end
137
+ end
138
+
139
+ # Return response to COPY
140
+ def copy
141
+ move(:copy)
142
+ end
143
+
144
+ # args:: Only argument used: :copy
145
+ # Move Resource to new location. If :copy is provided,
146
+ # Resource will be copied (implementation ease)
147
+ def move(*args)
148
+ unless(resource.exist?)
149
+ NotFound
150
+ else
151
+ resource.lock_check if resource.supports_locking? && !args.include(:copy)
152
+ destination = url_unescape(env['HTTP_DESTINATION'].sub(%r{https?://([^/]+)}, ''))
153
+ dest_host = $1
154
+ if(dest_host && dest_host.gsub(/:\d{2,5}$/, '') != request.host)
155
+ BadGateway
156
+ elsif(destination == resource.public_path)
157
+ Forbidden
158
+ else
159
+ collection = resource.collection?
160
+ dest = resource_class.new(destination, clean_path(destination), @request, @response, @options.merge(:user => resource.user))
161
+ status = nil
162
+ if(args.include?(:copy))
163
+ status = resource.copy(dest, overwrite)
164
+ else
165
+ return Conflict unless depth.is_a?(Symbol) || depth > 1
166
+ status = resource.move(dest, overwrite)
167
+ end
168
+ response['Location'] = "#{scheme}://#{host}:#{port}#{url_format(dest)}" if status == Created
169
+ # RFC 2518
170
+ if collection
171
+ multistatus do |xml|
172
+ xml.response do
173
+ xml.href "#{scheme}://#{host}:#{port}#{url_format(status == Created ? dest : resource)}"
174
+ xml.status "#{http_version} #{status.status_line}"
175
+ end
176
+ end
177
+ else
178
+ status
179
+ end
180
+ end
181
+ end
182
+ end
183
+
184
+ # Return response to PROPFIND
185
+ def propfind
186
+ unless(resource.exist?)
187
+ NotFound
188
+ else
189
+ unless(request_document.xpath("//#{ns}propfind/#{ns}allprop").empty?)
190
+ properties = resource.properties
191
+ else
192
+ check = request_document.xpath("//#{ns}propfind")
193
+ if(check && !check.empty?)
194
+ properties = request_document.xpath(
195
+ "//#{ns}propfind/#{ns}prop"
196
+ ).children.find_all{ |item|
197
+ item.element?
198
+ }.map{ |item|
199
+ # We should do this, but Nokogiri transforms prefix w/ null href into
200
+ # something valid. Oops.
201
+ # TODO: Hacky grep fix that's horrible
202
+ hsh = to_element_hash(item)
203
+ if(hsh.namespace.nil? && !ns.empty?)
204
+ raise BadRequest if request_document.to_s.scan(%r{<#{item.name}[^>]+xmlns=""}).empty?
205
+ end
206
+ hsh
207
+ }.compact
208
+ else
209
+ raise BadRequest
210
+ end
211
+ end
212
+ multistatus do |xml|
213
+ find_resources.each do |resource|
214
+ xml.response do
215
+ unless(resource.propstat_relative_path)
216
+ xml.href "#{scheme}://#{host}:#{port}#{url_format(resource)}"
217
+ else
218
+ xml.href url_format(resource)
219
+ end
220
+ propstats(xml, get_properties(resource, properties.empty? ? resource.properties : properties))
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ # Return response to PROPPATCH
228
+ def proppatch
229
+ unless(resource.exist?)
230
+ NotFound
231
+ else
232
+ resource.lock_check if resource.supports_locking?
233
+ prop_actions = []
234
+ request_document.xpath("/#{ns}propertyupdate").children.each do |element|
235
+ case element.name
236
+ when 'set', 'remove'
237
+ prp = element.children.detect{|e|e.name == 'prop'}
238
+ if(prp)
239
+ prp.children.each do |elm|
240
+ next if elm.name == 'text'
241
+ prop_actions << {:type => element.name, :name => to_element_hash(elm), :value => elm.text}
242
+ end
243
+ end
244
+ end
245
+ end
246
+ multistatus do |xml|
247
+ find_resources.each do |resource|
248
+ xml.response do
249
+ xml.href "#{scheme}://#{host}:#{port}#{url_format(resource)}"
250
+ prop_actions.each do |action|
251
+ case action[:type]
252
+ when 'set'
253
+ propstats(xml, set_properties(resource, action[:name] => action[:value]))
254
+ when 'remove'
255
+ rm_properties(resource, action[:name] => action[:value])
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+
264
+
265
+ # Lock current resource
266
+ # NOTE: This will pass an argument hash to Resource#lock and
267
+ # wait for a success/failure response.
268
+ def lock
269
+ lockinfo = request_document.xpath("//#{ns}lockinfo")
270
+ asked = {}
271
+ asked[:timeout] = request.env['Timeout'].split(',').map{|x|x.strip} if request.env['Timeout']
272
+ asked[:depth] = depth
273
+ unless([0, :infinity].include?(asked[:depth]))
274
+ BadRequest
275
+ else
276
+ asked[:scope] = lockinfo.xpath("//#{ns}lockscope").children.find_all{|n|n.element?}.map{|n|n.name}.first
277
+ asked[:type] = lockinfo.xpath("#{ns}locktype").children.find_all{|n|n.element?}.map{|n|n.name}.first
278
+ asked[:owner] = lockinfo.xpath("//#{ns}owner/#{ns}href").children.map{|n|n.text}.first
279
+ begin
280
+ lock_time, locktoken = resource.lock(asked)
281
+ render_xml(:prop) do |xml|
282
+ xml.lockdiscovery do
283
+ xml.activelock do
284
+ if(asked[:scope])
285
+ xml.lockscope do
286
+ xml.send(asked[:scope])
287
+ end
288
+ end
289
+ if(asked[:type])
290
+ xml.locktype do
291
+ xml.send(asked[:type])
292
+ end
293
+ end
294
+ xml.depth asked[:depth].to_s
295
+ xml.timeout lock_time ? "Second-#{lock_time}" : 'infinity'
296
+ xml.locktoken do
297
+ xml.href locktoken
298
+ end
299
+ if(asked[:owner])
300
+ xml.owner asked[:owner]
301
+ end
302
+ end
303
+ end
304
+ end
305
+ response.headers['Lock-Token'] = locktoken
306
+ response.status = resource.exist? ? OK : Created
307
+ rescue LockFailure => e
308
+ multistatus do |xml|
309
+ e.path_status.each_pair do |path, status|
310
+ xml.response do
311
+ xml.href path
312
+ xml.status "#{http_version} #{status.status_line}"
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end
318
+ end
319
+
320
+ # Unlock current resource
321
+ def unlock
322
+ resource.unlock(lock_token)
323
+ end
324
+
325
+ # Perform authentication
326
+ # NOTE: Authentication will only be performed if the Resource
327
+ # has defined an #authenticate method
328
+ def authenticate
329
+ authed = true
330
+ if(resource.respond_to?(:authenticate, true))
331
+ authed = false
332
+ uname = nil
333
+ password = nil
334
+ if(request.env['HTTP_AUTHORIZATION'])
335
+ auth = Rack::Auth::Basic::Request.new(request.env)
336
+ if(auth.basic? && auth.credentials)
337
+ uname = auth.credentials[0]
338
+ password = auth.credentials[1]
339
+ end
340
+ end
341
+ authed = resource.send(:authenticate, uname, password)
342
+ end
343
+ raise Unauthorized unless authed
344
+ end
345
+
346
+ private
347
+
348
+ # Request environment variables
349
+ def env
350
+ @request.env
351
+ end
352
+
353
+ # Current request scheme (http/https)
354
+ def scheme
355
+ request.scheme
356
+ end
357
+
358
+ # Request host
359
+ def host
360
+ request.host
361
+ end
362
+
363
+ # Request port
364
+ def port
365
+ request.port
366
+ end
367
+
368
+ # Class of the resource in use
369
+ def resource_class
370
+ @options[:resource_class]
371
+ end
372
+
373
+ # Root URI path for the resource
374
+ def root_uri_path
375
+ @options[:root_uri_path]
376
+ end
377
+
378
+ # Returns Resource path with root URI removed
379
+ def implied_path
380
+ clean_path(@request.path.dup)
381
+ end
382
+
383
+ # x:: request path
384
+ # Unescapes path and removes root URI if applicable
385
+ def clean_path(x)
386
+ ip = url_unescape(x)
387
+ ip.gsub!(/^#{Regexp.escape(root_uri_path)}/, '') if root_uri_path
388
+ ip
389
+ end
390
+
391
+ # Unescaped request path
392
+ def actual_path
393
+ url_unescape(@request.path.dup)
394
+ end
395
+
396
+ # Lock token if provided by client
397
+ def lock_token
398
+ env['HTTP_LOCK_TOKEN'] || nil
399
+ end
400
+
401
+ # Requested depth
402
+ def depth
403
+ d = env['HTTP_DEPTH']
404
+ if(d =~ /^\d+$/)
405
+ d = d.to_i
406
+ else
407
+ d = :infinity
408
+ end
409
+ d
410
+ end
411
+
412
+ # Current HTTP version being used
413
+ def http_version
414
+ env['HTTP_VERSION'] || env['SERVER_PROTOCOL'] || 'HTTP/1.0'
415
+ end
416
+
417
+ # Overwrite is allowed
418
+ def overwrite
419
+ env['HTTP_OVERWRITE'].to_s.upcase != 'F'
420
+ end
421
+
422
+ # Find resources at depth requested
423
+ def find_resources(with_current_resource=true)
424
+ ary = nil
425
+ case depth
426
+ when 0
427
+ ary = []
428
+ when 1
429
+ ary = resource.children
430
+ else
431
+ ary = resource.descendants
432
+ end
433
+ with_current_resource ? [resource] + ary : ary
434
+ end
435
+
436
+ # XML parsed request
437
+ def request_document
438
+ @request_document ||= Nokogiri.XML(request.body.read)
439
+ rescue
440
+ raise BadRequest
441
+ end
442
+
443
+ # Namespace being used within XML document
444
+ # TODO: Make this better
445
+ def ns(wanted_uri="DAV:")
446
+ _ns = ''
447
+ if(request_document && request_document.root && request_document.root.namespace_definitions.size > 0)
448
+ _ns = request_document.root.namespace_definitions.collect{|__ns| __ns if __ns.href == wanted_uri}.compact
449
+ if _ns.empty?
450
+ _ns = request_document.root.namespace_definitions.first.prefix.to_s if _ns.empty?
451
+ else
452
+ _ns = _ns.first
453
+ _ns = _ns.prefix.nil? ? 'xmlns' : _ns.prefix.to_s
454
+ end
455
+ _ns += ':' unless _ns.empty?
456
+ end
457
+ _ns
458
+ end
459
+
460
+ # root_type:: Root tag name
461
+ # Render XML and set Rack::Response#body= to final XML
462
+ def render_xml(root_type)
463
+ raise ArgumentError.new 'Expecting block' unless block_given?
464
+ doc = Nokogiri::XML::Builder.new do |xml_base|
465
+ xml_base.send(root_type.to_s, {'xmlns:D' => 'DAV:'}.merge(resource.root_xml_attributes)) do
466
+ xml_base.parent.namespace = xml_base.parent.namespace_definitions.first
467
+ xml = xml_base['D']
468
+ yield xml
469
+ end
470
+ end
471
+
472
+ if(@options[:pretty_xml])
473
+ response.body = doc.to_xml
474
+ else
475
+ response.body = doc.to_xml(
476
+ :save_with => Nokogiri::XML::Node::SaveOptions::AS_XML
477
+ )
478
+ end
479
+ response["Content-Type"] = 'text/xml; charset="utf-8"'
480
+ response["Content-Length"] = response.body.size.to_s
481
+ end
482
+
483
+ # block:: block
484
+ # Creates a multistatus response using #render_xml and
485
+ # returns the correct status
486
+ def multistatus(&block)
487
+ render_xml(:multistatus, &block)
488
+ MultiStatus
489
+ end
490
+
491
+ # xml:: Nokogiri::XML::Builder
492
+ # errors:: Array of errors
493
+ # Crafts responses for errors
494
+ def response_errors(xml, errors)
495
+ for path, status in errors
496
+ xml.response do
497
+ xml.href "#{scheme}://#{host}:#{port}#{URI.escape(path)}"
498
+ xml.status "#{http_version} #{status.status_line}"
499
+ end
500
+ end
501
+ end
502
+
503
+ # resource:: Resource
504
+ # elements:: Property hashes (name, ns_href, children)
505
+ # Returns array of property values for given names
506
+ def get_properties(resource, elements)
507
+ stats = Hash.new { |h, k| h[k] = [] }
508
+ for element in elements
509
+ begin
510
+ val = resource.get_property(element)
511
+ stats[OK] << [element, val]
512
+ rescue Unauthorized => u
513
+ raise u
514
+ rescue Status
515
+ stats[$!.class] << element
516
+ end
517
+ end
518
+ stats
519
+ end
520
+
521
+ # resource:: Resource
522
+ # elements:: Property hashes (name, namespace, children)
523
+ # Removes the given properties from a resource
524
+ def rm_properties(resource, elements)
525
+ for element, value in elements
526
+ resource.remove_property(element)
527
+ end
528
+ end
529
+
530
+ # resource:: Resource
531
+ # elements:: Property hashes (name, namespace, children)
532
+ # Sets the given properties
533
+ def set_properties(resource, elements)
534
+ stats = Hash.new { |h, k| h[k] = [] }
535
+ for element, value in elements
536
+ begin
537
+ stats[OK] << [element, resource.set_property(element, value)]
538
+ rescue Unauthorized => u
539
+ raise u
540
+ rescue Status
541
+ stats[$!.class] << element
542
+ end
543
+ end
544
+ stats
545
+ end
546
+
547
+ # xml:: Nokogiri::XML::Builder
548
+ # stats:: Array of stats
549
+ # Build propstats response
550
+ def propstats(xml, stats)
551
+ return if stats.empty?
552
+ for status, props in stats
553
+ xml.propstat do
554
+ xml.prop do
555
+ for element, value in props
556
+ defn = xml.doc.root.namespace_definitions.find{|ns_def| ns_def.href == element[:ns_href]}
557
+ if defn.nil?
558
+ if element[:ns_href] and not element[:ns_href].empty?
559
+ _ns = "unknown#{rand(65536)}"
560
+ xml.doc.root.add_namespace_definition(_ns, element[:ns_href])
561
+ else
562
+ _ns = nil
563
+ end
564
+ else
565
+ # Unfortunately Nokogiri won't let the null href, non-null prefix happen
566
+ # So we can't properly handle that error.
567
+ _ns = element[:ns_href].nil? ? nil : defn.prefix
568
+ end
569
+ ns_xml = _ns.nil? ? xml : xml[_ns]
570
+ if (value.is_a?(Nokogiri::XML::Node)) or (value.is_a?(Nokogiri::XML::DocumentFragment))
571
+ xml.__send__ :insert, value
572
+ elsif(value.is_a?(Symbol))
573
+ ns_xml.send(element[:name]) do
574
+ ns_xml.send(value)
575
+ end
576
+ else
577
+ ns_xml.send(element[:name], value) do |x|
578
+ # Make sure we return valid XML
579
+ x.parent.namespace = nil if _ns.nil?
580
+ end
581
+ end
582
+
583
+ # This is gross, but make sure we set the current namespace back to DAV:
584
+ xml['D']
585
+ end
586
+ end
587
+ xml.status "#{http_version} #{status.status_line}"
588
+ end
589
+ end
590
+ end
591
+
592
+ # xml:: Nokogiri::XML::Builder
593
+ # element:: Nokogiri::XML::Element
594
+ # Converts element into proper text
595
+ def xml_convert(xml, element)
596
+ xml.doc.root.add_child(element)
597
+ end
598
+
599
+ end
600
+
601
+ end