430 lines
12 KiB
Ruby
430 lines
12 KiB
Ruby
require 'uri'
|
|
require 'dav4rack/destination_header'
|
|
require 'dav4rack/request'
|
|
require 'dav4rack/xml_elements'
|
|
require 'dav4rack/xml_response'
|
|
|
|
module DAV4Rack
|
|
|
|
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]
|
|
@allow_unauthenticated_options_on_root = !!options[:allow_unauthenticated_options_on_root]
|
|
|
|
setup_resource
|
|
|
|
if @always_include_dav_header
|
|
add_dav_header
|
|
end
|
|
end
|
|
|
|
|
|
# main entry point, called by the Handler
|
|
def process
|
|
if skip_authorization? || authenticate
|
|
status = process_action || OK
|
|
else
|
|
status = HTTPStatus::Unauthorized
|
|
end
|
|
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.
|
|
return @allow_unauthenticated_options_on_root &&
|
|
request.request_method.downcase == 'options' &&
|
|
(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
|
|
if(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
|
|
else
|
|
NotFound
|
|
end
|
|
end
|
|
|
|
# Return response to GET
|
|
def get
|
|
if(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
|
|
else
|
|
NotFound
|
|
end
|
|
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
|
|
Logger.error 'Content-Range on PUT requests is forbidden.'
|
|
BadRequest
|
|
elsif(resource.collection?)
|
|
Forbidden
|
|
elsif(!resource.parent_exists? || !resource.parent_collection?)
|
|
Conflict
|
|
else
|
|
resource.lock_check if resource.supports_locking?
|
|
status = resource.put(request, response)
|
|
if status == Created
|
|
response['Location'] ||= request.url_for resource.path
|
|
end
|
|
response.body = response['Location'] || ''
|
|
status
|
|
end
|
|
end
|
|
|
|
# Return response to POST
|
|
def post
|
|
resource.post(request, response)
|
|
end
|
|
|
|
# Return response to DELETE
|
|
def delete
|
|
if(resource.exist?)
|
|
resource.lock_check if resource.supports_locking?
|
|
resource.delete
|
|
else
|
|
NotFound
|
|
end
|
|
end
|
|
|
|
# Return response to MKCOL
|
|
def mkcol
|
|
if request.content_length.to_i > 0
|
|
return UnsupportedMediaType
|
|
end
|
|
return MethodNotAllowed if resource.exist?
|
|
|
|
resource.lock_check if resource.supports_locking?
|
|
status = resource.make_collection
|
|
|
|
if(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
|
|
else
|
|
status
|
|
end
|
|
end
|
|
|
|
|
|
# Move Resource to new location.
|
|
def move
|
|
return NotFound unless(resource.exist?)
|
|
return BadRequest unless request.depth == :infinity
|
|
|
|
return BadRequest unless dest = request.destination
|
|
if status = dest.validate(host: request.host,
|
|
resource_path: resource.path)
|
|
return status
|
|
end
|
|
|
|
resource.lock_check if resource.supports_locking?
|
|
|
|
return resource.move dest.path_info, request.overwrite?
|
|
end
|
|
|
|
|
|
# Return response to COPY
|
|
def copy
|
|
return NotFound unless(resource.exist?)
|
|
return BadRequest unless request.depth == :infinity or request.depth == 0
|
|
|
|
return BadRequest unless dest = request.destination
|
|
if status = dest.validate(host: request.host,
|
|
resource_path: resource.path)
|
|
return status
|
|
end
|
|
|
|
resource.copy dest.path_info, request.overwrite?, request.depth
|
|
end
|
|
|
|
# Return response to PROPFIND
|
|
def propfind
|
|
return NotFound unless resource.exist?
|
|
|
|
ns = request.ns
|
|
document = request.document if request.content_length.to_i > 0
|
|
propfind = document.xpath("//#{ns}propfind") if document
|
|
|
|
# propname request
|
|
if propfind and 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.nil? or
|
|
propfind.empty? or
|
|
propfind.xpath("//#{ns}allprop").first
|
|
|
|
resource.properties
|
|
|
|
elsif prop = propfind.xpath("//#{ns}prop").first
|
|
|
|
prop.children
|
|
.find_all{ |item| item.element? }
|
|
.map{ |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?)
|
|
return BadRequest if prop.to_s.scan(%r{<#{item.name}[^>]+xmlns=""}).empty?
|
|
end
|
|
hsh
|
|
}.compact
|
|
else
|
|
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 = {}
|
|
doc.xpath("/#{request.ns}propertyupdate").children.each do |element|
|
|
action = element.name
|
|
if action == 'set' || action == 'remove'
|
|
properties[action] ||= []
|
|
if 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
|
|
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 depth == 0 || depth == :infinity
|
|
|
|
asked = { depth: depth }
|
|
|
|
if timeout = request.env['Timeout']
|
|
asked[:timeout] = timeout.split(',').map{|x|x.strip}
|
|
end
|
|
|
|
Rails.logger.info ">>> #{request.document}"
|
|
|
|
ns = request.ns
|
|
if doc = request.document and lockinfo = doc.xpath("//#{ns}lockinfo")
|
|
|
|
asked[:scope] = lockinfo.xpath("//#{ns}lockscope").children.find_all{|n|n.element?}.map{|n|n.name}.first
|
|
asked[:type] = lockinfo.xpath("#{ns}locktype").children.find_all{|n|n.element?}.map{|n|n.name}.first
|
|
asked[:owner] = lockinfo.xpath("//#{ns}owner").children.map{|n|n.text}.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}>"
|
|
return resource.exist? ? OK : Created
|
|
|
|
rescue LockFailure => e
|
|
|
|
r.multistatus do |xml|
|
|
e.path_status.each_pair do |path, status|
|
|
xml << r.response(path, status)
|
|
end
|
|
end
|
|
|
|
return 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
|
|
unless @options[:resource_class]
|
|
require 'dav4rack/resources/file_resource'
|
|
@options[:resource_class] = FileResource
|
|
@options[:root] ||= Dir.pwd
|
|
end
|
|
|
|
@options[:resource_class]
|
|
end
|
|
|
|
|
|
def add_dav_header
|
|
unless 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
|
|
|
|
end
|