diff --git a/.rubocop.yml b/.rubocop.yml index 6d76c37..46f09b6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,9 +1,10 @@ AllCops: TargetRubyVersion: 2.3 Exclude: - - 'spec/**/*' + - 'spec/dummy/**/*' Metrics/BlockLength: Exclude: + - 'spec/**/*' - 'Rakefile' Metrics/ClassLength: Exclude: @@ -15,6 +16,8 @@ Metrics/AbcSize: Metrics/LineLength: Exclude: - 'lib/calligraphy/rails/mapper.rb' + - 'spec/spec_helper.rb' + - 'spec/rails_helper.rb' Metrics/MethodLength: Exclude: - 'lib/calligraphy/rails/mapper.rb' diff --git a/lib/calligraphy/rails/web_dav_methods.rb b/lib/calligraphy/rails/web_dav_methods.rb index c122714..f9de239 100644 --- a/lib/calligraphy/rails/web_dav_methods.rb +++ b/lib/calligraphy/rails/web_dav_methods.rb @@ -44,7 +44,12 @@ module Calligraphy end def mkcol - Calligraphy::Mkcol.new(web_dav_request).execute + mkcol_request = Calligraphy::Mkcol.new(web_dav_request) + + precondition_response = mkcol_request.preconditions + return precondition_response unless precondition_response.nil? + + mkcol_request.execute end def propfind diff --git a/lib/calligraphy/resource/file_resource.rb b/lib/calligraphy/resource/file_resource.rb index 655cffb..74371d8 100644 --- a/lib/calligraphy/resource/file_resource.rb +++ b/lib/calligraphy/resource/file_resource.rb @@ -44,6 +44,23 @@ module Calligraphy File.directory? @src_path end + # Responsible for creating a duplicate of the resource in + # `options[:destination]` (see section 9.8 of RFC4918). + # + # Used in COPY and MOVE (which inherits from COPY) requests. + def copy(options) + destination = copy_destination options + to_path = join_paths @root_dir, destination + to_path_exists = File.exist? to_path + + preserve_existing = false? options[:overwrite] + + copy_resource_to_path to_path, preserve_existing + copy_pstore_to_path to_path, preserve_existing + + to_path_exists + end + # Responsible for returning a hash with keys indicating if the resource # can be copied, if an ancestor exists, or if the copy destinatin is # locked. @@ -65,23 +82,6 @@ module Calligraphy copy_options end - # Responsible for creating a duplicate of the resource in - # `options[:destination]` (see section 9.8 of RFC4918). - # - # Used in COPY and MOVE (which inherits from COPY) requests. - def copy(options) - destination = copy_destination options - to_path = join_paths @root_dir, destination - to_path_exists = File.exist? to_path - - preserve_existing = false? options[:overwrite] - - copy_resource_to_path to_path, preserve_existing - copy_pstore_to_path to_path, preserve_existing - - to_path_exists - end - # Responsible for creating a new collection based on the resource (see # section 9.3 of RFC4918). # @@ -99,6 +99,12 @@ module Calligraphy FileUtils.rm_r @store_path if store_exist? end + # Responsible for returning a boolean indicating whether the resource + # supports Extended MKCOL (see RFC5689). + def enable_extended_mkcol? + true + end + # Responsible for returning unique identifier used to create an etag. # # Used in precondition validation, as well as GET, HEAD, and PROPFIND @@ -185,6 +191,8 @@ module Calligraphy # # Used in PROPPATCH requests. def proppatch(nodes) + init_pstore unless exists? + actions = { set: [], remove: [] } @store.transaction do @@ -584,6 +592,8 @@ module Calligraphy def add_properties(node, actions) node.children.each do |prop| prop.children.each do |property| + next unless node.is_a? Nokogiri::XML::Element + node = Calligraphy::XML::Node.new property prop_sym = property.name.to_sym diff --git a/lib/calligraphy/resource/resource.rb b/lib/calligraphy/resource/resource.rb index 302190a..8311975 100644 --- a/lib/calligraphy/resource/resource.rb +++ b/lib/calligraphy/resource/resource.rb @@ -36,6 +36,14 @@ module Calligraphy raise NotImplementedError end + # Responsible for creating a duplicate of the resource in + # `options[:destination]` (see section 9.8 of RFC4918). + # + # Used in COPY and MOVE (which inherits from COPY) requests. + def copy(_options) + raise NotImplementedError + end + # Responsible for returning a hash with keys indicating if the resource # can be copied, if an ancestor exists, or if the copy destinatin is # locked. @@ -48,14 +56,6 @@ module Calligraphy raise NotImplementedError end - # Responsible for creating a duplicate of the resource in - # `options[:destination]` (see section 9.8 of RFC4918). - # - # Used in COPY and MOVE (which inherits from COPY) requests. - def copy(_options) - raise NotImplementedError - end - # Responsible for creating a new collection based on the resource (see # section 9.3 of RFC4918). # @@ -70,7 +70,10 @@ module Calligraphy # # Used in OPTIONS requests. def dav_compliance - '1, 2, 3' + compliance_classes = %w[1 2 3] + compliance_classes.push 'extended-mkcol' if enable_extended_mkcol? + + compliance_classes.join ', ' end # Responsible for deleting a resource collection (see section 9.6 of @@ -81,6 +84,12 @@ module Calligraphy raise NotImplementedError end + # Responsible for returning a boolean indicating whether the resource + # supports Extended MKCOL (see RFC5689). + def enable_extended_mkcol? + false + end + # Responsible for returning unique identifier used to create an etag. # # Used in precondition validation, as well as GET, HEAD, and PROPFIND @@ -179,6 +188,16 @@ module Calligraphy raise NotImplementedError end + # Responsible for declaring the valid `resourcetypes` for a resource. If + # an extended MKCOL request is made using an invalid `resourcetype` the + # request will fail with a 403 (Forbidden) and will return an XML response + # with the `mkcol-response` element (see section 3.3 and 3.5 of RFC5689). + # + # Used in Extended MKCOL requests. + def valid_resourcetypes + %w[collection] + end + private # DAV property which can be retrieved by a PROPFIND request. `creationdate` diff --git a/lib/calligraphy/web_dav_request/mkcol.rb b/lib/calligraphy/web_dav_request/mkcol.rb index c1587ba..1e6abea 100644 --- a/lib/calligraphy/web_dav_request/mkcol.rb +++ b/lib/calligraphy/web_dav_request/mkcol.rb @@ -4,20 +4,90 @@ module Calligraphy # Responsible for creating a new collection resource at the location # specified by the request. class Mkcol < WebDavRequest + include Calligraphy::XML::Utils + + # Responsible for evaluating preconditions for the WebDAV request. + def preconditions + return :unsupported_media_type unless validate_request_body + return [:forbidden, mkcol_response] unless validate_resourcetypes + end + # Executes the WebDAV request for a particular resource. def execute return :method_not_allowed if @resource.exists? return :conflict unless @resource.ancestor_exist? - return :unsupported_media_type unless @resource.request_body.blank? + + xml = @resource.enable_extended_mkcol? ? extended_mkcol_xml : nil @resource.create_collection - set_content_location_header + + post_mkcol_actions xml :created end private + def validate_request_body + xml = @resource.enable_extended_mkcol? ? extended_mkcol_xml : nil + + if xml == :bad_request + false + elsif @resource.enable_extended_mkcol? + true + else + @resource.request_body.blank? ? false : true + end + end + + def validate_resourcetypes + return true if body.blank? + + xml = search_xml_for(body: body, search: 'resourcetype').first + resourcetypes = xml.children.map do |node| + next unless node.is_a? Nokogiri::XML::Element + + node.name + end.compact + + resourcetypes.each do |rt| + return false unless @resource.valid_resourcetypes.include? rt + end + + true + end + + def mkcol_response + xml_builder.mkcol_response prepare_mkcol_response_xml + end + + def prepare_mkcol_response_xml + nodes = search_xml_for(body: body, search: 'prop').first.children + + separate_nodes_by_name nodes, 'resourcetype' + end + + def extended_mkcol_xml + return nil if body.blank? + + # The `mkcol` tag specifies properties to be set in an extended MKCOL + # request, as well as any additional information needed when creating + # the resource. + xml_for body: body, node: 'mkcol' + end + + def post_mkcol_actions(xml) + apply_extended_mkcol_properties xml + + set_content_location_header + end + + def apply_extended_mkcol_properties(xml) + return nil if xml.nil? + + @resource.proppatch xml + end + def set_content_location_header @response.headers['Content-Location'] = @resource.full_request_path end diff --git a/lib/calligraphy/web_dav_request/web_dav_request.rb b/lib/calligraphy/web_dav_request/web_dav_request.rb index 8a03bb9..f8a6cea 100644 --- a/lib/calligraphy/web_dav_request/web_dav_request.rb +++ b/lib/calligraphy/web_dav_request/web_dav_request.rb @@ -19,6 +19,11 @@ module Calligraphy @resource = resource end + # Responsible for evaluating preconditions for the WebDAV request. + def preconditions + raise NotImplemented + end + # Executes the WebDAV request for a particular resource. def execute raise NotImplemented @@ -35,7 +40,7 @@ module Calligraphy end def xml_builder - protocol = @request.env['SERVER_PROTOCOL'] + protocol = @request.env['SERVER_PROTOCOL'] || 'HTTP/1.1' Calligraphy::XML::Builder.new server_protocol: protocol end diff --git a/lib/calligraphy/xml/builder.rb b/lib/calligraphy/xml/builder.rb index 496d7bd..8d69e88 100644 --- a/lib/calligraphy/xml/builder.rb +++ b/lib/calligraphy/xml/builder.rb @@ -86,17 +86,17 @@ module Calligraphy end def prop(xml, property_set) - xml[@dav_ns].prop do - iterate_and_drilldown xml, property_set - end + xml[@dav_ns].prop { iterate_and_drilldown xml, property_set } end - def propstat(xml, property_set, status = :ok) + def propstat(xml, property_set, status, error_tag: nil, description: nil) return if property_set.empty? xml[@dav_ns].propstat do prop xml, property_set status xml, status + error xml, error_tag unless error_tag.nil? + responsedescription xml, description unless description.nil? end end @@ -113,6 +113,16 @@ module Calligraphy Rack::Utils::HTTP_STATUS_CODES[status_code] ].join ' ' end + + def error(xml, error) + xml.error { self_closing_tag xml, error } + end + + def responsedescription(xml, description) + xml.responsedescription do + xml.text description + end + end end end end diff --git a/lib/calligraphy/xml/utils.rb b/lib/calligraphy/xml/utils.rb index 09cff39..9f60cb2 100644 --- a/lib/calligraphy/xml/utils.rb +++ b/lib/calligraphy/xml/utils.rb @@ -4,25 +4,50 @@ module Calligraphy module XML # Miscellaneous XML convenience methods. module Utils - # Returns the XML for a given XML body and node/CSS selector. + # Returns the inner XML for a given XML body and node/CSS selector. def xml_for(body:, node:) xml = Nokogiri::XML body return :bad_request unless xml.errors.empty? - namespace = nil - xml.root.namespace_definitions.each do |n| - namespace = "#{n.prefix}|" if dav_namespace n + namespace = dav_namespace xml + + xml.css("dav|#{node}", 'dav': namespace).children + end + + # Searches XML body for a given node/CSS selector and returns that + # node/CSS selector. + def search_xml_for(body:, search:) + xml = Nokogiri::XML body + + [].tap do |results| + xml.namespaces.each_value do |v| + results << xml.css("cs|#{search}", 'cs': v) + end + end.flatten + end + + # Iterates through top level nodes, finds node names that match and + # separates matching nodes from non-matching nodes. + def separate_nodes_by_name(nodes, match_name) + { found: [], not_found: [] }.tap do |property| + nodes.each do |node| + next unless node.is_a? Nokogiri::XML::Element + + if node.name == match_name + property[:found].push Calligraphy::XML::Node.new node + else + property[:not_found].push Calligraphy::XML::Node.new node + end + end end - - node = node.split(' ').map! { |n| namespace + n }.join(' ') if namespace - - xml.css(node).children end private - def dav_namespace(namespace) - namespace&.href == Calligraphy::DAV_NS && !namespace.prefix.nil? + def dav_namespace(xml) + xml.namespaces.each_value do |v| + return v if v == Calligraphy::DAV_NS + end end end end diff --git a/lib/calligraphy/xml/web_dav_elements.rb b/lib/calligraphy/xml/web_dav_elements.rb index e818689..9a115da 100644 --- a/lib/calligraphy/xml/web_dav_elements.rb +++ b/lib/calligraphy/xml/web_dav_elements.rb @@ -8,10 +8,10 @@ module Calligraphy activelock allprop collection creationdate depth displayname error exclusive getcontentlanguage getcontentlength getcontenttype getetag getlastmodified href include location lockdiscovery lockentry lockinfo - lockroot lockscope locktoken locktype multistatus owner prop - propertyupdate propfind propname propstat remove response + lockroot lockscope locktoken locktype mkcol-response multistatus owner + prop propertyupdate propfind propname propstat remove response responsedescription resourcetype set shared status supportedlock - timeout write + timeout valid-resourcetype write ].freeze DAV_NS_METHODS = %w[resourcetype supportedlock timeout].freeze @@ -29,6 +29,21 @@ module Calligraphy end end + # Build an XML response for a failed MKCOL request. + def mkcol_response(properties) + description = 'Resource type is not supported by this server' + error = 'valid-resourcetype' + + build 'mkcol-response' do |xml| + propstat(xml, properties[:found], + :forbidden, error_tag: error, description: description) + + if properties[:not_found].length.positive? + propstat xml, properties[:not_found], :failed_dependency + end + end + end + # Build an XML response for a PROPFIND request. def propfind_response(path, properties) multistatus do |xml| @@ -42,8 +57,8 @@ module Calligraphy def proppatch_response(path, actions) multistatus do |xml| href xml, path - propstat xml, actions[:set] - propstat xml, actions[:remove] + propstat xml, actions[:set], :ok + propstat xml, actions[:remove], :ok end end diff --git a/spec/requests/mkcol_spec.rb b/spec/requests/mkcol_spec.rb new file mode 100644 index 0000000..cefbaf8 --- /dev/null +++ b/spec/requests/mkcol_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'support/request_helpers' +require 'support/examples/ext_mkcol' + +RSpec.describe 'mkcol', type: :request do + before(:all) do + tmp_dir = Rails.root.join('../../tmp').to_path + Dir.mkdir tmp_dir unless File.exists? tmp_dir + + webdav_dir = Rails.root.join('../../tmp/webdav').to_path + FileUtils.rm_r webdav_dir if File.exists? webdav_dir + Dir.mkdir webdav_dir + end + + before(:each) do + allow(Calligraphy).to receive(:enable_digest_authentication) + .and_return(false) + end + + it 'creates a collection with additional properties' do + allow_any_instance_of(Calligraphy::FileResource).to receive( + :valid_resourcetypes + ).and_return(%w[collection special-resource]) + + expect(Dir).to receive(:mkdir).and_call_original + expect_any_instance_of(Calligraphy::FileResource).to receive( + :proppatch + ) + + mkcol '/webdav/special', headers: { + RAW_POST_DATA: Support::Examples::ExtMkcol.rfc5689_3_4 + } + + expect(response.body.empty?).to eq(true) + expect(response.status).to eq(201) + end + + context 'with an invalid resource type' do + it 'returns an error response' do + mkcol '/webdav/special', headers: { + RAW_POST_DATA: Support::Examples::ExtMkcol.rfc5689_3_4 + } + + expect(response.status).to eq(403) + expect(response.body).to include('mkcol-response') + expect(response.body).to include('valid-resourcetype') + end + end +end diff --git a/spec/requests/options_spec.rb b/spec/requests/options_spec.rb new file mode 100644 index 0000000..04b7812 --- /dev/null +++ b/spec/requests/options_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'support/request_helpers' + +RSpec.describe 'OPTIONS', type: :request do + before(:each) do + allow(Calligraphy).to receive(:enable_digest_authentication) + .and_return(false) + end + + context 'when not using extended MKCOL support' do + before(:each) do + allow_any_instance_of(Calligraphy::FileResource).to receive( + :enable_extended_mkcol? + ).and_return(false) + end + + it 'advertises support for all 3 WebDAV classes' do + options '/webdav/special' + + %w[1 2 3].each { |c| expect(response.headers['DAV']).to include(c) } + end + + it 'does not advertise support for extended-mkcol' do + options '/webdav/special' + + expect(response.headers['DAV']).to_not include('extended-mkcol') + end + end + + context 'when using extended MKCOL support' do + before(:each) do + allow_any_instance_of(Calligraphy::FileResource).to receive( + :enable_extended_mkcol? + ).and_return(true) + end + + it 'advertises support for all 3 WebDAV classes' do + options '/webdav/special' + + %w[1 2 3].each { |c| expect(response.headers['DAV']).to include(c) } + end + + it 'advertises support for extended-mkcol' do + options '/webdav/special' + + expect(response.headers['DAV']).to include('extended-mkcol') + end + end +end diff --git a/spec/resource/resource_spec.rb b/spec/resource/resource_spec.rb index 40da062..4568b8a 100644 --- a/spec/resource/resource_spec.rb +++ b/spec/resource/resource_spec.rb @@ -1,22 +1,29 @@ +# frozen_string_literal: true + require 'rails_helper' +require 'support/request_helpers' RSpec.describe 'Resource' do context 'base method' do - resource_methods_without_inputs = %w( + resource_methods_without_inputs = %w[ ancestor_exist? collection? create_collection delete_collection etag exists? lock_is_exclusive? locked? read readable? refresh_lock - creationdate displayname getcontentlanguage getcontentlength getcontenttype - getetag getlastmodified lockdiscovery resourcetype supportedlock - ) - resource_methods_with_inputs = %w( + creationdate displayname getcontentlanguage getcontentlength + getcontenttype getetag getlastmodified lockdiscovery resourcetype + supportedlock + ] + resource_methods_with_inputs = %w[ copy copy_options lock locked_to_user? propfind proppatch unlock write - ) + ] resource_methods_without_inputs.each do |method| describe "##{method}" do it 'raises NotImplementedError' do resource = Calligraphy::Resource.new - expect{resource.send(method)}.to raise_exception(NotImplementedError) + + expect { resource.send(method) }.to raise_exception( + NotImplementedError + ) end end end @@ -25,7 +32,10 @@ RSpec.describe 'Resource' do describe "##{method}" do it 'raises NotImplementedError' do resource = Calligraphy::Resource.new - expect{resource.send(method, nil)}.to raise_exception(NotImplementedError) + + expect { resource.send(method, nil) }.to raise_exception( + NotImplementedError + ) end end end diff --git a/spec/support/examples/ext_mkcol.rb b/spec/support/examples/ext_mkcol.rb new file mode 100644 index 0000000..0dd4289 --- /dev/null +++ b/spec/support/examples/ext_mkcol.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: false + +module Support + module Examples + module ExtMkcol + # RFC5689: 3.4. Successful Extended MKCOL Request + def self.rfc5689_3_4 + <<~XML + + + + + + + + + Special Resource + + + +XML + end + end + end +end diff --git a/spec/support/rspec_matchers.rb b/spec/support/request_helpers.rb similarity index 71% rename from spec/support/rspec_matchers.rb rename to spec/support/request_helpers.rb index b5a96bb..61793f5 100644 --- a/spec/support/rspec_matchers.rb +++ b/spec/support/request_helpers.rb @@ -1,7 +1,7 @@ module ActionDispatch module Integration module RequestHelpers - %w[copy move mkcol propfind proppatch lock unlock].each do |method| + %w[copy move mkcol options propfind proppatch lock unlock].each do |method| define_method method do |path, **args| process method.to_sym, path, **args end