require 'uri' require_relative 'string' module RackWebDAV class Controller include RackWebDAV::HTTPStatus attr_reader :request, :response, :resource def initialize(request, response, options) @request = request @response = response @options = options @resource = resource_class.new(url_unescape(request.path_info), @request, @response, @options) raise Forbidden if request.path_info.include?('../') end def url_escape(s) URI.escape(s) end def url_unescape(s) URI.unescape(s).force_valid_encoding end def options response["Allow"] = 'OPTIONS,HEAD,GET,PUT,POST,DELETE,PROPFIND,PROPPATCH,MKCOL,COPY,MOVE' response["Dav"] = "1" if resource.lockable? response["Allow"] << ",LOCK,UNLOCK" response["Dav"] << ",2" end response["Ms-Author-Via"] = "DAV" end def head raise NotFound if not resource.exist? response['Etag'] = resource.etag response['Content-Type'] = resource.content_type response['Content-Length'] = resource.content_length.to_s response['Last-Modified'] = resource.last_modified.httpdate end def get raise NotFound if not resource.exist? response['Etag'] = resource.etag response['Content-Type'] = resource.content_type response['Content-Length'] = resource.content_length.to_s response['Last-Modified'] = resource.last_modified.httpdate map_exceptions do resource.get end end def put raise Forbidden if resource.collection? locktoken = request_locktoken('LOCK_TOKEN') locktoken ||= request_locktoken('IF') locketag = request_locketag('IF') raise PreconditionFailed if locketag && locketag != resource.etag raise Locked if resource.locked?(locktoken, locketag) map_exceptions do resource.put end response.status = Created response['Location'] = "#{request.scheme}://#{request.host}:#{request.port}#{url_format_for_response(resource)}" end def post map_exceptions do resource.post end end def delete raise NotFound if not resource.exist? raise Locked if resource.locked?(request_locktoken('LOCK_TOKEN')) delete_recursive(resource, errors = []) if errors.empty? response.status = NoContent else multistatus do |xml| response_errors(xml, errors) end end end def mkcol # Reject message bodies - RFC2518:8.3.1 body = @request.body.read(8) fail UnsupportedMediaType if !body.nil? && body.length > 0 map_exceptions do resource.make_collection end response.status = Created end def copy raise NotFound if not resource.exist? # Source Lock Check locktoken = request_locktoken('LOCK_TOKEN') locktoken ||= request_locktoken('IF') raise Locked if resource.locked?(locktoken) && !overwrite dest_uri = URI.parse(env['HTTP_DESTINATION']) destination = parse_destination(dest_uri) raise BadGateway if dest_uri.host and dest_uri.host != request.host raise Forbidden if destination == resource.path dest = resource_class.new(destination, @request, @response, @options) raise PreconditionFailed if dest.exist? && !overwrite # Destination Lock Check locktoken = request_locktoken('LOCK_TOKEN') locktoken ||= request_locktoken('IF') raise Locked if dest.locked?(locktoken) dest = dest.child(resource.name) if dest.collection? dest_existed = dest.exist? copy_recursive(resource, dest, depth, errors = []) if errors.empty? response.status = dest_existed ? NoContent : Created else multistatus do |xml| response_errors(xml, errors) end end rescue URI::InvalidURIError => e raise BadRequest.new(e.message) end def move raise NotFound if not resource.exist? raise Locked if resource.locked?(request_locktoken('LOCK_TOKEN')) dest_uri = URI.parse(env['HTTP_DESTINATION']) destination = parse_destination(dest_uri) raise BadGateway if dest_uri.host and dest_uri.host != request.host raise Forbidden if destination == resource.path dest = resource_class.new(destination, @request, @response, @options) raise PreconditionFailed if dest.exist? && !overwrite dest_existed = dest.exist? dest = dest.child(resource.name) if dest.collection? raise Conflict if depth <= 1 copy_recursive(resource, dest, depth, errors = []) delete_recursive(resource, errors) if errors.empty? response.status = dest_existed ? NoContent : Created else multistatus do |xml| response_errors(xml, errors) end end rescue URI::InvalidURIError => e raise BadRequest.new(e.message) end def propfind raise NotFound if not resource.exist? if not request_match("/d:propfind/d:allprop").empty? nodes = all_prop_nodes else nodes = request_match("/d:propfind/d:prop/*") nodes = all_prop_nodes if nodes.empty? end nodes.each do |n| # Don't allow empty namespace declarations # See litmus props test 3 raise BadRequest if n.namespace.nil? && n.namespace_definitions.empty? # Set a blank namespace if one is included in the request # See litmus props test 16 # if n.namespace.nil? nd = n.namespace_definitions.first if nd.prefix.nil? && nd.href.empty? n.add_namespace(nil, '') end end end multistatus do |xml| for resource in find_resources resource.path.gsub!(/\/\//, '/') xml.response do xml.href "http://#{host}#{@request.script_name}#{url_escape resource.path}" propstats xml, get_properties(resource, nodes) end end end end def proppatch raise NotFound if not resource.exist? locktoken = request_locktoken('LOCK_TOKEN') locktoken ||= request_locktoken('IF') raise Locked if resource.locked?(locktoken) nodes = request_match("/d:propertyupdate[d:remove/d:prop/* or d:set/d:prop/*]//d:prop/*") # Set a blank namespace if one is included in the request # See litmus props test 15 # # randomvalue # nodes.each do |n| nd = n.namespace_definitions.first if !nd.nil? && nd.prefix.nil? && nd.href.empty? n.add_namespace(nil, '') end end multistatus do |xml| for resource in find_resources xml.response do xml.href "http://#{host}#{@request.script_name}#{resource.path}" propstats xml, set_properties(resource, nodes) end end end end def lock raise MethodNotAllowed unless resource.lockable? raise NotFound if not resource.exist? timeout = request_timeout if timeout.nil? || timeout.zero? timeout = 60 end if request_document.content.empty? refresh_lock timeout else create_lock timeout end end def unlock raise MethodNotAllowed unless resource.lockable? locktoken = request_locktoken('LOCK_TOKEN') raise BadRequest if locktoken.nil? response.status = resource.unlock(locktoken) ? NoContent : Forbidden end private def env @request.env end def host @request.host end def resource_class @options[:resource_class] end def depth case env['HTTP_DEPTH'] when '0' then 0 when '1' then 1 else 100 end end def overwrite env['HTTP_OVERWRITE'].to_s.upcase != 'F' end def find_resources case env['HTTP_DEPTH'] when '0' [resource] when '1' [resource] + resource.children else [resource] + resource.descendants end end def delete_recursive(res, errors) for child in res.children delete_recursive(child, errors) end begin map_exceptions { res.delete } if errors.empty? rescue Status errors << [res.path, $!] end end def copy_recursive(res, dest, depth, errors) map_exceptions do if dest.exist? if overwrite delete_recursive(dest, errors) else raise PreconditionFailed end end res.copy(dest) end rescue Status errors << [res.path, $!] else if depth > 0 for child in res.children dest_child = dest.child(child.name) copy_recursive(child, dest_child, depth - 1, errors) end end end def map_exceptions yield rescue case $! when URI::InvalidURIError then raise BadRequest when Errno::EACCES then raise Forbidden when Errno::ENOENT then raise Conflict when Errno::EEXIST then raise Conflict when Errno::ENOSPC then raise InsufficientStorage else raise end end def request_document @request_document ||= if (body = request.body.read).empty? Nokogiri::XML::Document.new else Nokogiri::XML(body, &:strict) end rescue Nokogiri::XML::SyntaxError, RuntimeError # Nokogiri raise RuntimeError :-( raise BadRequest end def request_match(pattern) request_document.xpath(pattern, 'd' => 'DAV:') end def qualified_node_name(node) node.namespace.nil? || node.namespace.prefix.nil? ? node.name : "#{node.namespace.prefix}:#{node.name}" end def qualified_property_name(node) node.namespace.nil? || node.namespace.href == 'DAV:' ? node.name : "{#{node.namespace.href}}#{node.name}" end def all_prop_nodes resource.property_names.map do |n| node = Nokogiri::XML::Element.new(n, request_document) node.add_namespace(nil, 'DAV:') node end end # Quick and dirty parsing of the WEBDAV Timeout header. # Refuses infinity, rejects anything but Second- timeouts # # @return [nil] or [Fixnum] # # @api internal # def request_timeout timeout = request.env['HTTP_TIMEOUT'] return if timeout.nil? || timeout.empty? timeout = timeout.split /,\s*/ timeout.reject! {|t| t !~ /^Second-/} timeout.first.sub('Second-', '').to_i end def request_locktoken(header) token = request.env["HTTP_#{header}"] return if token.nil? || token.empty? token.scan /<(opaquelocktoken:.+?)>/ return $1 end def request_locketag(header) etag = request.env["HTTP_#{header}"] return if etag.nil? || etag.empty? etag.scan /\[(.+?)\]/ return $1 end # Creates a new XML document, yields given block # and sets the response.body with the final XML content. # The response length is updated accordingly. # # @return [void] # # @yield [xml] Yields the Builder XML instance. # # @api internal # def render_xml content = Nokogiri::XML::Builder.new(:encoding => "UTF-8") do |xml| yield xml end.to_xml response.body = [content] response["Content-Type"] = 'text/xml; charset=utf-8' response["Content-Length"] = content.bytesize.to_s end def multistatus render_xml do |xml| xml.multistatus('xmlns' => "DAV:") do yield xml end end response.status = MultiStatus end def response_errors(xml, errors) for path, status in errors xml.response do xml.href "http://#{host}#{path}" xml.status "#{request.env['HTTP_VERSION']} #{status.status_line}" end end end def get_properties(resource, nodes) stats = Hash.new { |h, k| h[k] = [] } for node in nodes begin map_exceptions do stats[OK] << [node, resource.get_property(qualified_property_name(node))] end rescue Status stats[$!] << node end end stats end def set_properties(resource, nodes) stats = Hash.new { |h, k| h[k] = [] } for node in nodes begin map_exceptions do stats[OK] << [node, resource.set_property(qualified_property_name(node), node.text)] end rescue Status stats[$!] << node end end stats end def propstats(xml, stats) return if stats.empty? for status, props in stats xml.propstat do xml.prop do for node, value in props if value.is_a?(Nokogiri::XML::Node) xml.send(qualified_node_name(node).to_sym) do rexml_convert(xml, value) end else attrs = {} unless node.namespace.nil? unless node.namespace.prefix.nil? attrs = { "xmlns:#{node.namespace.prefix}" => node.namespace.href } else attrs = { 'xmlns' => node.namespace.href } end end xml.send(qualified_node_name(node).to_sym, value, attrs) end end end xml.status "#{request.env['HTTP_VERSION']} #{status.status_line}" end end end def create_lock(timeout) lockscope = request_match("/d:lockinfo/d:lockscope/d:*").first lockscope = lockscope.name if lockscope locktype = request_match("/d:lockinfo/d:locktype/d:*").first locktype = locktype.name if locktype owner = request_match("/d:lockinfo/d:owner/d:href").first owner ||= request_match("/d:lockinfo/d:owner").first owner = owner.text if owner locktoken = "opaquelocktoken:" + sprintf('%x-%x-%s', Time.now.to_i, Time.now.sec, resource.etag) raise Locked if resource.other_owner_locked?(locktoken, owner) # Quick & Dirty - FIXME: Lock should become a new Class # and this dirty parameter passing refactored. unless resource.lock(locktoken, timeout, lockscope, locktype, owner) raise Forbidden end response['Lock-Token'] = locktoken render_lockdiscovery locktoken, lockscope, locktype, timeout, owner end def refresh_lock(timeout) locktoken = request_locktoken('IF') raise BadRequest if locktoken.nil? timeout, lockscope, locktype, owner = resource.lock(locktoken, timeout) unless lockscope && locktype && timeout raise Forbidden end render_lockdiscovery locktoken, lockscope, locktype, timeout, owner end # FIXME add multiple locks support def render_lockdiscovery(locktoken, lockscope, locktype, timeout, owner) render_xml do |xml| xml.prop('xmlns' => "DAV:") do xml.lockdiscovery do render_lock(xml, locktoken, lockscope, locktype, timeout, owner) end end end end def render_lock(xml, locktoken, lockscope, locktype, timeout, owner) xml.activelock do xml.lockscope { xml.tag! lockscope } xml.locktype { xml.tag! locktype } xml.depth 'Infinity' if owner xml.owner { xml.href owner } end xml.timeout "Second-#{timeout}" xml.locktoken do xml.href locktoken end end end def rexml_convert(xml, element) if element.elements.empty? if element.text xml.send(element.name.to_sym, element.text, element.attributes) else xml.send(element.name.to_sym, element.attributes) end else xml.send(element.name.to_sym, element.attributes) do element.elements.each do |child| rexml_convert(xml, child) end end end end def parse_destination dest_uri destination = url_unescape(dest_uri.path) destination.slice!(1..@request.script_name.length) if @request.script_name.length > 0 destination end def url_format_for_response(resource) ret = URI.escape(resource.path) if resource.collection? and ret[-1,1] != '/' ret += '/' end ret end end end