From a8ce29e2c056c5064518aac449326aafdce9ffc4 Mon Sep 17 00:00:00 2001 From: Karel Picman Date: Wed, 14 Mar 2018 17:35:40 +0100 Subject: [PATCH] Dav4Rack gem replaced with a local copy --- CHANGELOG.md | 4 + Gemfile | 11 +- lib/dav4rack.rb | 15 + lib/dav4rack/controller.rb | 426 +++++++++++++++ lib/dav4rack/destination_header.rb | 39 ++ lib/dav4rack/file.rb | 39 ++ lib/dav4rack/file_resource_lock.rb | 162 ++++++ lib/dav4rack/handler.rb | 80 +++ lib/dav4rack/http_status.rb | 100 ++++ lib/dav4rack/interceptor.rb | 22 + lib/dav4rack/interceptor_resource.rb | 119 ++++ lib/dav4rack/lock.rb | 40 ++ lib/dav4rack/lock_store.rb | 61 +++ lib/dav4rack/logger.rb | 30 ++ lib/dav4rack/remote_file.rb | 149 +++++ lib/dav4rack/request.rb | 191 +++++++ lib/dav4rack/resource.rb | 657 +++++++++++++++++++++++ lib/dav4rack/resources/file_resource.rb | 379 +++++++++++++ lib/dav4rack/resources/mongo_resource.rb | 268 +++++++++ lib/dav4rack/security_utils.rb | 24 + lib/dav4rack/utils.rb | 48 ++ lib/dav4rack/version.rb | 17 + lib/dav4rack/xml_elements.rb | 129 +++++ lib/dav4rack/xml_response.rb | 120 +++++ lib/redmine_dmsf.rb | 1 + 25 files changed, 3130 insertions(+), 1 deletion(-) create mode 100644 lib/dav4rack.rb create mode 100644 lib/dav4rack/controller.rb create mode 100644 lib/dav4rack/destination_header.rb create mode 100644 lib/dav4rack/file.rb create mode 100644 lib/dav4rack/file_resource_lock.rb create mode 100644 lib/dav4rack/handler.rb create mode 100644 lib/dav4rack/http_status.rb create mode 100644 lib/dav4rack/interceptor.rb create mode 100644 lib/dav4rack/interceptor_resource.rb create mode 100644 lib/dav4rack/lock.rb create mode 100644 lib/dav4rack/lock_store.rb create mode 100644 lib/dav4rack/logger.rb create mode 100644 lib/dav4rack/remote_file.rb create mode 100644 lib/dav4rack/request.rb create mode 100644 lib/dav4rack/resource.rb create mode 100644 lib/dav4rack/resources/file_resource.rb create mode 100644 lib/dav4rack/resources/mongo_resource.rb create mode 100644 lib/dav4rack/security_utils.rb create mode 100644 lib/dav4rack/utils.rb create mode 100644 lib/dav4rack/version.rb create mode 100644 lib/dav4rack/xml_elements.rb create mode 100644 lib/dav4rack/xml_response.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b632abd0..a71106e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Changelog for Redmine DMSF Javascript on pages is loaded asynchronously Obsolete Dav4Rack gem replaced with an up to date fork by Planio (Consequently WebDAV caching has been removed, sorry...) + Instead of usage their gem in the Gemfile: + gem 'dav4rack', git: 'https://github.com/planio-gmbh/dav4rack.git', branch: 'master' + The library is a part of the project (lib/dav4rack). + Cloned from gem https://github.com/planio-gmbh/dav4rack.git Project members can be chosen as recipients when sending documents by email Responsive view Direct editing of document in MS Office diff --git a/Gemfile b/Gemfile index a3d2492d..9779cd11 100644 --- a/Gemfile +++ b/Gemfile @@ -26,8 +26,17 @@ gem 'rubyzip', '>= 1.0.0' gem 'zip-zip' gem 'simple_enum' gem 'uuidtools' -gem 'dav4rack', git: 'https://github.com/planio-gmbh/dav4rack.git', branch: 'master' gem 'dalli' + +# Redmine extensions unless %w(easyproject easy_gantt).any? { |plugin| Dir.exist?(File.expand_path("../../#{plugin}", __FILE__)) } gem 'redmine_extensions', '~> 0.2.5' end + +# Dav4Rack +gem 'rake' +gem 'mongo' +gem 'unicorn' +gem 'byebug', platforms: :mri +gem 'ruby-prof' +gem 'ox' diff --git a/lib/dav4rack.rb b/lib/dav4rack.rb new file mode 100644 index 00000000..d96dcc53 --- /dev/null +++ b/lib/dav4rack.rb @@ -0,0 +1,15 @@ +require 'time' +require 'uri' +require 'nokogiri' +require 'ox' + +require 'rack' +require 'dav4rack/utils' +require 'dav4rack/http_status' +require 'dav4rack/resource' +require 'dav4rack/handler' +require 'dav4rack/controller' + +module DAV4Rack + IS_18 = RUBY_VERSION[0,3] == '1.8' +end diff --git a/lib/dav4rack/controller.rb b/lib/dav4rack/controller.rb new file mode 100644 index 00000000..f75086dd --- /dev/null +++ b/lib/dav4rack/controller.rb @@ -0,0 +1,426 @@ +# frozen_string_literal: true + +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?) + 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 + resource.head(request, response) + OK + 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 + 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 + + 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/#{ns}href").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 diff --git a/lib/dav4rack/destination_header.rb b/lib/dav4rack/destination_header.rb new file mode 100644 index 00000000..fe43e7e1 --- /dev/null +++ b/lib/dav4rack/destination_header.rb @@ -0,0 +1,39 @@ +require 'addressable/uri' + +module DAV4Rack + class DestinationHeader + + attr_reader :host, :path, :path_info + + def initialize(value, script_name: nil) + @script_name = script_name.to_s + @value = value.to_s.strip + parse + end + + def parse + uri = Addressable::URI.parse @value + + @host = uri.host + @path = Addressable::URI.unencode uri.path + + if @script_name + if @path =~ /\A(?#{Regexp.escape @script_name}(?\/.*))\z/ + @path_info = $~[:path_info] + else + raise ArgumentError, 'invalid destination header value' + end + end + end + + # host must be the same, but path must differ + def validate(host: nil, resource_path: nil) + if host and self.host and self.host != host + DAV4Rack::HTTPStatus::BadGateway + elsif self.path == resource_path + DAV4Rack::HTTPStatus::Forbidden + end + end + + end +end diff --git a/lib/dav4rack/file.rb b/lib/dav4rack/file.rb new file mode 100644 index 00000000..57bc59da --- /dev/null +++ b/lib/dav4rack/file.rb @@ -0,0 +1,39 @@ +require 'time' +require 'rack/utils' +require 'rack/mime' + +module DAV4Rack + # DAV4Rack::File simply allows us to use Rack::File but with the + # specific location we deem appropriate + # + # FIXME is that used anywhere? + class File < Rack::File + attr_accessor :path + + alias :to_path :path + + def initialize(path) + @path = path + end + + def _call(env) + begin + if F.file?(@path) && F.readable?(@path) + serving(env) + else + raise Errno::EPERM + end + rescue SystemCallError + not_found + end + end + + def not_found + body = "File not found: #{Rack::Utils.unescape(env["PATH_INFO"])}\n" + [404, {"Content-Type" => "text/plain", + "Content-Length" => body.size.to_s, + "X-Cascade" => "pass"}, + [body]] + end + end +end diff --git a/lib/dav4rack/file_resource_lock.rb b/lib/dav4rack/file_resource_lock.rb new file mode 100644 index 00000000..0421089a --- /dev/null +++ b/lib/dav4rack/file_resource_lock.rb @@ -0,0 +1,162 @@ +require 'pstore' + +module DAV4Rack + class FileResourceLock + attr_accessor :path + attr_accessor :token + attr_accessor :timeout + attr_accessor :depth + attr_accessor :user + attr_accessor :scope + attr_accessor :kind + attr_accessor :owner + attr_reader :created_at + attr_reader :root + + class << self + def explicitly_locked?(path, croot=nil) + store = init_pstore(croot) + !!store.transaction(true){ + store[:paths][path] + } + end + + def implicitly_locked?(path, croot=nil) + store = init_pstore(croot) + !!store.transaction(true){ + store[:paths].keys.detect do |check| + check.start_with?(path) + end + } + end + + def explicit_locks(path, croot, args={}) + end + + def implicit_locks(path, croot) + end + + def find_by_path(path, croot=nil) + lock = self.class.new(:path => path, :root => croot) + lock.token.nil? ? nil : lock + end + + def find_by_token(token, croot=nil) + store = init_pstore(croot) + struct = store.transaction(true){ + store[:tokens][token] + } + if(struct) + new(path: struct[:path], root: croot) + else + nil + end + end + + def generate(path, user, token, croot) + lock = self.new(:root => croot) + lock.user = user + lock.path = path + lock.token = token + lock.save + lock + end + + def root=(path) + @root = path + end + + def root + @root || '/tmp/dav4rack' + end + + def init_pstore(croot) + path = File.join(croot, '.attribs', 'locks.pstore') + FileUtils.mkdir_p(File.dirname(path)) unless File.directory?(File.dirname(path)) + store = IS_18 ? PStore.new(path) : PStore.new(path, true) + store.transaction do + unless(store[:paths]) + store[:paths] = {} + store[:tokens] = {} + store.commit + end + end + store + end + end + + def initialize(args={}) + @path = args[:path] + @root = args[:root] + @owner = args[:owner] + @store = init_pstore(@root) + @max_timeout = args[:max_timeout] || 86400 + @default_timeout = args[:max_timeout] || 60 + load_if_exists! + @new_record = true if token.nil? + end + + def owner?(user) + user == owner + end + + def reload + load_if_exists + self + end + + def remaining_timeout + t = timeout.to_i - (Time.now.to_i - created_at.to_i) + t < 0 ? 0 : t + end + + def save + struct = { + :path => path, + :token => token, + :timeout => timeout, + :depth => depth, + :created_at => Time.now, + :owner => owner + } + @store.transaction do + @store[:paths][path] = struct + @store[:tokens][token] = struct + @store.commit + end + @new_record = false + self + end + + def destroy + @store.transaction do + @store[:paths].delete(path) + @store[:tokens].delete(token) + @store.commit + end + nil + end + + private + + def load_if_exists! + struct = @store.transaction do + @store[:paths][path] + end + if(struct) + @path = struct[:path] + @token = struct[:token] + @timeout = struct[:timeout] + @depth = struct[:depth] + @created_at = struct[:created_at] + @owner = struct[:owner] + end + self + end + + def init_pstore(croot=nil) + self.class.init_pstore(croot || @root) + end + + end +end diff --git a/lib/dav4rack/handler.rb b/lib/dav4rack/handler.rb new file mode 100644 index 00000000..ce67589a --- /dev/null +++ b/lib/dav4rack/handler.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'dav4rack/logger' + +module DAV4Rack + class Handler + include DAV4Rack::HTTPStatus + + # Options: + # + # - resource_class: your Resource implementation + # - controller_class: custom Controller implementation (optional). + # - root_uri_path: Path the handler is mapped to. Any resources + # instantiated will only see the part of the path below this root. + # - recursive_propfind_allowed (true) : set to false to disable + # potentially expensive Depth: Infinity propfinds + # - all options are passed on to your resource implementation and are + # accessible there as @options. + # + def initialize(options={}) + @options = options.dup + + Logger.set(*@options[:log_to]) + end + + def call(env) + start = Time.now + request = setup_request env + response = Rack::Response.new + + Logger.info "Processing WebDAV request: #{request.path} (for #{request.ip} at #{Time.now}) [#{request.request_method}]" + + controller = setup_controller request, response + controller.process + postprocess_response response + + # Apache wants the body dealt with, so just read it and junk it + buf = true + buf = request.body.read(8192) while buf + + if Logger.debug? and response.body.is_a?(String) + Logger.debug "Response String:\n#{response.body}" + end + Logger.info "Completed in: #{((Time.now.to_f - start.to_f) * 1000).to_i} ms | #{response.status} [#{request.url}]" + + response.finish + + rescue Exception => e + Logger.error "WebDAV Error: #{e}" + raise e + end + + + private + + + def postprocess_response(response) + if response.body.is_a?(String) + response['Content-Length'] ||= response.body.length.to_s + end + response.body = [response.body] unless response.body.respond_to?(:each) + end + + + def setup_request(env) + ::DAV4Rack::Request.new env, @options + end + + + def setup_controller(request, response) + controller_class.new(request, response, @options) + end + + def controller_class + @options[:controller_class] || ::DAV4Rack::Controller + end + + end + +end diff --git a/lib/dav4rack/http_status.rb b/lib/dav4rack/http_status.rb new file mode 100644 index 00000000..24a41211 --- /dev/null +++ b/lib/dav4rack/http_status.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module DAV4Rack + + module HTTPStatus + + class Status < Exception + + class << self + attr_accessor :code, :reason_phrase + alias_method :to_i, :code + + def status_line + "#{code} #{reason_phrase}" + end + + end + + def code + self.class.code + end + + def reason_phrase + self.class.reason_phrase + end + + def status_line + self.class.status_line + end + + def to_i + self.class.to_i + end + + end + + StatusMessage = { + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Large', + 415 => 'Unsupported Media Type', + 416 => 'Request Range Not Satisfiable', + 417 => 'Expectation Failed', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 507 => 'Insufficient Storage' + } + + StatusClasses = { } + + StatusMessage.each do |code, reason_phrase| + klass = Class.new(Status) + klass.code = code + klass.reason_phrase = reason_phrase + klass_name = reason_phrase.gsub(/[ \-]/,'') + const_set(klass_name, klass) + StatusClasses[code] = klass + end + + end + +end + diff --git a/lib/dav4rack/interceptor.rb b/lib/dav4rack/interceptor.rb new file mode 100644 index 00000000..d22d922b --- /dev/null +++ b/lib/dav4rack/interceptor.rb @@ -0,0 +1,22 @@ +require 'dav4rack/interceptor_resource' +module DAV4Rack + class Interceptor + def initialize(app, args={}) + @roots = args[:mappings].keys + @args = args + @app = app + @intercept_methods = %w(OPTIONS PROPFIND PROPPATCH MKCOL COPY MOVE LOCK UNLOCK) + @intercept_methods -= args[:ignore_methods] if args[:ignore_methods] + end + + def call(env) + path = env['PATH_INFO'].downcase + method = env['REQUEST_METHOD'].upcase + app = nil + if(@roots.detect{|x| path =~ /^#{Regexp.escape(x.downcase)}\/?/}.nil? && @intercept_methods.include?(method)) + app = DAV4Rack::Handler.new(:resource_class => InterceptorResource, :mappings => @args[:mappings], :log_to => @args[:log_to]) + end + app ? app.call(env) : @app.call(env) + end + end +end \ No newline at end of file diff --git a/lib/dav4rack/interceptor_resource.rb b/lib/dav4rack/interceptor_resource.rb new file mode 100644 index 00000000..00e502d3 --- /dev/null +++ b/lib/dav4rack/interceptor_resource.rb @@ -0,0 +1,119 @@ +require 'digest/sha1' + +module DAV4Rack + + class InterceptorResource < Resource + attr_reader :path, :options + + def initialize(*args) + super + @root_paths = @options[:mappings].keys + @mappings = @options[:mappings] + end + + def children + childs = @root_paths.find_all{|x|x =~ /^#{Regexp.escape(@path)}/} + childs = childs.map{|a| child a.gsub(/^#{Regexp.escape(@path)}/, '').split('/').delete_if{|x|x.empty?}.first }.flatten + end + + def collection? + true if exist? + end + + def exist? + !@root_paths.find_all{|x| x =~ /^#{Regexp.escape(@path)}/}.empty? + end + + def creation_date + Time.now + end + + def last_modified + Time.now + end + + def last_modified=(time) + Time.now + end + + def etag + Digest::SHA1.hexdigest(@path) + end + + def content_type + 'text/html' + end + + def content_length + 0 + end + + def get(request, response) + raise Forbidden + end + + def put(request, response) + raise Forbidden + end + + def post(request, response) + raise Forbidden + end + + def delete + raise Forbidden + end + + def copy(dest) + raise Forbidden + end + + def move(dest) + raise Forbidden + end + + def make_collection + raise Forbidden + end + + def ==(other) + path == other.path + end + + def name + ::File.basename(path) + end + + def display_name + ::File.basename(path.to_s) + end + + def child(name, option={}) + new_path = path.dup + new_path = '/' + new_path unless new_path[0,1] == '/' + new_path.slice!(-1) if new_path[-1,1] == '/' + name = '/' + name unless name[-1,1] == '/' + new_path = "#{new_path}#{name}" + new_public = public_path.dup + new_public = '/' + new_public unless new_public[0,1] == '/' + new_public.slice!(-1) if new_public[-1,1] == '/' + new_public = "#{new_public}#{name}" + if(key = @root_paths.find{|x| new_path =~ /^#{Regexp.escape(x.downcase)}\/?/}) + @mappings[key][:resource_class].new(new_public, new_path.gsub(key, ''), request, response, {:root_uri_path => key, :user => @user}.merge(options).merge(@mappings[key])) + else + self.class.new(new_public, new_path, request, response, {:user => @user}.merge(options)) + end + end + + def descendants + list = [] + children.each do |child| + list << child + list.concat(child.descendants) + end + list + end + + end + +end diff --git a/lib/dav4rack/lock.rb b/lib/dav4rack/lock.rb new file mode 100644 index 00000000..e37c0d06 --- /dev/null +++ b/lib/dav4rack/lock.rb @@ -0,0 +1,40 @@ +module DAV4Rack + class Lock + + def initialize(args={}) + @args = args + @store = nil + @args[:created_at] = Time.now + @args[:updated_at] = Time.now + end + + def store + @store + end + + def store=(s) + raise TypeError.new 'Expecting LockStore' unless s.respond_to? :remove + @store = s + end + + def destroy + if(@store) + @store.remove(self) + end + end + + def remaining_timeout + @args[:timeout].to_i - (Time.now.to_i - @args[:created_at].to_i) + end + + def method_missing(*args) + if(@args.has_key?(args.first.to_sym)) + @args[args.first.to_sym] + elsif(args.first.to_s[-1,1] == '=') + @args[args.first.to_s[0, args.first.to_s.length - 1].to_sym] = args[1] + else + raise NoMethodError.new "Undefined method #{args.first} for #{self}" + end + end + end +end \ No newline at end of file diff --git a/lib/dav4rack/lock_store.rb b/lib/dav4rack/lock_store.rb new file mode 100644 index 00000000..76f7e82e --- /dev/null +++ b/lib/dav4rack/lock_store.rb @@ -0,0 +1,61 @@ +require 'dav4rack/lock' +module DAV4Rack + class LockStore + class << self + def create + @locks_by_path = {} + @locks_by_token = {} + end + def add(lock) + @locks_by_path[lock.path] = lock + @locks_by_token[lock.token] = lock + end + + def remove(lock) + @locks_by_path.delete(lock.path) + @locks_by_token.delete(lock.token) + end + + def find_by_path(path) + @locks_by_path.map do |lpath, lock| + lpath == path && lock.remaining_timeout > 0 ? lock : nil + end.compact.first + end + + def find_by_token(token) + @locks_by_token.map do |ltoken, lock| + ltoken == token && lock.remaining_timeout > 0 ? lock : nil + end.compact.first + end + + def explicit_locks(path) + @locks_by_path.map do |lpath, lock| + lpath == path && lock.remaining_timeout > 0 ? lock : nil + end.compact + end + + def implicit_locks(path) + @locks_by_path.map do |lpath, lock| + lpath =~ /^#{Regexp.escape(path)}/ && lock.remaining_timeout > 0 && lock.depth > 0 ? lock : nil + end.compact + end + + def explicitly_locked?(path) + self.explicit_locks(path).size > 0 + end + + def implicitly_locked?(path) + self.implicit_locks(path).size > 0 + end + + def generate(path, user, token) + l = Lock.new(:path => path, :user => user, :token => token) + l.store = self + add(l) + l + end + end + end +end + +DAV4Rack::LockStore.create \ No newline at end of file diff --git a/lib/dav4rack/logger.rb b/lib/dav4rack/logger.rb new file mode 100644 index 00000000..64f708be --- /dev/null +++ b/lib/dav4rack/logger.rb @@ -0,0 +1,30 @@ +require 'logger' + +module DAV4Rack + # This is a simple wrapper for the Logger class. It allows easy access + # to log messages from the library. + class Logger + class << self + # args:: Arguments for Logger -> [path, level] (level is optional) or a Logger instance + # Set the path to the log file. + def set(*args) + if(%w(info debug warn fatal).all?{|meth| args.first.respond_to?(meth)}) + @@logger = args.first + elsif(args.first.respond_to?(:to_s) && !args.first.to_s.empty?) + @@logger = ::Logger.new(args.first.to_s, 'weekly') + elsif(args.first) + raise 'Invalid type specified for logger' + end + if(args.size > 1) + @@logger.level = args[1] + end + end + + def method_missing(*args) + if(defined? @@logger) + @@logger.send *args + end + end + end + end +end diff --git a/lib/dav4rack/remote_file.rb b/lib/dav4rack/remote_file.rb new file mode 100644 index 00000000..8bcbde7f --- /dev/null +++ b/lib/dav4rack/remote_file.rb @@ -0,0 +1,149 @@ +require 'net/http' +require 'uri' +require 'digest/sha1' +require 'rack/file' + +module DAV4Rack + + #FIXME unused? + class RemoteFile < Rack::File + + attr_accessor :path + + alias :to_path :path + + # path:: path to file (Actual path, preferably a URL since this is a *REMOTE* file) + # args:: Hash of arguments: + # :size -> Integer - number of bytes + # :mime_type -> String - mime type + # :last_modified -> String/Time - Time of last modification + # :sendfile -> True or String to define sendfile header variation + # :cache_directory -> Where to store cached files + # :cache_ref -> Reference to be used for cache file name (useful for changing URLs like S3) + # :sendfile_prefix -> String directory prefix. Eg: 'webdav' will result in: /wedav/#{path.sub('http://', '')} + # :sendfile_fail_gracefully -> Boolean if true will simply proxy if unable to determine proper sendfile + def initialize(path, args={}) + @path = path + @args = args + @heads = {} + @cache_file = args[:cache_directory] ? cache_file_path : nil + @redefine_prefix = nil + if(@cache_file && File.exists?(@cache_file)) + @root = '' + @path_info = @cache_file + @path = @path_info + elsif(args[:sendfile]) + @redefine_prefix = 'sendfile' + @sendfile_header = args[:sendfile].is_a?(String) ? args[:sendfile] : nil + else + setup_remote + end + do_redefines(@redefine_prefix) if @redefine_prefix + end + + # env:: Environment variable hash + # Process the call + def call(env) + serving(env) + end + + # env:: Environment variable hash + # Return an empty result with the proper header information + def sendfile_serving(env) + header = @sendfile_header || env['sendfile.type'] || env['HTTP_X_SENDFILE_TYPE'] + unless(header) + raise 'Failed to determine proper sendfile header value' unless @args[:sendfile_fail_gracefully] + setup_remote + do_redefines('remote') + call(env) + end + prefix = (@args[:sendfile_prefix] || env['HTTP_X_ACCEL_REMOTE_MAPPING']).to_s.sub(/^\//, '').sub(/\/$/, '') + [200, { + "Last-Modified" => last_modified, + "Content-Type" => content_type, + "Content-Length" => size, + "Redirect-URL" => @path, + "Redirect-Host" => @path.scan(%r{^https?://([^/\?]+)}).first.first, + header => "/#{prefix}" + }, + ['']] + end + + # env:: Environment variable hash + # Return self to be processed + def remote_serving(e) + [200, { + "Last-Modified" => last_modified, + "Content-Type" => content_type, + "Content-Length" => size + }, self] + end + + # Get the remote file + def remote_each + if(@store) + yield @store + else + @con.request_get(@call_path) do |res| + res.read_body(@store) do |part| + @cf.write part if @cf + yield part + end + end + end + end + + # Size based on remote headers or given size + def size + @heads['content-length'] || @size.to_s + end + + private + + # Content type based on provided or remote headers + def content_type + @mime_type || @heads['content-type'] + end + + # Last modified type based on provided, remote headers or current time + def last_modified + @heads['last-modified'] || @modified || Time.now.httpdate + end + + # Builds the path for the cached file + def cache_file_path + raise IOError.new 'Write permission is required for cache directory' unless File.writable?(@args[:cache_directory]) + "#{@args[:cache_directory]}/#{Digest::SHA1.hexdigest((@args[:cache_ref] || @path).to_s + size.to_s + last_modified.to_s)}.cache" + end + + # prefix:: prefix of methods to be redefined + # Redefine methods to do what we want in the proper situation + def do_redefines(prefix) + self.public_methods.each do |method| + m = method.to_s.dup + next unless m.slice!(0, prefix.to_s.length + 1) == "#{prefix}_" + self.class.class_eval "undef :'#{m}'" + self.class.class_eval "alias :'#{m}' :'#{method}'" + end + end + + # Sets up all the requirements for proxying a remote file + def setup_remote + if(@cache_file) + begin + @cf = File.open(@cache_file, 'w+') + rescue + @cf = nil + end + end + @uri = URI.parse(@path) + @con = Net::HTTP.new(@uri.host, @uri.port) + @call_path = @uri.path + (@uri.query ? "?#{@uri.query}" : '') + res = @con.request_get(@call_path) + @heads = res.to_hash + res.value + @store = nil + @redefine_prefix = 'remote' + end + end +end diff --git a/lib/dav4rack/request.rb b/lib/dav4rack/request.rb new file mode 100644 index 00000000..7851988b --- /dev/null +++ b/lib/dav4rack/request.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require 'uri' +require 'addressable/uri' +require 'dav4rack/logger' + +module DAV4Rack + class Request < Rack::Request + + # Root URI path for the resource + attr_reader :root_uri_path + + # options: + # + # recursive_propfind_allowed (true) : set to false to disable + # potentially expensive recursive propfinds + # + def initialize(env, options = {}) + super env + @options = { recursive_propfind_allowed: true }.merge options + sanitize_path_info + end + + + def authorization? + !!env['HTTP_AUTHORIZATION'] + end + + # path relative to root uri + def unescaped_path_info + @unescaped_path_info ||= self.class.unescape_path path_info + end + + # the full path (script_name aka rack mount point + path_info) + def unescaped_path + @unescaped_path ||= self.class.unescape_path path + end + + def self.unescape_path(p) + p = p.dup + p.force_encoding 'UTF-8' + Addressable::URI.unencode p + end + + + # Namespace being used within XML document + def ns(wanted_uri = XmlElements::DAV_NAMESPACE) + if document and + root = document.root and + ns_defs = root.namespace_definitions and + ns_defs.size > 0 + + result = ns_defs.detect{ |nd| nd.href == wanted_uri } || ns_defs.first + result = result.prefix.nil? ? 'xmlns' : result.prefix.to_s + result += ':' unless result.empty? + result + else + '' + end + end + + + + # Lock token if provided by client + def lock_token + get_header 'HTTP_LOCK_TOKEN' + end + + + # Requested depth + def depth + @http_depth ||= begin + if d = get_header('HTTP_DEPTH') and (d == '0' or d == '1') + d.to_i + elsif infinity_depth_allowed? + :infinity + else + 1 + end + end + end + + + # Destination header + def destination + @destination ||= if h = get_header('HTTP_DESTINATION') + DestinationHeader.new h, script_name: script_name + end + end + + + # Overwrite is allowed + def overwrite? + get_header('HTTP_OVERWRITE').to_s.upcase != 'F' + end + + # content_length as a Fixnum (nil if the header is unset / empty) + def content_length + if length = (super || get_header('HTTP_X_EXPECTED_ENTITY_LENGTH')) + length.to_i + end + end + + # parsed XML request body if any (Nokogiri XML doc) + def document + @request_document ||= parse_request_body if content_length && content_length > 0 + end + + # builds a URL for path using this requests scheme, host, port and + # script_name + # path must be valid UTF-8 and will be url encoded by this method + def url_for(rel_path, collection: false) + path = path_for rel_path, collection: collection + "#{scheme}://#{host}:#{port}#{path}" + end + + # returns an url encoded, absolute path for the given relative path + def path_for(rel_path, collection: false) + path = Addressable::URI.encode_component rel_path, Addressable::URI::CharacterClasses::PATH + if collection and path[-1] != ?/ + path << '/' + end + "#{script_name}#{expand_path path}" + end + + # expands '/foo/../bar' to '/bar' + def expand_path(path) + path.squeeze! '/' + path = Addressable::URI.normalize_component path, Addressable::URI::CharacterClasses::PATH + URI("http://example.com/").merge(path).path + end + + + REDIRECTABLE_CLIENTS = [ + /cyberduck/i, + /konqueror/i + ] + + # Does client allow GET redirection + # TODO: Get a comprehensive list in here. + # TODO: Allow this to be dynamic so users can add regexes to match if they know of a client + # that can be supported that is not listed. + def client_allows_redirect? + ua = self.user_agent + REDIRECTABLE_CLIENTS.any? { |re| ua =~ re } + end + + + MS_CLIENTS = [ + /microsoft-webdav/i, + /microsoft office/i + ] + + # Basic user agent testing for MS authored client + def is_ms_client? + ua = self.user_agent + MS_CLIENTS.any? { |re| ua =~ re } + end + + def get_header(name) + @env[name] + end + + private + + # true if Depth: Infinity is allowed for this request. + # + # http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND + # Servers ... should support "infinity" requests. In practice, + # support for infinite-depth requests may be disabled, due to the + # performance and security concerns associated with this behavior + def infinity_depth_allowed? + request_method != 'PROPFIND' or @options[:recursive_propfind_allowed] + end + + def sanitize_path_info + self.path_info.force_encoding 'UTF-8' + self.path_info = expand_path path_info + end + + def parse_request_body + return Nokogiri.XML(body.read){ |config| + config.strict + } if body + rescue + DAV4Rack::Logger.error $!.message + raise ::DAV4Rack::HTTPStatus::BadRequest + end + + end +end diff --git a/lib/dav4rack/resource.rb b/lib/dav4rack/resource.rb new file mode 100644 index 00000000..d6f2c609 --- /dev/null +++ b/lib/dav4rack/resource.rb @@ -0,0 +1,657 @@ +# frozen_string_literal: true + +require 'uuidtools' +require 'dav4rack/lock_store' +require 'dav4rack/xml_elements' + +module DAV4Rack + + class LockFailure < RuntimeError + attr_reader :path_status + def initialize(*args) + super(*args) + @path_status = {} + end + + def add_failure(path, status) + @path_status[path] = status + end + end + + class Resource + include DAV4Rack::Utils + include DAV4Rack::XmlElements + + attr_reader :path, :request, + :response, :propstat_relative_path, :root_xml_attributes, :namespaces + attr_accessor :user + @@blocks = {} + + class << self + + # This lets us define a bunch of before and after blocks that are + # either called before all methods on the resource, or only specific + # methods on the resource + def method_missing(*args, &block) + class_sym = self.name.to_sym + @@blocks[class_sym] ||= {:before => {}, :after => {}} + m = args.shift + parts = m.to_s.split('_') + type = parts.shift.to_s.to_sym + method = parts.empty? ? nil : parts.join('_').to_sym + if(@@blocks[class_sym][type] && block_given?) + if(method) + @@blocks[class_sym][type][method] ||= [] + @@blocks[class_sym][type][method] << block + else + @@blocks[class_sym][type][:'__all__'] ||= [] + @@blocks[class_sym][type][:'__all__'] << block + end + else + raise NoMethodError.new("Undefined method #{m} for class #{self}") + end + end + + end + + include DAV4Rack::HTTPStatus + + # path:: Internal resource path (unescaped PATH_INFO) + # request:: Rack::Request + # options:: Any options provided for this resource + # Creates a new instance of the resource. + # NOTE: path and public_path will only differ if the root_uri has been set for the resource. The + # controller will strip out the starting path so the resource can easily determine what + # it is working on. For example: + # request -> /my/webdav/directory/actual/path + # public_path -> /my/webdav/directory/actual/path + # path -> /actual/path + # NOTE: Customized Resources should not use initialize for setup. Instead + # use the #setup method + def initialize(path, request, response, options) + if path.nil? || path.empty? || path[0] != ?/ + raise ArgumentError, 'path must be present and start with a /' + end + @path = path + + @propstat_relative_path = !!options[:propstat_relative_path] + @root_xml_attributes = options.delete(:root_xml_attributes) || {} + @namespaces = (options[:namespaces] || {}).merge({DAV_NAMESPACE => DAV_NAMESPACE_NAME}) + @request = request + @response = response + unless(options.has_key?(:lock_class)) + @lock_class = LockStore + else + @lock_class = options[:lock_class] + raise NameError.new("Unknown lock type constant provided: #{@lock_class}") unless @lock_class.nil? || defined?(@lock_class) + end + @options = options + @max_timeout = options[:max_timeout] || 86400 + @default_timeout = options[:default_timeout] || 60 + @user = @options[:user] || request.ip + + setup + end + + # returns a new instance for the given path + def new_for_path(path) + self.class.new path, request, response, + @options.merge(user: @user, namespaces: @namespaces) + end + + # override to implement custom authentication + # should return true for successful authentication, false otherwise + def authenticate(username, password) + true + end + + def authentication_error_message + 'Not Authorized' + end + + def authentication_realm + 'Locked content' + end + + + # override in child classes for custom setup + def setup + end + private :setup + + + # Returns if resource supports locking + def supports_locking? + false #true + end + + # Returns supported lock types (an array of [lockscope, locktype] pairs) + # i.e. [%w(D:exclusive D:write)] + def supported_locks + [] + end + + # If this is a collection, return the child resources. + def children + NotImplemented + end + + # Is this resource a collection? + def collection? + NotImplemented + end + + # Does this resource exist? + def exist? + NotImplemented + end + + # Does the parent resource exist? + def parent_exists? + parent.exist? + end + + # Is the parent resource a collection? + def parent_collection? + parent.collection? + end + + # Return the creation time. + def creation_date + raise NotImplemented + end + + # Return the time of last modification. + def last_modified + raise NotImplemented + end + + # Set the time of last modification. + def last_modified=(time) + # Is this correct? + raise NotImplemented + end + + # Return an Etag, an unique hash value for this resource. + def etag + raise NotImplemented + end + + # Return the resource type. Generally only used to specify + # resource is a collection. + def resource_type + :collection if collection? + end + + # Return the mime type of this resource. + def content_type + raise NotImplemented + end + + # Return the size in bytes for this resource. + def content_length + raise NotImplemented + end + + # HTTP OPTIONS request. + # resources should override this to set the Allow header to indicate the + # allowed methods. By default, all WebDAV methods are advertised on all + # resources. + def options(request, response) + OK + end + + # HTTP GET request. + # + # Write the content of the resource to the response.body. + def get(request, response) + NotImplemented + end + + def head(request, response) + #no-op, but called by the controller + end + + # HTTP PUT request. + # + # Save the content of the request.body. + def put(request, response) + NotImplemented + end + + # HTTP POST request. + # + # Usually forbidden. + def post(request, response) + NotImplemented + end + + # HTTP DELETE request. + # + # Delete this resource. + def delete + NotImplemented + end + + # HTTP COPY request. + # + # Copy this resource to given destination path. + def copy(dest_path, overwrite = false, depth = nil) + NotImplemented + end + + # HTTP MOVE request. + # + # Move this resource to given destination path. + def move(dest_path, overwrite=false) + NotImplemented + end + + # args:: Hash of lock arguments + # Request for a lock on the given resource. A valid lock should lock + # all descendents. Failures should be noted and returned as an exception + # using LockFailure. + # Valid args keys: :timeout -> requested timeout + # :depth -> lock depth + # :scope -> lock scope + # :type -> lock type + # :owner -> lock owner + # Should return a tuple: [lock_time, locktoken] where lock_time is the + # given timeout + # NOTE: See section 9.10 of RFC 4918 for guidance about + # how locks should be generated and the expected responses + # (http://www.webdav.org/specs/rfc4918.html#rfc.section.9.10) + + def lock(args) + raise NotImplemented unless @lock_class + raise Conflict unless parent_exists? + + lock_check(args[:scope]) + lock = @lock_class.explicit_locks(@path).find{|l| l.scope == args[:scope] && l.kind == args[:type] && l.user == @user} + unless(lock) + token = UUIDTools::UUID.random_create.to_s + lock = @lock_class.generate(@path, @user, token) + lock.scope = args[:scope] + lock.kind = args[:type] + lock.owner = args[:owner] + lock.depth = args[:depth].is_a?(Symbol) ? args[:depth] : args[:depth].to_i + if(args[:timeout]) + lock.timeout = args[:timeout] <= @max_timeout && args[:timeout] > 0 ? args[:timeout] : @max_timeout + else + lock.timeout = @default_timeout + end + lock.save if lock.respond_to? :save + end + begin + lock_check(args[:type]) + rescue DAV4Rack::LockFailure => lock_failure + lock.destroy + raise lock_failure + rescue HTTPStatus::Status => status + status + end + [lock.remaining_timeout, lock.token] + end + + # lock_scope:: scope of lock + # Check if resource is locked. Raise DAV4Rack::LockFailure if locks are in place. + def lock_check(lock_scope=nil) + return unless @lock_class + if(@lock_class.explicitly_locked?(@path)) + raise Locked if @lock_class.explicit_locks(@path).find_all{|l|l.scope == 'exclusive' && l.user != @user}.size > 0 + elsif(@lock_class.implicitly_locked?(@path)) + if(lock_scope.to_s == 'exclusive') + locks = @lock_class.implicit_locks(@path) + failure = DAV4Rack::LockFailure.new("Failed to lock: #{@path}") + locks.each do |lock| + failure.add_failure(@path, Locked) + end + raise failure + else + locks = @lock_class.implict_locks(@path).find_all{|l| l.scope == 'exclusive' && l.user != @user} + if(locks.size > 0) + failure = LockFailure.new("Failed to lock: #{@path}") + locks.each do |lock| + failure.add_failure(@path, Locked) + end + raise failure + end + end + end + end + + # token:: Lock token + # Remove the given lock + def unlock(token) + return NotImplemented unless @lock_class + + token = token.slice(1, token.length - 2) + if(token.nil? || token.empty?) + BadRequest + else + lock = @lock_class.find_by_token(token) + if(lock.nil? || lock.user != @user) + Forbidden + elsif(lock.path !~ /^#{Regexp.escape(@path)}.*$/) + Conflict + else + lock.destroy + NoContent + end + end + end + + + # Create this resource as collection. + def make_collection + NotImplemented + end + + # other:: Resource + # Returns if current resource is equal to other resource + def ==(other) + path == other.path + end + + # Name of the resource + def name + File.basename(path) + end + + # Name of the resource to be displayed to the client + def display_name + name + end + + + # Available properties + # + # These are returned by PROPFIND without body, or with an allprop body. + DAV_PROPERTIES = %w( + getetag + resourcetype + getcontenttype + getcontentlength + getlastmodified + creationdate + displayname + ).map{|prop| { name: prop, ns_href: DAV_NAMESPACE } }.freeze + + def properties + props = DAV_PROPERTIES + if supports_locking? + props = props.dup # do not attempt to modify the (frozen) constant + props << { name: 'supportedlock', ns_href: DAV_NAMESPACE } + end + props + end + + # Properties to be returned for PROPFIND + # + # this should include the names of all properties defined on the resource + def propname_properties + props = self.properties + if supports_locking? + props = props.dup if props.frozen? + props << { name: 'lockdiscovery', ns_href: DAV_NAMESPACE } + end + props + end + + # name:: String - Property name + # Returns the value of the given property + def get_property(element) + return NotFound if (element[:ns_href] != DAV_NAMESPACE) + case element[:name] + when 'resourcetype' then resource_type + when 'displayname' then display_name + when 'creationdate' then use_ms_compat_creationdate? ? creation_date.httpdate : creation_date.xmlschema + when 'getcontentlength' then content_length.to_s + when 'getcontenttype' then content_type + when 'getetag' then etag + when 'getlastmodified' then last_modified.httpdate + when 'supportedlock' then supported_locks_xml + when 'lockdiscovery' then lockdiscovery_xml + else NotImplemented + end + end + + # name:: String - Property name + # value:: New value + # Set the property to the given value + # + # This default implementation does not allow any properties to be changed. + def set_property(element, value) + return Forbidden + end + + # name:: Property name + # Remove the property from the resource + def remove_property(element) + Forbidden + end + + # name:: Name of child + # Create a new child with the given name + # NOTE:: Include trailing '/' if child is collection + def child(name) + new_path = @path.dup + new_path << ?/ unless new_path[-1] == ?/ + new_path << name + new_for_path new_path + end + + # Return parent of this resource + def parent + return nil if @path == '/' + unless @path.to_s.empty? + new_for_path File.split(@path).first + end + end + + # Return list of descendants + def descendants + list = [] + children.each do |child| + list << child + list.concat(child.descendants) + end + list + end + + # Index page template for GETs on collection + def index_page + ' %s + +

%s


+ + %s
NameSize TypeLast Modified

' + end + + def properties_xml_with_depth(process_properties, depth = request.depth) + xml_with_depth self, depth do |element, ox_doc| + ox_doc << element.properties_xml(process_properties) + end + end + + def propnames_xml_with_depth(depth = request.depth) + xml_with_depth self, depth do |element, ox_doc| + ox_doc << element.propnames_xml + end + end + + # Returns a complete URL for this resource. + # If the propstat_relative_path option is set, just an absolute path will + # be returned. + # If this is a collection, the result will end with a '/' + def href + @href ||= build_href(path, collection: self.collection?) + end + + # Returns a complete URL for the given path. + # + # If the propstat_relative_path option is set, just an absolute path will + # be returned. If the :collection argument is true, the returned path will + # end with a '/' + def build_href(path, collection: false) + if propstat_relative_path + request.path_for path, collection: collection + else + request.url_for path, collection: collection + end + end + + def propnames_xml + response = Ox::Element.new(D_RESPONSE) + response << ox_element(D_HREF, href) + propstats response, { OK => Hash[propname_properties.map{|p| [p,nil]}] } + response + end + + def properties_xml(process_properties) + response = Ox::Element.new(D_RESPONSE) + response << ox_element(D_HREF, href) + + process_properties.each do |type, properties| + propstats(response, self.send("#{type}_properties_with_status",properties)) + end + response + end + + def supported_locks_xml + supported_locks.map do |scope, type| + ox_lockentry scope, type + end + end + + # array of lock info hashes + # required keys are :time, :token, :depth + # other valid keys are :scope, :type, :root and :owner + def lockdiscovery + [] + end + + # returns an array of activelock ox elements + def lockdiscovery_xml + if supports_locking? + lockdiscovery.map do |lock| + ox_activelock(**lock) + end + end + end + + def get_properties_with_status(properties) + stats = Hash.new { |h, k| h[k] = [] } + properties.each do |property| + val = self.get_property(property[:element]) + if val.is_a?(Class) + stats[val] << property[:element] + else + stats[OK] << [property[:element], val] + end + end + stats + end + + def set_properties_with_status(properties) + stats = Hash.new { |h, k| h[k] = [] } + properties.each do |property| + val = self.set_property(property[:element], property[:value]) + if val.is_a?(Class) + stats[val] << property[:element] + else + stats[OK] << [property[:element], val] + end + end + stats + end + + + # resource:: Resource + # elements:: Property hashes (name, namespace, children) + # Removes the given properties from a resource + def remove_properties_with_status(properties) + stats = Hash.new { |h, k| h[k] = [] } + properties.each do |property| + val = self.remove_property(property[:element]) + if val.is_a?(Class) + stats[val] << property[:element] + else + stats[OK] << [property[:element], val] + end + end + stats + end + + + # adds the given xml namespace to namespaces and returns the prefix + def add_namespace(ns, prefix = "unknown#{rand 65536}") + return nil if ns.nil? || ns.empty? + unless namespaces.key? ns + namespaces[ns] = prefix + return prefix + end + end + + + # returns the prefix for the given namespace, adding it if necessary + def prefix_for(ns_href) + namespaces[ns_href] || add_namespace(ns_href) + end + + + # response:: parent Ox::Element + # stats:: Array of stats + # Build propstats response + def propstats(response, stats) + return if stats.empty? + stats.each do |status, props| + propstat = Ox::Element.new(D_PROPSTAT) + prop = Ox::Element.new(D_PROP) + + props.each do |element, value| + + name = element[:name] + if prefix = prefix_for(element[:ns_href]) + name = "#{prefix}:#{name}" + end + + prop_element = Ox::Element.new(name) + ox_append prop_element, value, prefix: prefix + prop << prop_element + + end + + propstat << prop + propstat << ox_element(D_STATUS, "#{http_version} #{status.status_line}") + + response << propstat + end + end + + + def use_compat_mkcol_response? + @options[:compat_mkcol] || @options[:compat_all] + end + + # Returns true if using an MS client + def use_ms_compat_creationdate? + if(@options[:compat_ms_mangled_creationdate] || @options[:compat_all]) + request.is_ms_client? + end + end + + + # Callback function that adds additional properties to the propfind REQUEST + # These properties will then be parsed and processed as though they were sent + # by the client. This makes sure we can add whatever property we want + # to the response and make it look like the client asked for them. + def propfind_add_additional_properties(properties) + # Default implementation doesn't need to add anything + properties + end + + + end + +end diff --git a/lib/dav4rack/resources/file_resource.rb b/lib/dav4rack/resources/file_resource.rb new file mode 100644 index 00000000..c48a26c8 --- /dev/null +++ b/lib/dav4rack/resources/file_resource.rb @@ -0,0 +1,379 @@ +# encoding: UTF-8 +# frozen_string_literal: true + +require 'pstore' +require 'webrick/httputils' +require 'dav4rack/file_resource_lock' +require 'dav4rack/security_utils' + +module DAV4Rack + + class FileResource < Resource + + include WEBrick::HTTPUtils + include DAV4Rack::Utils + + # If this is a collection, return the child resources. + def children + Dir[file_path + '/*'].map do |path| + child ::File.basename(path) + end + end + + # Is this resource a collection? + def collection? + ::File.directory?(file_path) + end + + # Does this recource exist? + def exist? + ::File.exist?(file_path) + end + + # Return the creation time. + def creation_date + stat.ctime + end + + # Return the time of last modification. + def last_modified + stat.mtime + end + + # Set the time of last modification. + def last_modified=(time) + ::File.utime(Time.now, time, file_path) + end + + # Return an Etag, an unique hash value for this resource. + def etag + sprintf('%x-%x-%x', stat.ino, stat.size, stat.mtime.to_i) + end + + # Return the mime type of this resource. + def content_type + if stat.directory? + "text/html" + else + mime_type(file_path, DefaultMimeTypes) + end + end + + # Return the size in bytes for this resource. + def content_length + stat.size + end + + # HTTP GET request. + # + # Write the content of the resource to the response.body. + def get(request, response) + return NotFound unless exist? + if stat.directory? + response.body = "".dup + Rack::Directory.new(root).call(request.env)[2].each do |line| + response.body << line + end + response['Content-Length'] = response.body.bytesize.to_s + OK + else + status, headers, body = Rack::File.new(root).call(request.env) + headers.each do |k, v| + response[k] = v + end + response.body = body + StatusClasses[status] + end + end + + # HTTP PUT request. + # + # Save the content of the request.body. + def put(request, response) + write(request.body) + Created + end + + # HTTP POST request. + # + # Usually forbidden. + def post(request, response) + raise HTTPStatus::Forbidden + end + + # HTTP DELETE request. + # + # Delete this resource. + def delete + if stat.directory? + FileUtils.rm_rf(file_path) + else + ::File.unlink(file_path) + end + ::File.unlink(prop_path) if ::File.exist?(prop_path) + @_prop_hash = nil + NoContent + end + + # HTTP COPY request. + # + # Copy this resource to given destination path. + def copy(dest_path, overwrite, depth = nil) + + is_new = true + + dest = new_for_path dest_path + unless dest.parent.exist? and dest.parent.collection? + return Conflict + end + + if dest.exist? + if overwrite + FileUtils.rm_r dest.file_path, secure: true + else + return PreconditionFailed + end + is_new = false + end + + if collection? + + if request.depth == 0 + Dir.mkdir dest.file_path + else + FileUtils.cp_r(file_path, dest.file_path) + end + + else + + FileUtils.cp(file_path, dest.file_path.sub(/\/$/, '')) + FileUtils.cp(prop_path, dest.prop_path) if ::File.exist? prop_path + + end + + is_new ? Created : NoContent + end + + # HTTP MOVE request. + # + # Move this resource to given destination resource. + def move(*args) + result = copy(*args) + delete if [Created, NoContent].include?(result) + result + end + + # HTTP MKCOL request. + # + # Create this resource as collection. + def make_collection + if(request.body.read.to_s == '') + if(::File.directory?(file_path)) + MethodNotAllowed + else + if(::File.directory?(::File.dirname(file_path)) && !::File.exist?(file_path)) + Dir.mkdir(file_path) + Created + else + Conflict + end + end + else + UnsupportedMediaType + end + end + + # Write to this resource from given IO. + def write(io) + tempfile = "#{file_path}.#{Process.pid}.#{object_id}" + open(tempfile, "wb") do |file| + while part = io.read(8192) + file << part + end + end + ::File.rename(tempfile, file_path) + ensure + ::File.unlink(tempfile) rescue nil + end + + # name:: String - Property name + # Returns the value of the given property + def get_property(name) + if name[:ns_href] == DAV_NAMESPACE + super + else + custom_props(name) + end + end + + # name:: String - Property name + # value:: New value + # Set the property to the given value + def set_property(name, value) + # let Resource handle DAV properties + if name[:ns_href] == DAV_NAMESPACE + super + else + set_custom_props name, value + end + end + + def remove_property(element) + prop_hash.transaction do + prop_hash.delete(to_element_key(element)) + prop_hash.commit + end + val = prop_hash.transaction{ prop_hash[to_element_key(element)] } + end + + def lock(args) + unless(parent_exists?) + Conflict + else + lock_check(args[:type]) + lock = FileResourceLock.explicit_locks(@path, root, :scope => args[:scope], :kind => args[:type], :user => @user) + unless(lock) + token = UUIDTools::UUID.random_create.to_s + lock = FileResourceLock.generate(@path, @user, token, root) + lock.scope = args[:scope] + lock.kind = args[:type] + lock.owner = args[:owner] + lock.depth = args[:depth] + if(args[:timeout]) + lock.timeout = args[:timeout] <= @max_timeout && args[:timeout] > 0 ? args[:timeout] : @max_timeout + else + lock.timeout = @default_timeout + end + lock.save + end + begin + lock_check(args[:type]) + rescue DAV4Rack::LockFailure => lock_failure + lock.destroy + raise lock_failure + rescue HTTPStatus::Status => status + status + end + [lock.remaining_timeout, lock.token] + end + end + + def unlock(token) + token = token.slice(1, token.length - 2) + if(token.nil? || token.empty?) + BadRequest + else + lock = FileResourceLock.find_by_token(token, root) + if(lock.nil? || lock.user != @user) + Forbidden + elsif(lock.path !~ /^#{Regexp.escape(@path)}.*$/) + Conflict + else + lock.destroy + NoContent + end + end + end + + def authenticate(user, pass) + if(@options[:username]) + # This comparison uses & so that it doesn't short circuit and + # uses `variable_size_secure_compare` so that length information + # isn't leaked. + SecurityUtils.variable_size_secure_compare( + user, @options[:username] + ) & + SecurityUtils.variable_size_secure_compare( + pass, @options[:password] + ) + else + true + end + end + + protected + + def file_path + ::File.expand_path ::File.join(root, path) + end + + def prop_path + path = ::File.join(store_directory, "#{::File.join(::File.dirname(file_path), ::File.basename(file_path)).gsub('/', '_')}.pstore") + unless(::File.directory?(::File.dirname(path))) + FileUtils.mkdir_p(::File.dirname(path)) + end + path + end + + private + + def lock_check(lock_type=nil) + if(FileResourceLock.explicitly_locked?(@path, root)) + raise Locked if lock_type && lock_type == 'exclusive' + #raise Locked if FileResourceLock.explicit_locks(@path, root).find(:all, :conditions => ["scope = 'exclusive' AND user_id != ?", @user.id]).size > 0 + elsif(FileResourceLock.implicitly_locked?(@path, root)) + if(lock_type.to_s == 'exclusive') + locks = FileResourceLock.implicit_locks(@path) + failure = DAV4Rack::LockFailure.new("Failed to lock: #{@path}") + locks.each do |lock| + failure.add_failure(@path, Locked) + end + raise failure + else + locks = FileResourceLock.implict_locks(@path).find(:all, :conditions => ["scope = 'exclusive' AND user_id != ?", @user.id]) + if(locks.size > 0) + failure = LockFailure.new("Failed to lock: #{@path}") + locks.each do |lock| + failure.add_failure(@path, Locked) + end + raise failure + end + end + end + end + + def set_custom_props(element, val) + prop_hash.transaction do + prop_hash[to_element_key(element)] = val + prop_hash.commit + end + end + + def custom_props(element) + val = prop_hash.transaction(true) do + prop_hash[to_element_key(element)] + end + val || NotFound + end + + def store_directory + path = ::File.join(root, '.attrib_store') + unless(::File.directory?(::File.dirname(path))) + FileUtils.mkdir_p(::File.dirname(path)) + end + path + end + + def lock_path + path = ::File.join(store_directory, 'locks.pstore') + unless(::File.directory?(::File.dirname(path))) + FileUtils.mkdir_p(::File.dirname(path)) + end + path + end + + def prop_hash + @_prop_hash ||= IS_18 ? PStore.new(prop_path) : PStore.new(prop_path, true) + end + + def root + @options[:root] + end + + def stat + @stat ||= ::File.stat(file_path) + end + + end + +end diff --git a/lib/dav4rack/resources/mongo_resource.rb b/lib/dav4rack/resources/mongo_resource.rb new file mode 100644 index 00000000..3ac6429c --- /dev/null +++ b/lib/dav4rack/resources/mongo_resource.rb @@ -0,0 +1,268 @@ +# coding: utf-8 +# frozen_string_literal: true + +require 'mime/types' +require 'mongo' +require 'erb' +require 'dav4rack/security_utils' + +module DAV4Rack + + class MongoResource < DAV4Rack::Resource + + def self.database=(db) + @database = db + end + def self.database; @database end + + + def setup + @filesystem = Mongo::Grid::FSBucket.new(self.class.database) + if @options[:bson] + @bson = @options[:bson] + elsif path.empty? || path == '/' + @bson = {'filename' => '/'} + else + @bson = @filesystem.find(filename: /^#{Regexp.escape(path)}\/?$/).first rescue nil + end + end + + def child(bson) + our_options = @options.dup + @options[:bson] = bson + child = new_for_path bson['filename'] + @options = our_options + child + end + + # If this is a collection, return the child resources. + def children + @filesystem.find(filename: /^#{Regexp.escape(@bson['filename'])}[^\/]+\/?$/).map do |bson| + child bson + end + end + + # Is this resource a collection? + def collection? + @bson && _collection?(@bson['filename']) + end + + # Does this recource exist? + def exist? + !!@bson + end + + # Return the creation time. + def creation_date + @bson['uploadDate'] || Date.new + end + + # Return the time of last modification. + def last_modified + @bson['uploadDate'] || Date.new + end + + # Set the time of last modification. + def last_modified=(time) + end + + # Return an Etag, an unique hash value for this resource. + def etag + @bson['_id'].to_s + end + + # Return the mime type of this resource. + def content_type + @bson['contentType'] || "text/html" + end + + # Return the size in bytes for this resource. + def content_length + @bson['length'] || 0 + end + + # HTTP GET request. + # + # Write the content of the resource to the response.body. + def get(request, response) + return NotFound unless exist? + + if collection? + response.body = "".dup + response.body << "

" + ERB::Util.html_escape(path) + "

" + children.each do |child| + name = ERB::Util.html_escape child.name + + path = ERB::Util.html_escape request.path_for(child.path, + collection: child.collection?) + response.body << "" + name + "" + response.body << "
" + end + response.body << "" + response['Content-Type'] = 'text/html' + else + @filesystem.open_download_stream_by_name(path, revision: -1) do |s| + # not sure if the copying is necessary, but keeping the reference to + # s outside the block seems unsafe as we dont know if/when that + # stream will be closed + #response.body = s + response.body = s.read + response['Content-Type'] = @bson['contentType'] + end + end + OK + rescue Mongo::Error::InvalidFileRevision + NotFound + end + + # HTTP PUT request. + # + # Save the content of the request.body. + def put(request, response) + exists = exist? + + @filesystem.open_upload_stream( + path, content_type: content_type_for(path) + ) { |f| f.write request.body } + + exists ? NoContent : Created + end + + # HTTP POST request. + # + # Usually forbidden. + def post(request, response) + raise HTTPStatus::Forbidden + end + + # HTTP DELETE request. + # + # Delete this resource. + def delete + if collection? + @filesystem.find(filename: /^#{Regexp.escape(@bson['filename'])}/).each do |bson| + @filesystem.delete(bson['_id']) + end + else + @filesystem.delete(@bson['_id']) + end + NoContent + end + + # HTTP COPY request. + # + # Copy this resource to given destination path. + def copy(dest_path, overwrite = false, depth = :infinity) + dest = new_for_path dest_path + dest.collection! if collection? + + src = @bson['filename'] + dst = dest.path + exists = @filesystem.find(filename: /^#{Regexp.escape(dst)}/).any? + + if overwrite + @filesystem.find(filename: /^#{Regexp.escape(dst)}/).each do |bson| + @filesystem.delete bson['_id'] + end + elsif @filesystem.find(filename: /^#{Regexp.escape(dst)}/).any? + return PreconditionFailed + end + + @filesystem.find(filename: /^#{Regexp.escape(src)}/).each do |bson| + src_name = bson['filename'] + dst_name = dst + src_name.slice(src.length, src_name.length) + + @filesystem.open_download_stream_by_name(src_name) do |i| + @filesystem.open_upload_stream(dst_name, + content_type: bson['contentType']) do |o| + i.each{|chunk| o.write chunk } + end + end + end + + exists ? NoContent : Created + end + + # HTTP MOVE request. + # + # Move this resource to given destination path. + def move(dest_path, overwrite = false) + dest = new_for_path dest_path + dest.collection! if collection? + + src = @bson['filename'] + dst = dest.path + exists = @filesystem.find(filename: /^#{Regexp.escape(dst)}/).any? + + if overwrite + @filesystem.find(filename: /^#{Regexp.escape(dst)}/).each do |bson| + @filesystem.delete bson['_id'] + end + elsif @filesystem.find(filename: /^#{Regexp.escape(dst)}/).any? + return PreconditionFailed + end + + @filesystem.find(filename: /^#{Regexp.escape(src)}/).each do |bson| + src_name = bson['filename'] + dst_name = dst + src_name.slice(src.length, src_name.length) + + # http://mongoid.org/docs/persistence/atomic.html + # http://rubydoc.info/github/mongoid/mongoid/master/Mongoid/Collection#update-instance_method + mongo_collection.find_one_and_update({'_id' => bson['_id']}, {'$set' => {'filename' => dst_name}}, safe: true) + + end + + exists ? NoContent : Created + end + + # HTTP MKCOL request. + # + # Create this resource as collection. + def make_collection + + if @filesystem.find(filename: path).any? + raise 'resource exists' + end + collection! + @filesystem.open_upload_stream(path) { |f| } + + Created + end + + def collection! + path << '/' unless _collection?(path) + end + + def authenticate(user, pass) + if(@options[:username]) + # This comparison uses & so that it doesn't short circuit and + # uses `variable_size_secure_compare` so that length information + # isn't leaked. + SecurityUtils.variable_size_secure_compare( + user, @options[:username] + ) & + SecurityUtils.variable_size_secure_compare( + pass, @options[:password] + ) + else + true + end + end + + private + + def mongo_collection + @mongo_collection ||= self.class.database['fs.files'] + end + + def content_type_for(filename) + MIME::Types.type_for(filename).first.to_s || 'text/html' + end + + def _collection?(path) + path && path.end_with?('/') + end + + end + +end diff --git a/lib/dav4rack/security_utils.rb b/lib/dav4rack/security_utils.rb new file mode 100644 index 00000000..bcf704b9 --- /dev/null +++ b/lib/dav4rack/security_utils.rb @@ -0,0 +1,24 @@ +require 'digest' + +module DAV4Rack + + # Implements secure string comparison methods. + # Taken straight from ActiveSupport + module SecurityUtils + def secure_compare(a, b) + return false unless a.bytesize == b.bytesize + + l = a.unpack "C#{a.bytesize}" + + res = 0 + b.each_byte { |byte| res |= byte ^ l.shift } + res == 0 + end + module_function :secure_compare + + def variable_size_secure_compare(a, b) + secure_compare(::Digest::SHA256.hexdigest(a), ::Digest::SHA256.hexdigest(b)) + end + module_function :variable_size_secure_compare + end +end diff --git a/lib/dav4rack/utils.rb b/lib/dav4rack/utils.rb new file mode 100644 index 00000000..fae44c00 --- /dev/null +++ b/lib/dav4rack/utils.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'ostruct' + +module DAV4Rack + + # Simple wrapper for formatted elements + class DAVElement < OpenStruct + def [](key) + self.send(key) + end + end + + module Utils + DEFAULT_HTTP_VERSION = 'HTTP/1.1' + + def to_element_hash(element) + ns = element.namespace + DAVElement.new( + :namespace => ns, + :name => element.name, + :ns_href => (ns.href if ns), + :children => element.children.collect{|e| + to_element_hash(e) if e.element? + }.compact, + :attributes => attributes_hash(element) + ) + end + + def to_element_key(element) + ns = element.namespace + "#{ns.href if ns}!!#{element.name}" + end + + def http_version + DEFAULT_HTTP_VERSION + end + + private + def attributes_hash(node) + node.attributes.inject({}) do |ret, (key,attr)| + ret[attr.name] = attr.value + ret + end + end + end + +end diff --git a/lib/dav4rack/version.rb b/lib/dav4rack/version.rb new file mode 100644 index 00000000..221a3e9a --- /dev/null +++ b/lib/dav4rack/version.rb @@ -0,0 +1,17 @@ +module DAV4Rack + class Version + + attr_reader :major, :minor, :tiny + + def initialize(version) + version = version.split('.') + @major, @minor, @tiny = [version[0].to_i, version[1].to_i, version[2].to_i] + end + + def to_s + "#{@major}.#{@minor}.#{@tiny}" + end + end + + VERSION = Version.new('1.0.0') +end diff --git a/lib/dav4rack/xml_elements.rb b/lib/dav4rack/xml_elements.rb new file mode 100644 index 00000000..6827b8e6 --- /dev/null +++ b/lib/dav4rack/xml_elements.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module DAV4Rack + module XmlElements + + DAV_NAMESPACE = 'DAV:' + DAV_NAMESPACE_NAME = 'd' + DAV_XML_NS = 'xmlns:d' + + XML_VERSION = '1.0' + XML_CONTENT_TYPE = 'application/xml; charset=utf-8' + + %w( + activelock + depth + error + href + lockdiscovery + lockentry + lockroot + lockscope + locktoken + lock-token-submitted + locktype + multistatus + owner + prop + propstat + response + status + timeout + ).each do |name| + const_set "D_#{name.upcase.gsub('-', '_')}", "#{DAV_NAMESPACE_NAME}:#{name}" + end + + INFINITY = 'infinity' + ZERO = '0' + + def ox_element(name, content = nil) + e = Ox::Element.new(name) + if content + e << content + end + e + end + + def ox_append(element, value, prefix: DAV_NAMESPACE_NAME) + case value + when Ox::Element + element << value + when Symbol + element << Ox::Element.new("#{prefix}:#{value}") + when Enumerable + value.each{|v| ox_append element, v, prefix: prefix } + else + element << value.to_s if value + end + end + + def ox_lockentry(scope, type) + Ox::Element.new(D_LOCKENTRY).tap do |e| + e << ox_element(D_LOCKSCOPE, Ox::Element.new(scope)) + e << ox_element(D_LOCKTYPE, Ox::Element.new(type)) + end + end + + def ox_response(path, status) + Ox::Element.new(D_RESPONSE).tap do |e| + # path = "#{scheme}://#{host}:#{port}#{URI.escape(path)}" + e << ox_element(D_HREF, path) + e << ox_element(D_STATUS, "#{http_version} #{status.status_line}") + end + end + + # returns an activelock Ox::Element for the given lock data + def ox_activelock(time: nil, token:, depth:, + scope: nil, type: nil, owner: nil, root: nil) + + Ox::Element.new(D_ACTIVELOCK).tap do |activelock| + if scope + activelock << ox_element(D_LOCKSCOPE, scope) + end + if type + activelock << ox_element(D_LOCKTYPE, type) + end + activelock << ox_element(D_DEPTH, depth) + activelock << ox_element(D_TIMEOUT, + (time ? "Second-#{time}" : INFINITY)) + + token = ox_element(D_HREF, token) + activelock << ox_element(D_LOCKTOKEN, token) + + if owner + activelock << ox_element(D_OWNER, owner) + end + + if root + root = ox_element(D_HREF, root) + activelock << ox_element(D_LOCKROOT, root) + end + end + + end + + # block is called for each element (at least self, depending on depth also + # with children / further descendants) + def xml_with_depth(resource, depth, &block) + partial_document = Ox::Document.new() + + yield resource, partial_document + + case depth + when 0 + when 1 + resource.children.each do |child| + yield child, partial_document + end + else + resource.descendants.each do |desc| + yield desc, partial_document + end + end + + Ox.dump(partial_document, {indent: -1}) + end + + + end +end diff --git a/lib/dav4rack/xml_response.rb b/lib/dav4rack/xml_response.rb new file mode 100644 index 00000000..88dd0277 --- /dev/null +++ b/lib/dav4rack/xml_response.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module DAV4Rack + class XmlResponse + include XmlElements + + def initialize(response, namespaces, http_version: 'HTTP/1.1') + @response = response + @namespaces = namespaces + @http_version = http_version + end + + def render_xml(xml_body) + @namespaces.each do |href, prefix| + xml_body["xmlns:#{prefix}"] = href + end + + xml_doc = Ox::Document.new(:version => '1.0') + xml_doc << xml_body + + @response.body = Ox.dump(xml_doc, {indent: -1, with_xml: true}) + + @response["Content-Type"] = 'application/xml; charset=utf-8' + @response["Content-Length"] = @response.body.bytesize.to_s + end + + + def multistatus + multistatus = Ox::Element.new(D_MULTISTATUS) + yield multistatus + render_xml multistatus + end + + + def render_failed_precondition(status, href) + error = Ox::Element.new(D_ERROR) + case status.code + when 423 + l = Ox::Element.new(D_LOCK_TOKEN_SUBMITTED) + l << ox_element(D_HREF, href) + error << l + end + render_xml error + end + + + def render_lock_errors(errors) + multistatus do |xml| + errors.each do |href, status| + r = response href, status + if status.code == 423 + r << ox_element(D_ERROR, Ox::Element.new(D_LOCK_TOKEN_SUBMITTED)) + end + xml << r + end + end + end + + + def render_lockdiscovery(*args) + render_xml ox_element(D_PROP, + ox_element(D_LOCKDISCOVERY, + activelock(*args)) + ) + end + + + # + # helpers for creating single elements + # + + + # returns an activelock Ox::Element for the given lock data + def activelock(time: nil, token:, depth:, + scope: nil, type: nil, owner: nil, root: nil) + + Ox::Element.new(D_ACTIVELOCK).tap do |activelock| + if scope + activelock << ox_element(D_LOCKSCOPE, scope) + end + if type + activelock << ox_element(D_LOCKTYPE, type) + end + activelock << ox_element(D_DEPTH, depth) + activelock << ox_element(D_TIMEOUT, + (time ? "Second-#{time}" : INFINITY)) + + token = ox_element(D_HREF, token) + activelock << ox_element(D_LOCKTOKEN, token) + + if owner + activelock << ox_element(D_OWNER, owner) + end + + if root + root = ox_element(D_HREF, root) + activelock << ox_element(D_LOCKROOT, root) + end + end + + end + + def response(href, status) + r = Ox::Element.new(D_RESPONSE) + r << ox_element(D_HREF, href) + r << self.status(status) + r + end + + def raw(xml) + Ox::Raw.new xml + end + + def status(status) + ox_element D_STATUS, "#{@http_version} #{status.status_line}" + end + + + end +end diff --git a/lib/redmine_dmsf.rb b/lib/redmine_dmsf.rb index c5219537..7c753ee6 100644 --- a/lib/redmine_dmsf.rb +++ b/lib/redmine_dmsf.rb @@ -41,6 +41,7 @@ if defined?(EasyExtensions) end # Load up classes that make up our WebDAV solution ontop of DAV4Rack +require 'dav4rack' require 'redmine_dmsf/webdav/custom_middleware' require 'redmine_dmsf/webdav/base_resource' require 'redmine_dmsf/webdav/dmsf_resource'