Add support for Extended MKCOL (RFC5689)

This commit is contained in:
Brandon Robins
2018-01-03 23:43:07 -06:00
committed by Brandon Robins
parent 46ff7a934f
commit 3b65768e40
14 changed files with 359 additions and 59 deletions

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
node = node.split(' ').map! { |n| namespace + n }.join(' ') if namespace
# 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
xml.css(node).children
[].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
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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
<?xml version="1.0" encoding="utf-8" ?>
<D:mkcol xmlns:D="DAV:"
xmlns:E="http://example.com/ns/">
<D:set>
<D:prop>
<D:resourcetype>
<D:collection/>
<E:special-resource/>
</D:resourcetype>
<D:displayname>Special Resource</D:displayname>
</D:prop>
</D:set>
</D:mkcol>
XML
end
end
end
end

View File

@@ -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