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: AllCops:
TargetRubyVersion: 2.3 TargetRubyVersion: 2.3
Exclude: Exclude:
- 'spec/**/*' - 'spec/dummy/**/*'
Metrics/BlockLength: Metrics/BlockLength:
Exclude: Exclude:
- 'spec/**/*'
- 'Rakefile' - 'Rakefile'
Metrics/ClassLength: Metrics/ClassLength:
Exclude: Exclude:
@@ -15,6 +16,8 @@ Metrics/AbcSize:
Metrics/LineLength: Metrics/LineLength:
Exclude: Exclude:
- 'lib/calligraphy/rails/mapper.rb' - 'lib/calligraphy/rails/mapper.rb'
- 'spec/spec_helper.rb'
- 'spec/rails_helper.rb'
Metrics/MethodLength: Metrics/MethodLength:
Exclude: Exclude:
- 'lib/calligraphy/rails/mapper.rb' - 'lib/calligraphy/rails/mapper.rb'

View File

@@ -44,7 +44,12 @@ module Calligraphy
end end
def mkcol 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 end
def propfind def propfind

View File

@@ -44,6 +44,23 @@ module Calligraphy
File.directory? @src_path File.directory? @src_path
end 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 # Responsible for returning a hash with keys indicating if the resource
# can be copied, if an ancestor exists, or if the copy destinatin is # can be copied, if an ancestor exists, or if the copy destinatin is
# locked. # locked.
@@ -65,23 +82,6 @@ module Calligraphy
copy_options copy_options
end 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 # Responsible for creating a new collection based on the resource (see
# section 9.3 of RFC4918). # section 9.3 of RFC4918).
# #
@@ -99,6 +99,12 @@ module Calligraphy
FileUtils.rm_r @store_path if store_exist? FileUtils.rm_r @store_path if store_exist?
end 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. # Responsible for returning unique identifier used to create an etag.
# #
# Used in precondition validation, as well as GET, HEAD, and PROPFIND # Used in precondition validation, as well as GET, HEAD, and PROPFIND
@@ -185,6 +191,8 @@ module Calligraphy
# #
# Used in PROPPATCH requests. # Used in PROPPATCH requests.
def proppatch(nodes) def proppatch(nodes)
init_pstore unless exists?
actions = { set: [], remove: [] } actions = { set: [], remove: [] }
@store.transaction do @store.transaction do
@@ -584,6 +592,8 @@ module Calligraphy
def add_properties(node, actions) def add_properties(node, actions)
node.children.each do |prop| node.children.each do |prop|
prop.children.each do |property| prop.children.each do |property|
next unless node.is_a? Nokogiri::XML::Element
node = Calligraphy::XML::Node.new property node = Calligraphy::XML::Node.new property
prop_sym = property.name.to_sym prop_sym = property.name.to_sym

View File

@@ -36,6 +36,14 @@ module Calligraphy
raise NotImplementedError raise NotImplementedError
end 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 # Responsible for returning a hash with keys indicating if the resource
# can be copied, if an ancestor exists, or if the copy destinatin is # can be copied, if an ancestor exists, or if the copy destinatin is
# locked. # locked.
@@ -48,14 +56,6 @@ module Calligraphy
raise NotImplementedError raise NotImplementedError
end 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 # Responsible for creating a new collection based on the resource (see
# section 9.3 of RFC4918). # section 9.3 of RFC4918).
# #
@@ -70,7 +70,10 @@ module Calligraphy
# #
# Used in OPTIONS requests. # Used in OPTIONS requests.
def dav_compliance 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 end
# Responsible for deleting a resource collection (see section 9.6 of # Responsible for deleting a resource collection (see section 9.6 of
@@ -81,6 +84,12 @@ module Calligraphy
raise NotImplementedError raise NotImplementedError
end 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. # Responsible for returning unique identifier used to create an etag.
# #
# Used in precondition validation, as well as GET, HEAD, and PROPFIND # Used in precondition validation, as well as GET, HEAD, and PROPFIND
@@ -179,6 +188,16 @@ module Calligraphy
raise NotImplementedError raise NotImplementedError
end 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 private
# DAV property which can be retrieved by a PROPFIND request. `creationdate` # 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 # Responsible for creating a new collection resource at the location
# specified by the request. # specified by the request.
class Mkcol < WebDavRequest 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. # Executes the WebDAV request for a particular resource.
def execute def execute
return :method_not_allowed if @resource.exists? return :method_not_allowed if @resource.exists?
return :conflict unless @resource.ancestor_exist? 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 @resource.create_collection
set_content_location_header
post_mkcol_actions xml
:created :created
end end
private 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 def set_content_location_header
@response.headers['Content-Location'] = @resource.full_request_path @response.headers['Content-Location'] = @resource.full_request_path
end end

View File

@@ -19,6 +19,11 @@ module Calligraphy
@resource = resource @resource = resource
end end
# Responsible for evaluating preconditions for the WebDAV request.
def preconditions
raise NotImplemented
end
# Executes the WebDAV request for a particular resource. # Executes the WebDAV request for a particular resource.
def execute def execute
raise NotImplemented raise NotImplemented
@@ -35,7 +40,7 @@ module Calligraphy
end end
def xml_builder def xml_builder
protocol = @request.env['SERVER_PROTOCOL'] protocol = @request.env['SERVER_PROTOCOL'] || 'HTTP/1.1'
Calligraphy::XML::Builder.new server_protocol: protocol Calligraphy::XML::Builder.new server_protocol: protocol
end end

View File

@@ -86,17 +86,17 @@ module Calligraphy
end end
def prop(xml, property_set) def prop(xml, property_set)
xml[@dav_ns].prop do xml[@dav_ns].prop { iterate_and_drilldown xml, property_set }
iterate_and_drilldown xml, property_set
end
end end
def propstat(xml, property_set, status = :ok) def propstat(xml, property_set, status, error_tag: nil, description: nil)
return if property_set.empty? return if property_set.empty?
xml[@dav_ns].propstat do xml[@dav_ns].propstat do
prop xml, property_set prop xml, property_set
status xml, status status xml, status
error xml, error_tag unless error_tag.nil?
responsedescription xml, description unless description.nil?
end end
end end
@@ -113,6 +113,16 @@ module Calligraphy
Rack::Utils::HTTP_STATUS_CODES[status_code] Rack::Utils::HTTP_STATUS_CODES[status_code]
].join ' ' ].join ' '
end 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 end
end end

View File

@@ -4,25 +4,50 @@ module Calligraphy
module XML module XML
# Miscellaneous XML convenience methods. # Miscellaneous XML convenience methods.
module Utils 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:) def xml_for(body:, node:)
xml = Nokogiri::XML body xml = Nokogiri::XML body
return :bad_request unless xml.errors.empty? return :bad_request unless xml.errors.empty?
namespace = nil namespace = dav_namespace xml
xml.root.namespace_definitions.each do |n|
namespace = "#{n.prefix}|" if dav_namespace n 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 end
node = node.split(' ').map! { |n| namespace + n }.join(' ') if namespace
xml.css(node).children
end end
private private
def dav_namespace(namespace) def dav_namespace(xml)
namespace&.href == Calligraphy::DAV_NS && !namespace.prefix.nil? xml.namespaces.each_value do |v|
return v if v == Calligraphy::DAV_NS
end
end end
end end
end end

View File

@@ -8,10 +8,10 @@ module Calligraphy
activelock allprop collection creationdate depth displayname error activelock allprop collection creationdate depth displayname error
exclusive getcontentlanguage getcontentlength getcontenttype getetag exclusive getcontentlanguage getcontentlength getcontenttype getetag
getlastmodified href include location lockdiscovery lockentry lockinfo getlastmodified href include location lockdiscovery lockentry lockinfo
lockroot lockscope locktoken locktype multistatus owner prop lockroot lockscope locktoken locktype mkcol-response multistatus owner
propertyupdate propfind propname propstat remove response prop propertyupdate propfind propname propstat remove response
responsedescription resourcetype set shared status supportedlock responsedescription resourcetype set shared status supportedlock
timeout write timeout valid-resourcetype write
].freeze ].freeze
DAV_NS_METHODS = %w[resourcetype supportedlock timeout].freeze DAV_NS_METHODS = %w[resourcetype supportedlock timeout].freeze
@@ -29,6 +29,21 @@ module Calligraphy
end end
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. # Build an XML response for a PROPFIND request.
def propfind_response(path, properties) def propfind_response(path, properties)
multistatus do |xml| multistatus do |xml|
@@ -42,8 +57,8 @@ module Calligraphy
def proppatch_response(path, actions) def proppatch_response(path, actions)
multistatus do |xml| multistatus do |xml|
href xml, path href xml, path
propstat xml, actions[:set] propstat xml, actions[:set], :ok
propstat xml, actions[:remove] propstat xml, actions[:remove], :ok
end end
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 'rails_helper'
require 'support/request_helpers'
RSpec.describe 'Resource' do RSpec.describe 'Resource' do
context 'base method' do context 'base method' do
resource_methods_without_inputs = %w( resource_methods_without_inputs = %w[
ancestor_exist? collection? create_collection delete_collection etag ancestor_exist? collection? create_collection delete_collection etag
exists? lock_is_exclusive? locked? read readable? refresh_lock exists? lock_is_exclusive? locked? read readable? refresh_lock
creationdate displayname getcontentlanguage getcontentlength getcontenttype creationdate displayname getcontentlanguage getcontentlength
getetag getlastmodified lockdiscovery resourcetype supportedlock getcontenttype getetag getlastmodified lockdiscovery resourcetype
) supportedlock
resource_methods_with_inputs = %w( ]
resource_methods_with_inputs = %w[
copy copy_options lock locked_to_user? propfind proppatch unlock write copy copy_options lock locked_to_user? propfind proppatch unlock write
) ]
resource_methods_without_inputs.each do |method| resource_methods_without_inputs.each do |method|
describe "##{method}" do describe "##{method}" do
it 'raises NotImplementedError' do it 'raises NotImplementedError' do
resource = Calligraphy::Resource.new resource = Calligraphy::Resource.new
expect{resource.send(method)}.to raise_exception(NotImplementedError)
expect { resource.send(method) }.to raise_exception(
NotImplementedError
)
end end
end end
end end
@@ -25,7 +32,10 @@ RSpec.describe 'Resource' do
describe "##{method}" do describe "##{method}" do
it 'raises NotImplementedError' do it 'raises NotImplementedError' do
resource = Calligraphy::Resource.new 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 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 ActionDispatch
module Integration module Integration
module RequestHelpers 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| define_method method do |path, **args|
process method.to_sym, path, **args process method.to_sym, path, **args
end end