# frozen_string_literal: true require "#{File.dirname(__FILE__)}/uri" require "#{File.dirname(__FILE__)}/destination_header" require "#{File.dirname(__FILE__)}/request" require "#{File.dirname(__FILE__)}/xml_elements" require "#{File.dirname(__FILE__)}/xml_response" module Dav4rack # Controller class Controller include Dav4rack::HttpStatus include Dav4rack::Utils attr_reader :request, :response, :resource # request:: Dav4rack::Request # response:: Rack::Response # options:: Options hash # Create a new Controller. # # options will be passed on to the resource def initialize(request, response, options = {}) @options = options @request = request @response = response @dav_extensions = options[:dav_extensions] @always_include_dav_header = !options[:always_include_dav_header].nil? @allow_unauthenticated_options_on_root = !options[:allow_unauthenticated_options_on_root].nil? setup_resource add_dav_header if @always_include_dav_header end # main entry point, called by the Handler def process status = skip_authorization? || authenticate ? process_action || OK : HttpStatus::Unauthorized rescue HttpStatus::Status => e status = e ensure if status response.status = status.code if status.code == 401 response.body = authentication_error_message response['WWW-Authenticate'] = "Basic realm=\"#{authentication_realm}\"" end end end private # delegates to the handler method matching this requests http method. # must return an HttpStatus. If nil / false, the resulting status will be # 200/OK def process_action send request.request_method.downcase end # if true, resource#authenticate will never be called def skip_authorization? # Microsoft Web Client workaround (from RedmineDmsf plugin): # # MS web client will not attempt any authentication (it'd seem) # until it's acknowledged a completed OPTIONS request. # # If the request method is OPTIONS return true, controller will simply # call the options method within, which accesses nothing, just returns # headers about the dav env. @allow_unauthenticated_options_on_root && request.request_method.casecmp('options').zero? && (request.path_info == '/' || request.path_info.empty?) end # Return response to OPTIONS def options status = resource.options request, response if status == OK add_dav_header response['Allow'] ||= 'OPTIONS,HEAD,GET,PUT,POST,DELETE,PROPFIND,PROPPATCH,MKCOL,COPY,MOVE,LOCK,UNLOCK' response['Ms-Author-Via'] ||= 'DAV' end status end # Return response to HEAD def head return NotFound unless resource.exist? res = resource.head(request, response) if res == OK 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 res end # Return response to GET def get return NotFound unless resource.exist? res = resource.get(request, response) if res == OK && !resource.collection? 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 res end # Return response to PUT def put if request.get_header('HTTP_CONTENT_RANGE') # An origin server that allows PUT on a given target resource MUST send # a 400 (Bad Request) response to a PUT request that contains a # Content-Range header field. # Reference: http://tools.ietf.org/html/rfc7231#section-4.3.4 Rails.logger.error 'Content-Range on PUT requests is forbidden.' BadRequest elsif resource.collection? Forbidden elsif !resource.parent.exist? || !resource.parent.collection? Conflict else resource.lock_check if resource.supports_locking? status = resource.put(request) response['Location'] ||= request.url_for resource.path if status == Created response.body = response['Location'] || '' status end end # Return response to POST def post resource.post request, response end # Return response to DELETE def delete return NotFound unless resource.exist? resource.lock_check if resource.supports_locking? resource.delete end # Return response to MKCOL def mkcol return UnsupportedMediaType if request.content_length.to_i.positive? return MethodNotAllowed if resource.exist? resource.lock_check if resource.supports_locking? status = resource.make_collection return status unless resource.use_compat_mkcol_response? url = request.url_for resource.path, collection: true r = XmlResponse.new(response, resource.namespaces) r.multistatus do |xml| xml << r.response(url, status) end MultiStatus end # Move Resource to new location. def move return NotFound unless resource.exist? return BadRequest unless request.depth == 'infinity' dest = request.destination return BadRequest unless dest status = dest.validate(host: request.host, resource_path: resource.path) return status if status resource.lock_check if resource.supports_locking? resource.move dest.path_info end # Return response to COPY def copy return NotFound unless resource.exist? return BadRequest unless request.depth == 'infinity' || request.depth == 0 dest = request.destination return BadRequest unless dest status = dest.validate(host: request.host, resource_path: resource.path) return status if status resource.copy dest.path_info end # Return response to PROPFIND def propfind return NotFound unless resource.exist? ns = request.ns document = request.document if request.content_length.to_i.positive? propfind = document.xpath("//#{ns}propfind") if document # propname request if propfind&.xpath("//#{ns}propname")&.first r = XmlResponse.new(response, resource.namespaces) r.multistatus do |xml| xml << Ox::Raw.new(resource.propnames_xml_with_depth) end return MultiStatus end properties = if propfind.blank? || propfind.xpath("//#{ns}allprop").first resource.properties elsif (prop = propfind.xpath("//#{ns}prop").first) prop.children .find_all(&:element?) .filter_map do |item| # We should do this, but Nokogiri transforms prefix w/ null href # into something valid. Oops. # TODO: Hacky grep fix that's horrible hsh = to_element_hash(item) if hsh.namespace.nil? && !ns.empty? && prop.to_s.scan(/<#{item.name}[^>]+xmlns=""/).empty? return BadRequest end hsh end else Rails.logger.error 'No properties found' raise BadRequest end properties = properties.map { |property| { element: property } } properties = resource.propfind_add_additional_properties(properties) prop_xml = resource.properties_xml_with_depth(get: properties) r = XmlResponse.new(response, resource.namespaces) r.multistatus do |xml| xml << r.raw(prop_xml) end MultiStatus end # Return response to PROPPATCH def proppatch return NotFound unless resource.exist? return BadRequest unless (doc = request.document) resource.lock_check if resource.supports_locking? properties = {} actions = %w[set remove] doc.xpath("/#{request.ns}propertyupdate").children.each do |element| action = element.name next unless actions.include?(action) properties[action] ||= [] next unless (prp = element.children.detect { |e| e.name == 'prop' }) prp.children.each do |elm| next if elm.name == 'text' properties[action] << { element: to_element_hash(elm), value: elm.text } end end r = XmlResponse.new(response, resource.namespaces) r.multistatus do |xml| xml << Ox::Raw.new(resource.properties_xml_with_depth(properties)) end MultiStatus end # Lock current resource # NOTE: This will pass an argument hash to Resource#lock and # wait for a success/failure response. def lock depth = request.depth return BadRequest unless %w[0 infinity].include?(depth) asked = { depth: depth } timeout = request.env['Timeout'] asked[:timeout] = timeout.split(',').map(&:strip) if timeout ns = request.ns doc = request.document lockinfo = doc&.xpath("//#{ns}lockinfo") if lockinfo asked[:scope] = lockinfo.xpath("//#{ns}lockscope").children.find_all(&:element?).map(&:name).first asked[:type] = lockinfo.xpath("#{ns}locktype").children.find_all(&:element?).map(&:name).first asked[:owner] = lockinfo.xpath("//#{ns}owner").children.map(&:name).first end r = XmlResponse.new(response, resource.namespaces) begin lock_time, locktoken = resource.lock(asked) r.render_lockdiscovery( time: lock_time, token: locktoken, depth: asked[:depth].to_s, scope: asked[:scope], type: asked[:type], owner: asked[:owner] ) response.headers['Lock-Token'] = "<#{locktoken}>" resource.exist? ? OK : Created rescue LockFailure => e begin resource.lock_check rescue StandardError => _ex return Locked end r.multistatus do |xml| e.path_status.each_pair do |path, status| xml << r.response(path, status) end end MultiStatus end end # Unlock current resource def unlock resource.unlock request.lock_token end # Perform authentication # # implement your authentication by overriding Resource#authenticate def authenticate uname = nil password = nil if request.authorization? auth = Rack::Auth::Basic::Request.new(request.env) if auth.basic? && auth.credentials uname = auth.credentials[0] password = auth.credentials[1] end end resource.authenticate uname, password end def authentication_error_message resource.authentication_error_message end def authentication_realm resource.authentication_realm end # override this for custom resource initialization def setup_resource @resource = resource_class.new(request.unescaped_path_info, request, response, @options) end # Class of the resource in use def resource_class @options[:resource_class] end def add_dav_header return if response['Dav'] dav_support = ['1'] if resource.supports_locking? # compliance is resource specific, only advertise 2 (locking) if # supported on the resource. dav_support << '2' end dav_support += @dav_extensions if @dav_extensions response['Dav'] = dav_support * ', ' end end end