# frozen_string_literal: true require 'uuidtools' require File.dirname(__FILE__) + '/lock_store' require File.dirname(__FILE__) + '/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 # HTTP HEAD request. # # Like GET, but without content. Override if you set custom headers in GET # to set them here as well. def head(request, response) OK 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
Name Size Type Last 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