├── spec ├── fixtures │ ├── folder │ │ ├── 01.txt │ │ └── 02.txt │ └── requests │ │ └── lock.xml ├── file_resource_spec.rb ├── spec_helper.rb ├── support │ └── lockable_file_resource.rb ├── handler_spec.rb └── controller_spec.rb ├── .gitignore ├── .travis.yml ├── test.sh ├── Gemfile ├── Dockerfile ├── config.ru ├── lib ├── rack_dav.rb └── rack_dav │ ├── version.rb │ ├── string.rb │ ├── handler.rb │ ├── http_status.rb │ ├── file_resource.rb │ ├── resource.rb │ └── controller.rb ├── CHANGELOG.md ├── Rakefile ├── rack_dav.gemspec ├── LICENSE └── README.md /spec/fixtures/folder/01.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/fixtures/folder/02.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | /.bundle 3 | doc/* 4 | pkg/* 5 | test/repo 6 | /vendor/bundle 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | - 1.9.3 5 | - 1.9.2 6 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker build -t rackdav . && docker run -it rackdav rspec 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in rack_dav.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /spec/file_resource_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RackDAV::FileResource do 4 | 5 | describe "#get" do 6 | context "with a directory" do 7 | # it "sets a compliant rack response" 8 | end 9 | end 10 | 11 | end -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:latest 2 | 3 | RUN mkdir /app 4 | WORKDIR /app 5 | 6 | ADD Gemfile /app 7 | ADD rack_dav.gemspec /app 8 | ADD lib/rack_dav/version.rb /app/lib/rack_dav/version.rb 9 | 10 | RUN bundle install 11 | 12 | ADD . /app 13 | 14 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'rack_dav' 3 | 4 | app = Rack::Builder.new do 5 | use Rack::ShowExceptions 6 | use Rack::CommonLogger 7 | use Rack::Reloader 8 | # use Rack::Lint 9 | 10 | run RackDAV::Handler.new(:root => ".") 11 | 12 | end.to_app 13 | 14 | run app 15 | -------------------------------------------------------------------------------- /lib/rack_dav.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'time' 3 | require 'uri' 4 | require 'rexml/document' 5 | require 'nokogiri' 6 | 7 | require 'rack' 8 | require 'rack_dav/http_status' 9 | require 'rack_dav/resource' 10 | require 'rack_dav/file_resource' 11 | require 'rack_dav/handler' 12 | require 'rack_dav/controller' 13 | -------------------------------------------------------------------------------- /spec/fixtures/requests/lock.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | http://example.com/ 11 | 12 | -------------------------------------------------------------------------------- /lib/rack_dav/version.rb: -------------------------------------------------------------------------------- 1 | module RackDAV 2 | 3 | # Holds information about library version. 4 | module Version 5 | MAJOR = 0 6 | MINOR = 5 7 | PATCH = 2 8 | BUILD = nil 9 | 10 | STRING = [MAJOR, MINOR, PATCH, BUILD].compact.join(".") 11 | end 12 | 13 | # The current library version. 14 | VERSION = Version::STRING 15 | 16 | end 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | = Changelog 2 | 3 | == master 4 | 5 | * NEW: Added Rakefile, package and test tasks. 6 | 7 | * NEW: Added Gemfile and Bundler tasks. 8 | 9 | * CHANGED: Upgraded to RSpec 2.0. 10 | 11 | * CHANGED: Bump dependency to rack >= 1.2.0 12 | 13 | * CHANGED: Removed response.status override/casting in RackDAV::Handler 14 | because already performed in Rack::Response#finish. 15 | 16 | * FIXED: rack_dav binary crashes if Mongrel is not installed. 17 | 18 | 19 | == Release 0.1.3 20 | 21 | * Unknown 22 | 23 | 24 | == Release 0.1.2 25 | 26 | * Unknown 27 | 28 | -------------------------------------------------------------------------------- /lib/rack_dav/string.rb: -------------------------------------------------------------------------------- 1 | class String 2 | 3 | if RUBY_VERSION >= "1.9" 4 | def force_valid_encoding 5 | find_encoding(Encoding.list.to_enum) 6 | end 7 | else 8 | def force_valid_encoding 9 | self 10 | end 11 | end 12 | 13 | private 14 | 15 | def find_encoding(encodings) 16 | if valid_encoding? 17 | self 18 | else 19 | force_next_encoding(encodings) 20 | end 21 | end 22 | 23 | def force_next_encoding(encodings) 24 | force_encoding(encodings.next) 25 | find_encoding(encodings) 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'rack_dav' 3 | 4 | unless defined?(SPEC_ROOT) 5 | SPEC_ROOT = File.expand_path("../", __FILE__) 6 | end 7 | 8 | module Helpers 9 | 10 | private 11 | 12 | # Gets the currently described class. 13 | # Conversely to +subject+, it returns the class 14 | # instead of an instance. 15 | def klass 16 | described_class 17 | end 18 | 19 | def fixture(*names) 20 | File.join(SPEC_ROOT, "fixtures", *names) 21 | end 22 | 23 | end 24 | 25 | RSpec.configure do |config| 26 | config.include Helpers 27 | end 28 | -------------------------------------------------------------------------------- /spec/support/lockable_file_resource.rb: -------------------------------------------------------------------------------- 1 | module RackDAV 2 | 3 | # Quick & Dirty 4 | class LockableFileResource < FileResource 5 | @@locks = {} 6 | 7 | def lock(token, timeout, scope = nil, type = nil, owner = nil) 8 | if scope && type && owner 9 | # Create lock 10 | @@locks[token] = { 11 | :timeout => timeout, 12 | :scope => scope, 13 | :type => type, 14 | :owner => owner 15 | } 16 | return true 17 | else 18 | # Refresh lock 19 | lock = @@locks[token] 20 | return false unless lock 21 | return [ lock[:timeout], lock[:scope], lock[:type], lock[:owner] ] 22 | end 23 | end 24 | 25 | def unlock(token) 26 | !!@@locks.delete(token) 27 | end 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | require "rspec/core/rake_task" 3 | 4 | Bundler::GemHelper.install_tasks 5 | 6 | 7 | task :default => :spec 8 | 9 | # Run all the specs in the /spec folder 10 | RSpec::Core::RakeTask.new 11 | 12 | 13 | namespace :spec do 14 | desc "Run RSpec against all Ruby versions" 15 | task :rubies => "spec:rubies:default" 16 | 17 | namespace :rubies do 18 | RUBIES = %w( 1.8.7-p330 1.9.2-p0 jruby-1.5.6 ree-1.8.7-2010.02 ) 19 | 20 | task :default => :ensure_rvm do 21 | sh "rvm #{RUBIES.join(",")} rake default" 22 | end 23 | 24 | task :ensure_rvm do 25 | File.exist?(File.expand_path("~/.rvm/scripts/rvm")) || abort("RVM is not available") 26 | end 27 | 28 | RUBIES.each do |ruby| 29 | desc "Run RSpec against Ruby #{ruby}" 30 | task ruby => :ensure_rvm do 31 | sh "rvm #{ruby} rake default" 32 | end 33 | end 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /rack_dav.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "rack_dav/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "rack_dav" 7 | s.version = RackDAV::VERSION 8 | s.author = "Matthias Georgi" 9 | s.email = "matti.georgi@gmail.com" 10 | s.homepage = "http://georgi.github.com/rack_dav" 11 | s.summary = "WebDAV handler for Rack." 12 | s.description = "WebDAV handler for Rack." 13 | s.license = "MIT" 14 | 15 | s.files = `git ls-files`.split("\n") 16 | s.require_paths = ["lib"] 17 | 18 | s.extra_rdoc_files = ["README.md"] 19 | 20 | s.add_dependency("rack", "~> 3.0.0") 21 | s.add_dependency("rackup", "~> 0.2.3") 22 | s.add_dependency("rexml", "~> 3.2.4") 23 | s.add_dependency('nokogiri', "~> 1.5") 24 | s.add_dependency('webrick', "~> 1.3") 25 | s.add_dependency('puma', "~> 6.0") 26 | s.add_development_dependency("rspec", "~> 3.4.0") 27 | s.add_development_dependency("rake","~> 13.0") 28 | end 29 | -------------------------------------------------------------------------------- /spec/handler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RackDAV::Handler do 4 | let(:handler) { RackDAV::Handler.new } 5 | let(:request) { Rack::Request.new({ 'REQUEST_METHOD' => 'GET' }) } 6 | 7 | describe "#initialize" do 8 | it "accepts zero parameters" do 9 | lambda do 10 | handler 11 | end.should_not raise_error 12 | end 13 | 14 | it "sets options from argument" do 15 | instance = klass.new :foo => "bar" 16 | instance.options[:foo].should == "bar" 17 | end 18 | 19 | it "defaults option :resource_class to FileResource" do 20 | handler.options[:resource_class].should be(RackDAV::FileResource) 21 | end 22 | 23 | it "defaults option :root to current directory" do 24 | path = '/tmp' 25 | Dir.chdir(path) 26 | instance = handler 27 | instance.options[:root].should == path 28 | end 29 | 30 | it 'sets the response status to 405 if the request method is not allowed' do 31 | request.env['REQUEST_METHOD'] = 'FOO' 32 | expect(handler.call(request.env)[0]).to eq(405) 33 | end 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Matthias Georgi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to 5 | deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /lib/rack_dav/handler.rb: -------------------------------------------------------------------------------- 1 | module RackDAV 2 | 3 | class Handler 4 | 5 | # @return [Hash] The hash of options. 6 | attr_reader :options 7 | 8 | 9 | ALLOWED_METHODS = %w(GET PUT POST DELETE PROPFIND PROPPATCH MKCOL COPY MOVE OPTIONS HEAD LOCK UNLOCK) 10 | 11 | 12 | # Initializes a new instance with given options. 13 | # 14 | # @param [Hash] options Hash of options to customize the handler behavior. 15 | # @option options [Class] :resource_class (FileResource) 16 | # The resource class. 17 | # @option options [String] :root (".") 18 | # The root resource folder. 19 | # 20 | def initialize(options = {}) 21 | @options = { 22 | :resource_class => FileResource, 23 | :root => Dir.pwd 24 | }.merge(options) 25 | end 26 | 27 | def call(env) 28 | request = Rack::Request.new(env) 29 | response = Rack::Response.new 30 | 31 | begin 32 | controller = Controller.new(request, response, @options) 33 | if ALLOWED_METHODS.include?(request.request_method) 34 | controller.send(request.request_method.downcase) 35 | else 36 | response.status = 405 37 | response['Allow'] = ALLOWED_METHODS.join(', ') 38 | end 39 | 40 | rescue HTTPStatus::Status => status 41 | response.status = status.code 42 | end 43 | 44 | response.finish 45 | end 46 | 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | RackDAV - Web Authoring for Rack 3 | --- 4 | 5 | RackDAV is a Ruby gem that allows you to use the WebDAV protocol to edit and manage files over HTTP. It comes with its own file backend, but you can also create your own backend by subclassing RackDAV::Resource. 6 | 7 | ## Quickstart 8 | 9 | To install the gem, run `gem install rack_dav`. 10 | 11 | To quickly test out RackDAV, copy the config.ru file from this repository and run `bundle exec rackup`. This will start a web server on a default port that you can connect to without any authentication. 12 | 13 | ## Rack Handler 14 | 15 | To use RackDAV in your own rack application, include the following in your config.ru file: 16 | 17 | require 'rubygems' 18 | require 'rack_dav' 19 | 20 | use Rack::CommonLogger 21 | 22 | run RackDAV::Handler.new(:root => '/path/to/docs') 23 | 24 | ## Implementing your own WebDAV resource 25 | 26 | If you want to create your own WebDAV resource, you will need to subclass RackDAV::Resource and implement the following methods: 27 | 28 | * __children__: If this is a collection, return the child resources. 29 | 30 | * __collection?__: Is this resource a collection? 31 | 32 | * __exist?__: Does this recource exist? 33 | 34 | * __creation\_date__: Return the creation time. 35 | 36 | * __last\_modified__: Return the time of last modification. 37 | 38 | * __last\_modified=(time)__: Set the time of last modification. 39 | 40 | * __etag__: Return an Etag, an unique hash value for this resource. 41 | 42 | * __content_type__: Return the mime type of this resource. 43 | 44 | * __content\_length__: Return the size in bytes for this resource. 45 | 46 | * __get(request, response)__: Write the content of the resource to the response.body. 47 | 48 | * __put(request, response)__: Save the content of the request.body. 49 | 50 | * __post(request, response)__: Usually forbidden. 51 | 52 | * __delete__: Delete this resource. 53 | 54 | * __copy(dest)__: Copy this resource to given destination resource. 55 | 56 | * __move(dest)__: Move this resource to given destination resource. 57 | 58 | * __make\_collection__: Create this resource as collection. 59 | 60 | * __set_custom_property(name, value)__: Set a custom property on the resource. If the value is nil, delete the custom property. 61 | 62 | * __get_custom_property(name)__: Return the value of the named custom property. 63 | 64 | * __lock(locktoken, timeout, lockscope=nil, locktype=nil, owner=nil)__: Lock this resource. 65 | If scope, type and owner are nil, refresh the given lock. 66 | 67 | * __unlock(token)__: Unlock this resource 68 | 69 | Note that it is possible that a resource object may be instantiated for a resource that does not yet exist. 70 | 71 | For more examples and inspiration, you can look at the FileResource implementation. 72 | -------------------------------------------------------------------------------- /lib/rack_dav/http_status.rb: -------------------------------------------------------------------------------- 1 | module RackDAV 2 | 3 | module HTTPStatus 4 | 5 | class Status < Exception 6 | 7 | class << self 8 | attr_accessor :code, :reason_phrase 9 | alias_method :to_i, :code 10 | 11 | def status_line 12 | "#{code} #{reason_phrase}" 13 | end 14 | 15 | end 16 | 17 | def code 18 | self.class.code 19 | end 20 | 21 | def reason_phrase 22 | self.class.reason_phrase 23 | end 24 | 25 | def status_line 26 | self.class.status_line 27 | end 28 | 29 | def to_i 30 | self.class.to_i 31 | end 32 | 33 | end 34 | 35 | StatusMessage = { 36 | 100 => 'Continue', 37 | 101 => 'Switching Protocols', 38 | 102 => 'Processing', 39 | 200 => 'OK', 40 | 201 => 'Created', 41 | 202 => 'Accepted', 42 | 203 => 'Non-Authoritative Information', 43 | 204 => 'No Content', 44 | 205 => 'Reset Content', 45 | 206 => 'Partial Content', 46 | 207 => 'Multi-Status', 47 | 300 => 'Multiple Choices', 48 | 301 => 'Moved Permanently', 49 | 302 => 'Found', 50 | 303 => 'See Other', 51 | 304 => 'Not Modified', 52 | 305 => 'Use Proxy', 53 | 307 => 'Temporary Redirect', 54 | 400 => 'Bad Request', 55 | 401 => 'Unauthorized', 56 | 402 => 'Payment Required', 57 | 403 => 'Forbidden', 58 | 404 => 'Not Found', 59 | 405 => 'Method Not Allowed', 60 | 406 => 'Not Acceptable', 61 | 407 => 'Proxy Authentication Required', 62 | 408 => 'Request Timeout', 63 | 409 => 'Conflict', 64 | 410 => 'Gone', 65 | 411 => 'Length Required', 66 | 412 => 'Precondition Failed', 67 | 413 => 'Request Entity Too Large', 68 | 414 => 'Request-URI Too Large', 69 | 415 => 'Unsupported Media Type', 70 | 416 => 'Request Range Not Satisfiable', 71 | 417 => 'Expectation Failed', 72 | 422 => 'Unprocessable Entity', 73 | 423 => 'Locked', 74 | 424 => 'Failed Dependency', 75 | 500 => 'Internal Server Error', 76 | 501 => 'Not Implemented', 77 | 502 => 'Bad Gateway', 78 | 503 => 'Service Unavailable', 79 | 504 => 'Gateway Timeout', 80 | 505 => 'HTTP Version Not Supported', 81 | 507 => 'Insufficient Storage' 82 | } 83 | 84 | StatusMessage.each do |code, reason_phrase| 85 | klass = Class.new(Status) 86 | klass.code = code 87 | klass.reason_phrase = reason_phrase 88 | klass_name = reason_phrase.gsub(/[ \-]/,'') 89 | const_set(klass_name, klass) 90 | end 91 | 92 | end 93 | 94 | end 95 | 96 | 97 | module Rack 98 | class Response 99 | module Helpers 100 | RackDAV::HTTPStatus::StatusMessage.each do |code, reason_phrase| 101 | name = reason_phrase.gsub(/[ \-]/,'_').downcase 102 | define_method(name + '?') do 103 | @status == code 104 | end 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/rack_dav/file_resource.rb: -------------------------------------------------------------------------------- 1 | require 'digest' 2 | require 'webrick/httputils' 3 | 4 | module RackDAV 5 | 6 | class FileResource < Resource 7 | include WEBrick::HTTPUtils 8 | 9 | # If this is a collection, return the child resources. 10 | def children 11 | Dir[file_path + '/*'].map do |path| 12 | child File.basename(path) 13 | end 14 | end 15 | 16 | # Is this resource a collection? 17 | def collection? 18 | File.directory?(file_path) 19 | end 20 | 21 | # Does this recource exist? 22 | def exist? 23 | File.exist?(file_path) 24 | end 25 | 26 | # Return the creation time. 27 | def creation_date 28 | stat.ctime 29 | end 30 | 31 | # Return the time of last modification. 32 | def last_modified 33 | stat.mtime 34 | end 35 | 36 | # Set the time of last modification. 37 | def last_modified=(time) 38 | File.utime(Time.now, time, file_path) 39 | end 40 | 41 | # Return an Etag, an unique hash value for this resource. 42 | def etag 43 | sprintf('%x-%x-%x', stat.ino, stat.size, stat.mtime.to_i) 44 | end 45 | 46 | # Return the resource type. 47 | # 48 | # If this is a collection, return a collection element 49 | def resource_type 50 | if collection? 51 | Nokogiri::XML::fragment('').children.first 52 | end 53 | end 54 | 55 | # Return the mime type of this resource. 56 | def content_type 57 | if stat.directory? 58 | "text/html" 59 | else 60 | mime_type(file_path, DefaultMimeTypes) 61 | end 62 | end 63 | 64 | # Return the size in bytes for this resource. 65 | def content_length 66 | stat.size 67 | end 68 | 69 | def list_custom_properties 70 | [] 71 | end 72 | 73 | # HTTP GET request. 74 | # 75 | # Write the content of the resource to the response.body. 76 | def get 77 | if stat.directory? 78 | content = "" 79 | Rack::Directory.new(root).call(@request.env)[2].each { |line| content << line } 80 | @response.body = [content] 81 | @response['Content-Length'] = (content.respond_to?(:bytesize) ? content.bytesize : content.size).to_s 82 | else 83 | file = File.open(file_path) 84 | @response.body = file 85 | end 86 | end 87 | 88 | # HTTP PUT request. 89 | # 90 | # Save the content of the request.body. 91 | def put 92 | if @request.env['HTTP_CONTENT_MD5'] 93 | content_md5_pass?(@request.env) or raise HTTPStatus::BadRequest.new('Content-MD5 mismatch') 94 | end 95 | 96 | write(@request.body) 97 | end 98 | 99 | # HTTP POST request. 100 | # 101 | # Usually forbidden. 102 | def post 103 | raise HTTPStatus::Forbidden 104 | end 105 | 106 | # HTTP DELETE request. 107 | # 108 | # Delete this resource. 109 | def delete 110 | if stat.directory? 111 | Dir.rmdir(file_path) 112 | else 113 | File.unlink(file_path) 114 | end 115 | end 116 | 117 | # HTTP COPY request. 118 | # 119 | # Copy this resource to given destination resource. 120 | def copy(dest) 121 | if stat.directory? 122 | dest.make_collection 123 | else 124 | open(file_path, "rb") do |file| 125 | dest.write(file) 126 | end 127 | 128 | list_custom_properties.each do |prop| 129 | dest.set_custom_property(prop, get_custom_property(prop)) 130 | end 131 | end 132 | end 133 | 134 | # HTTP MOVE request. 135 | # 136 | # Move this resource to given destination resource. 137 | def move(dest) 138 | copy(dest) 139 | delete 140 | end 141 | 142 | # HTTP MKCOL request. 143 | # 144 | # Create this resource as collection. 145 | def make_collection 146 | Dir.mkdir(file_path) 147 | end 148 | 149 | # Write to this resource from given IO. 150 | def write(io) 151 | tempfile = "#{file_path}.#{Process.pid}.#{object_id}" 152 | 153 | open(tempfile, "wb") do |file| 154 | while part = io.read(8192) 155 | file << part 156 | end 157 | end 158 | 159 | File.rename(tempfile, file_path) 160 | ensure 161 | File.unlink(tempfile) rescue nil 162 | end 163 | 164 | 165 | private 166 | 167 | def root 168 | @options[:root] 169 | end 170 | 171 | def file_path 172 | root + '/' + path 173 | end 174 | 175 | def stat 176 | @stat ||= File.stat(file_path) 177 | end 178 | 179 | def content_md5_pass?(env) 180 | expected = env['HTTP_CONTENT_MD5'] or return true 181 | 182 | body = env['rack.input'].dup 183 | digest = Digest::MD5.new.digest(body.read) 184 | actual = [ digest ].pack('m').strip 185 | 186 | body.rewind 187 | 188 | expected == actual 189 | end 190 | 191 | end 192 | 193 | end 194 | -------------------------------------------------------------------------------- /lib/rack_dav/resource.rb: -------------------------------------------------------------------------------- 1 | module RackDAV 2 | 3 | class Resource 4 | 5 | attr_reader :path, :options 6 | 7 | def initialize(path, request, response, options) 8 | @path = path 9 | @request = request 10 | @response = response 11 | @options = options 12 | end 13 | 14 | # If this is a collection, return the child resources. 15 | def children 16 | raise NotImplementedError 17 | end 18 | 19 | # Is this resource a collection? 20 | def collection? 21 | raise NotImplementedError 22 | end 23 | 24 | # Does this recource exist? 25 | def exist? 26 | raise NotImplementedError 27 | end 28 | 29 | # Return the creation time. 30 | def creation_date 31 | raise NotImplementedError 32 | end 33 | 34 | # Return the time of last modification. 35 | def last_modified 36 | raise NotImplementedError 37 | end 38 | 39 | # Set the time of last modification. 40 | def last_modified=(time) 41 | raise NotImplementedError 42 | end 43 | 44 | # Return an Etag, an unique hash value for this resource. 45 | def etag 46 | raise NotImplementedError 47 | end 48 | 49 | # Return the resource type. 50 | # 51 | # If this is a collection, return a collection element 52 | def resource_type 53 | if collection? 54 | Nokogiri::XML::fragment('').children.first 55 | end 56 | end 57 | 58 | # Return the mime type of this resource. 59 | def content_type 60 | raise NotImplementedError 61 | end 62 | 63 | # Return the size in bytes for this resource. 64 | def content_length 65 | raise NotImplementedError 66 | end 67 | 68 | # HTTP GET request. 69 | # 70 | # Write the content of the resource to the response.body. 71 | def get 72 | raise NotImplementedError 73 | end 74 | 75 | # HTTP PUT request. 76 | # 77 | # Save the content of the request.body. 78 | def put 79 | raise NotImplementedError 80 | end 81 | 82 | # HTTP POST request. 83 | # 84 | # Usually forbidden. 85 | def post 86 | raise NotImplementedError 87 | end 88 | 89 | # HTTP DELETE request. 90 | # 91 | # Delete this resource. 92 | def delete 93 | raise NotImplementedError 94 | end 95 | 96 | # HTTP COPY request. 97 | # 98 | # Copy this resource to given destination resource. 99 | def copy(dest) 100 | raise NotImplementedError 101 | end 102 | 103 | # HTTP MOVE request. 104 | # 105 | # Move this resource to given destination resource. 106 | def move(dest) 107 | copy(dest) 108 | delete 109 | end 110 | 111 | # HTTP MKCOL request. 112 | # 113 | # Create this resource as collection. 114 | def make_collection 115 | raise NotImplementedError 116 | end 117 | 118 | def ==(other) 119 | path == other.path 120 | end 121 | 122 | def name 123 | File.basename(path) 124 | end 125 | 126 | def display_name 127 | name 128 | end 129 | 130 | def child(name, option={}) 131 | self.class.new(path + '/' + name, @request, @response, options) 132 | end 133 | 134 | def lockable? 135 | self.respond_to?(:lock) && self.respond_to?(:unlock) 136 | end 137 | 138 | def property_names 139 | %w(creationdate displayname getlastmodified getetag resourcetype getcontenttype getcontentlength) 140 | end 141 | 142 | def get_property(name) 143 | case name 144 | when 'resourcetype' then resource_type 145 | when 'displayname' then display_name 146 | when 'creationdate' then creation_date.xmlschema 147 | when 'getcontentlength' then content_length.to_s 148 | when 'getcontenttype' then content_type 149 | when 'getetag' then etag 150 | when 'getlastmodified' then last_modified.httpdate 151 | else self.get_custom_property(name) if self.respond_to?(:get_custom_property) 152 | end 153 | end 154 | 155 | def set_property(name, value) 156 | case name 157 | when 'resourcetype' then self.resource_type = value 158 | when 'getcontenttype' then self.content_type = value 159 | when 'getetag' then self.etag = value 160 | when 'getlastmodified' then self.last_modified = Time.httpdate(value) 161 | else self.set_custom_property(name, value) if self.respond_to?(:set_custom_property) 162 | end 163 | rescue ArgumentError 164 | raise HTTPStatus::Conflict 165 | end 166 | 167 | def remove_property(name) 168 | raise HTTPStatus::Forbidden if property_names.include?(name) 169 | end 170 | 171 | def parent 172 | elements = @path.scan(/[^\/]+/) 173 | return nil if elements.empty? 174 | self.class.new('/' + elements[0..-2].to_a.join('/'), @options) 175 | end 176 | 177 | def descendants 178 | list = [] 179 | children.each do |child| 180 | list << child 181 | list.concat(child.descendants) 182 | end 183 | list 184 | end 185 | 186 | end 187 | 188 | end 189 | -------------------------------------------------------------------------------- /lib/rack_dav/controller.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | 3 | require_relative 'string' 4 | 5 | module RackDAV 6 | 7 | class Controller 8 | include RackDAV::HTTPStatus 9 | 10 | attr_reader :request, :response, :resource 11 | 12 | @@uri = URI::RFC2396_Parser.new 13 | 14 | def initialize(request, response, options) 15 | @request = request 16 | @response = response 17 | @options = options 18 | @resource = resource_class.new(url_unescape(request.path_info), @request, @response, @options) 19 | raise Forbidden if request.path_info.include?('../') 20 | end 21 | 22 | def url_escape(s) 23 | @@uri.escape(s) 24 | end 25 | 26 | def url_unescape(s) 27 | @@uri.unescape(s).force_valid_encoding 28 | end 29 | 30 | def options 31 | response["Allow"] = 'OPTIONS,HEAD,GET,PUT,POST,DELETE,PROPFIND,PROPPATCH,MKCOL,COPY,MOVE' 32 | response["Dav"] = "1" 33 | 34 | if resource.lockable? 35 | response["Allow"] << ",LOCK,UNLOCK" 36 | response["Dav"] << ",2" 37 | end 38 | 39 | response["Ms-Author-Via"] = "DAV" 40 | end 41 | 42 | def head 43 | raise NotFound if not resource.exist? 44 | response['Etag'] = resource.etag 45 | response['Content-Type'] = resource.content_type 46 | response['Content-Length'] = resource.content_length.to_s 47 | response['Last-Modified'] = resource.last_modified.httpdate 48 | end 49 | 50 | def get 51 | raise NotFound if not resource.exist? 52 | response['Etag'] = resource.etag 53 | response['Content-Type'] = resource.content_type 54 | response['Content-Length'] = resource.content_length.to_s 55 | response['Last-Modified'] = resource.last_modified.httpdate 56 | map_exceptions do 57 | resource.get 58 | end 59 | end 60 | 61 | def put 62 | raise Forbidden if resource.collection? 63 | map_exceptions do 64 | resource.put 65 | end 66 | response.status = Created.to_i 67 | end 68 | 69 | def post 70 | map_exceptions do 71 | resource.post 72 | end 73 | end 74 | 75 | def delete 76 | raise NotFound if not resource.exist? 77 | 78 | delete_recursive(resource, errors = []) 79 | 80 | if errors.empty? 81 | response.status = NoContent.to_i 82 | else 83 | multistatus do |xml| 84 | response_errors(xml, errors) 85 | end 86 | end 87 | end 88 | 89 | def mkcol 90 | # Reject message bodies - RFC2518:8.3.1 91 | body = @request.body.read(8) 92 | fail UnsupportedMediaType if !body.nil? && body.length > 0 93 | 94 | map_exceptions do 95 | resource.make_collection 96 | end 97 | response.status = Created.to_i 98 | end 99 | 100 | def copy 101 | raise NotFound if not resource.exist? 102 | 103 | dest_uri = URI.parse(env['HTTP_DESTINATION']) 104 | destination = parse_destination(dest_uri) 105 | 106 | raise BadGateway if dest_uri.host and dest_uri.host != request.host 107 | raise Forbidden if destination == resource.path 108 | 109 | dest = resource_class.new(destination, @request, @response, @options) 110 | raise PreconditionFailed if dest.exist? && !overwrite 111 | 112 | dest = dest.child(resource.name) if dest.collection? 113 | 114 | dest_existed = dest.exist? 115 | 116 | copy_recursive(resource, dest, depth, errors = []) 117 | 118 | if errors.empty? 119 | response.status = dest_existed ? NoContent.to_i : Created.to_i 120 | else 121 | multistatus do |xml| 122 | response_errors(xml, errors) 123 | end 124 | end 125 | rescue URI::InvalidURIError => e 126 | raise BadRequest.new(e.message) 127 | end 128 | 129 | def move 130 | raise NotFound if not resource.exist? 131 | 132 | dest_uri = URI.parse(env['HTTP_DESTINATION']) 133 | destination = parse_destination(dest_uri) 134 | 135 | raise BadGateway if dest_uri.host and dest_uri.host != request.host 136 | raise Forbidden if destination == resource.path 137 | 138 | dest = resource_class.new(destination, @request, @response, @options) 139 | raise PreconditionFailed if dest.exist? && !overwrite 140 | 141 | dest_existed = dest.exist? 142 | dest = dest.child(resource.name) if dest.collection? 143 | 144 | raise Conflict if depth <= 1 145 | 146 | copy_recursive(resource, dest, depth, errors = []) 147 | delete_recursive(resource, errors) 148 | 149 | if errors.empty? 150 | response.status = dest_existed ? NoContent.to_i : Created.to_i 151 | else 152 | multistatus do |xml| 153 | response_errors(xml, errors) 154 | end 155 | end 156 | rescue URI::InvalidURIError => e 157 | raise BadRequest.new(e.message) 158 | end 159 | 160 | def propfind 161 | raise NotFound if not resource.exist? 162 | 163 | if not request_match("/d:propfind/d:allprop").empty? 164 | nodes = all_prop_nodes 165 | else 166 | nodes = request_match("/d:propfind/d:prop/*") 167 | nodes = all_prop_nodes if nodes.empty? 168 | end 169 | 170 | nodes.each do |n| 171 | # Don't allow empty namespace declarations 172 | # See litmus props test 3 173 | raise BadRequest if n.namespace.nil? && n.namespace_definitions.empty? 174 | 175 | # Set a blank namespace if one is included in the request 176 | # See litmus props test 16 177 | # 178 | if n.namespace.nil? 179 | nd = n.namespace_definitions.first 180 | if nd.prefix.nil? && nd.href.empty? 181 | n.add_namespace(nil, '') 182 | end 183 | end 184 | end 185 | 186 | multistatus do |xml| 187 | for resource in find_resources 188 | resource.path.gsub!(/\/\//, '/') 189 | xml.response do 190 | xml.href "http://#{host}#{@request.script_name}#{url_escape resource.path}" 191 | propstats xml, get_properties(resource, nodes) 192 | end 193 | end 194 | end 195 | end 196 | 197 | def proppatch 198 | raise NotFound if not resource.exist? 199 | 200 | nodes = request_match("/d:propertyupdate[d:remove/d:prop/* or d:set/d:prop/*]//d:prop/*") 201 | 202 | # Set a blank namespace if one is included in the request 203 | # See litmus props test 15 204 | # 205 | # randomvalue 206 | # 207 | nodes.each do |n| 208 | nd = n.namespace_definitions.first 209 | if !nd.nil? && nd.prefix.nil? && nd.href.empty? 210 | n.add_namespace(nil, '') 211 | end 212 | end 213 | 214 | multistatus do |xml| 215 | for resource in find_resources 216 | xml.response do 217 | xml.href "http://#{host}#{@request.script_name}#{resource.path}" 218 | propstats xml, set_properties(resource, nodes) 219 | end 220 | end 221 | end 222 | end 223 | 224 | def lock 225 | raise MethodNotAllowed unless resource.lockable? 226 | raise NotFound if not resource.exist? 227 | 228 | timeout = request_timeout 229 | if timeout.nil? || timeout.zero? 230 | timeout = 60 231 | end 232 | 233 | if request_document.content.empty? 234 | refresh_lock timeout 235 | else 236 | create_lock timeout 237 | end 238 | end 239 | 240 | def unlock 241 | raise MethodNotAllowed unless resource.lockable? 242 | 243 | locktoken = request_locktoken('LOCK_TOKEN') 244 | raise BadRequest if locktoken.nil? 245 | 246 | response.status = resource.unlock(locktoken) ? NoContent.to_i : Forbidden.to_i 247 | end 248 | 249 | private 250 | 251 | def env 252 | @request.env 253 | end 254 | 255 | def host 256 | @request.host 257 | end 258 | 259 | def resource_class 260 | @options[:resource_class] 261 | end 262 | 263 | def depth 264 | case env['HTTP_DEPTH'] 265 | when '0' then 0 266 | when '1' then 1 267 | else 100 268 | end 269 | end 270 | 271 | def overwrite 272 | env['HTTP_OVERWRITE'].to_s.upcase != 'F' 273 | end 274 | 275 | def find_resources 276 | case env['HTTP_DEPTH'] 277 | when '0' 278 | [resource] 279 | when '1' 280 | [resource] + resource.children 281 | else 282 | [resource] + resource.descendants 283 | end 284 | end 285 | 286 | def delete_recursive(res, errors) 287 | for child in res.children 288 | delete_recursive(child, errors) 289 | end 290 | 291 | begin 292 | map_exceptions { res.delete } if errors.empty? 293 | rescue Status 294 | errors << [res.path, $!] 295 | end 296 | end 297 | 298 | def copy_recursive(res, dest, depth, errors) 299 | map_exceptions do 300 | if dest.exist? 301 | if overwrite 302 | delete_recursive(dest, errors) 303 | else 304 | raise PreconditionFailed 305 | end 306 | end 307 | res.copy(dest) 308 | end 309 | rescue Status 310 | errors << [res.path, $!] 311 | else 312 | if depth > 0 313 | for child in res.children 314 | dest_child = dest.child(child.name) 315 | copy_recursive(child, dest_child, depth - 1, errors) 316 | end 317 | end 318 | end 319 | 320 | def map_exceptions 321 | yield 322 | rescue 323 | case $! 324 | when URI::InvalidURIError then raise BadRequest 325 | when Errno::EACCES then raise Forbidden 326 | when Errno::ENOENT then raise Conflict 327 | when Errno::EEXIST then raise Conflict 328 | when Errno::ENOSPC then raise InsufficientStorage 329 | else 330 | raise 331 | end 332 | end 333 | 334 | def request_document 335 | @request_document ||= if (body = request.body.read).empty? 336 | Nokogiri::XML::Document.new 337 | else 338 | Nokogiri::XML(body, &:strict) 339 | end 340 | 341 | rescue Nokogiri::XML::SyntaxError, RuntimeError # Nokogiri raise RuntimeError :-( 342 | raise BadRequest 343 | end 344 | 345 | def request_match(pattern) 346 | request_document.xpath(pattern, 'd' => 'DAV:') 347 | end 348 | 349 | def qualified_node_name(node) 350 | node.namespace.nil? || node.namespace.prefix.nil? ? node.name : "#{node.namespace.prefix}:#{node.name}" 351 | end 352 | 353 | def qualified_property_name(node) 354 | node.namespace.nil? || node.namespace.href == 'DAV:' ? node.name : "{#{node.namespace.href}}#{node.name}" 355 | end 356 | 357 | def all_prop_nodes 358 | resource.property_names.map do |n| 359 | node = Nokogiri::XML::Element.new(n, request_document) 360 | node.add_namespace(nil, 'DAV:') 361 | node 362 | end 363 | end 364 | 365 | # Quick and dirty parsing of the WEBDAV Timeout header. 366 | # Refuses infinity, rejects anything but Second- timeouts 367 | # 368 | # @return [nil] or [Fixnum] 369 | # 370 | # @api internal 371 | # 372 | def request_timeout 373 | timeout = request.env['HTTP_TIMEOUT'] 374 | return if timeout.nil? || timeout.empty? 375 | 376 | timeout = timeout.split /,\s*/ 377 | timeout.reject! {|t| t !~ /^Second-/} 378 | timeout.first.sub('Second-', '').to_i 379 | end 380 | 381 | def request_locktoken(header) 382 | token = request.env["HTTP_#{header}"] 383 | return if token.nil? || token.empty? 384 | token.scan /^\(??\)?$/ 385 | return $1 386 | end 387 | 388 | # Creates a new XML document, yields given block 389 | # and sets the response.body with the final XML content. 390 | # The response length is updated accordingly. 391 | # 392 | # @return [void] 393 | # 394 | # @yield [xml] Yields the Builder XML instance. 395 | # 396 | # @api internal 397 | # 398 | def render_xml 399 | content = Nokogiri::XML::Builder.new(:encoding => "UTF-8") do |xml| 400 | yield xml 401 | end.to_xml 402 | response.body = [content] 403 | response["Content-Type"] = 'text/xml; charset=utf-8' 404 | response["Content-Length"] = content.to_s.bytesize.to_s 405 | end 406 | 407 | def multistatus 408 | render_xml do |xml| 409 | xml.multistatus('xmlns' => "DAV:") do 410 | yield xml 411 | end 412 | end 413 | 414 | response.status = MultiStatus 415 | end 416 | 417 | def response_errors(xml, errors) 418 | for path, status in errors 419 | xml.response do 420 | xml.href "http://#{host}#{path}" 421 | xml.status "#{request.env['HTTP_VERSION']} #{status.status_line}" 422 | end 423 | end 424 | end 425 | 426 | def get_properties(resource, nodes) 427 | stats = Hash.new { |h, k| h[k] = [] } 428 | for node in nodes 429 | begin 430 | map_exceptions do 431 | stats[OK] << [node, resource.get_property(qualified_property_name(node))] 432 | end 433 | rescue Status 434 | stats[$!] << node 435 | end 436 | end 437 | stats 438 | end 439 | 440 | def set_properties(resource, nodes) 441 | stats = Hash.new { |h, k| h[k] = [] } 442 | for node in nodes 443 | begin 444 | map_exceptions do 445 | stats[OK] << [node, resource.set_property(qualified_property_name(node), node.text)] 446 | end 447 | rescue Status 448 | stats[$!] << node 449 | end 450 | end 451 | stats 452 | end 453 | 454 | def propstats(xml, stats) 455 | return if stats.empty? 456 | for status, props in stats 457 | xml.propstat do 458 | xml.prop do 459 | for node, value in props 460 | if value.is_a?(Nokogiri::XML::Node) 461 | xml.send(qualified_node_name(node).to_sym) do 462 | rexml_convert(xml, value) 463 | end 464 | else 465 | attrs = {} 466 | unless node.namespace.nil? 467 | unless node.namespace.prefix.nil? 468 | attrs = { "xmlns:#{node.namespace.prefix}" => node.namespace.href } 469 | else 470 | attrs = { 'xmlns' => node.namespace.href } 471 | end 472 | end 473 | 474 | xml.send(qualified_node_name(node).to_sym, value, attrs) 475 | end 476 | end 477 | end 478 | xml.status "#{request.env['HTTP_VERSION']} #{status.status_line}" 479 | end 480 | end 481 | end 482 | 483 | def create_lock(timeout) 484 | lockscope = request_match("/d:lockinfo/d:lockscope/d:*").first 485 | lockscope = lockscope.name if lockscope 486 | locktype = request_match("/d:lockinfo/d:locktype/d:*").first 487 | locktype = locktype.name if locktype 488 | owner = request_match("/d:lockinfo/d:owner/d:href").first 489 | owner = owner.text if owner 490 | locktoken = "opaquelocktoken:" + sprintf('%x-%x-%s', Time.now.to_i, Time.now.sec, resource.etag) 491 | 492 | # Quick & Dirty - FIXME: Lock should become a new Class 493 | # and this dirty parameter passing refactored. 494 | unless resource.lock(locktoken, timeout, lockscope, locktype, owner) 495 | raise Forbidden 496 | end 497 | 498 | response['Lock-Token'] = locktoken 499 | 500 | render_lockdiscovery locktoken, lockscope, locktype, timeout, owner 501 | end 502 | 503 | def refresh_lock(timeout) 504 | locktoken = request_locktoken('IF') 505 | raise BadRequest if locktoken.nil? 506 | 507 | timeout, lockscope, locktype, owner = resource.lock(locktoken, timeout) 508 | unless lockscope && locktype && timeout 509 | raise Forbidden 510 | end 511 | 512 | render_lockdiscovery locktoken, lockscope, locktype, timeout, owner 513 | end 514 | 515 | # FIXME add multiple locks support 516 | def render_lockdiscovery(locktoken, lockscope, locktype, timeout, owner) 517 | render_xml do |xml| 518 | xml.prop('xmlns' => "DAV:") do 519 | xml.lockdiscovery do 520 | render_lock(xml, locktoken, lockscope, locktype, timeout, owner) 521 | end 522 | end 523 | end 524 | end 525 | 526 | def render_lock(xml, locktoken, lockscope, locktype, timeout, owner) 527 | xml.activelock do 528 | xml.lockscope { xml.tag! lockscope } 529 | xml.locktype { xml.tag! locktype } 530 | xml.depth 'Infinity' 531 | if owner 532 | xml.owner { xml.href owner } 533 | end 534 | xml.timeout "Second-#{timeout}" 535 | xml.locktoken do 536 | xml.href locktoken 537 | end 538 | end 539 | end 540 | 541 | def rexml_convert(xml, element) 542 | if element.elements.empty? 543 | if element.text 544 | xml.send(element.name.to_sym, element.text, element.attributes) 545 | else 546 | xml.send(element.name.to_sym, element.attributes) 547 | end 548 | else 549 | xml.send(element.name.to_sym, element.attributes) do 550 | element.elements.each do |child| 551 | rexml_convert(xml, child) 552 | end 553 | end 554 | end 555 | end 556 | 557 | def parse_destination dest_uri 558 | destination = url_unescape(dest_uri.path) 559 | destination.slice!(1..@request.script_name.length) if @request.script_name.length > 0 560 | destination 561 | end 562 | 563 | end 564 | 565 | end 566 | -------------------------------------------------------------------------------- /spec/controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'fileutils' 3 | 4 | require 'rack/mock' 5 | 6 | require 'support/lockable_file_resource' 7 | 8 | class Rack::MockResponse 9 | 10 | attr_reader :original_response 11 | 12 | def initialize_with_original(*args) 13 | status, headers, @original_response = *args 14 | initialize_without_original(*args) 15 | end 16 | 17 | alias_method :initialize_without_original, :initialize 18 | alias_method :initialize, :initialize_with_original 19 | 20 | def media_type_params 21 | return {} if content_type.nil? 22 | Hash[*content_type.split(/\s*[;,]\s*/)[1..-1]. 23 | collect { |s| s.split('=', 2) }. 24 | map { |k,v| [k.downcase, v] }.flatten] 25 | end 26 | 27 | end 28 | 29 | describe RackDAV::Handler do 30 | 31 | DOC_ROOT = File.expand_path(File.dirname(__FILE__) + '/htdocs') 32 | METHODS = %w(GET PUT POST DELETE PROPFIND PROPPATCH MKCOL COPY MOVE OPTIONS HEAD LOCK UNLOCK) 33 | CLASS_2 = METHODS 34 | CLASS_1 = CLASS_2 - %w(LOCK UNLOCK) 35 | 36 | before do 37 | FileUtils.mkdir(DOC_ROOT) unless File.exist?(DOC_ROOT) 38 | end 39 | 40 | after do 41 | FileUtils.rm_rf(DOC_ROOT) if File.exist?(DOC_ROOT) 42 | end 43 | 44 | attr_reader :response 45 | 46 | shared_examples :lockable_resource do 47 | 48 | 49 | describe "OPTIONS" do 50 | it "is successful" do 51 | options(url_root).should be_ok 52 | end 53 | 54 | it "sets the allow header with class 2 methods" do 55 | options(url_root) 56 | CLASS_2.each do |method| 57 | response.headers['allow'].should include(method) 58 | end 59 | end 60 | end 61 | 62 | describe "LOCK" do 63 | before(:each) do 64 | put(url_root + 'test', :input => "body").should be_created 65 | lock(url_root + 'test', :input => File.read(fixture("requests/lock.xml"))) 66 | end 67 | 68 | describe "creation" do 69 | it "succeeds" do 70 | response.should be_ok 71 | end 72 | 73 | it "sets a compliant rack response" do 74 | body = response.original_response 75 | body.should be_a(Array) 76 | expect(body.size).to eq(1) 77 | end 78 | 79 | it "prints the lockdiscovery" do 80 | lockdiscovery_response response_locktoken 81 | end 82 | end 83 | 84 | describe "refreshing" do 85 | context "a valid locktoken" do 86 | it "prints the lockdiscovery" do 87 | token = response_locktoken 88 | lock(url_root + 'test', 'HTTP_IF' => "(#{token})").should be_ok 89 | lockdiscovery_response token 90 | end 91 | 92 | it "accepts it without parenthesis" do 93 | token = response_locktoken 94 | lock(url_root + 'test', 'HTTP_IF' => token).should be_ok 95 | lockdiscovery_response token 96 | end 97 | 98 | it "accepts it with excess angular braces (office 2003)" do 99 | token = response_locktoken 100 | lock(url_root + 'test', 'HTTP_IF' => "(<#{token}>)").should be_ok 101 | lockdiscovery_response token 102 | end 103 | end 104 | 105 | context "an invalid locktoken" do 106 | it "bails out" do 107 | lock(url_root + 'test', 'HTTP_IF' => '123') 108 | response.should be_forbidden 109 | response.body.should be_empty 110 | end 111 | end 112 | 113 | context "no locktoken" do 114 | it "bails out" do 115 | lock(url_root + 'test') 116 | response.should be_bad_request 117 | response.body.should be_empty 118 | end 119 | end 120 | 121 | end 122 | end 123 | 124 | describe "UNLOCK" do 125 | before(:each) do 126 | put(url_root + 'test', :input => "body").should be_created 127 | lock(url_root + 'test', :input => File.read(fixture("requests/lock.xml"))).should be_ok 128 | end 129 | 130 | context "given a valid token" do 131 | before(:each) do 132 | token = response_locktoken 133 | unlock(url_root + 'test', 'HTTP_LOCK_TOKEN' => "(#{token})") 134 | end 135 | 136 | it "unlocks the resource" do 137 | response.should be_no_content 138 | end 139 | end 140 | 141 | context "given an invalid token" do 142 | before(:each) do 143 | unlock(url_root + 'test', 'HTTP_LOCK_TOKEN' => '(123)') 144 | end 145 | 146 | it "bails out" do 147 | response.should be_forbidden 148 | end 149 | end 150 | 151 | context "given no token" do 152 | before(:each) do 153 | unlock(url_root + 'test') 154 | end 155 | 156 | it "bails out" do 157 | response.should be_bad_request 158 | end 159 | end 160 | 161 | end 162 | end 163 | 164 | 165 | context "Given a Lockable resource" do 166 | context "when mounted directly" do 167 | before do 168 | @controller = RackDAV::Handler.new( 169 | :root => DOC_ROOT, 170 | :resource_class => RackDAV::LockableFileResource 171 | ) 172 | end 173 | 174 | let(:url_root){ '/' } 175 | include_examples :lockable_resource 176 | end 177 | 178 | context "When mounted via a URLMap" do 179 | before do 180 | @controller = Rack::URLMap.new( 181 | "/dav" => RackDAV::Handler.new( 182 | :root => DOC_ROOT, 183 | :resource_class => RackDAV::LockableFileResource 184 | ) 185 | ) 186 | end 187 | 188 | let(:url_root){ '/dav/' } 189 | include_examples :lockable_resource 190 | end 191 | end 192 | 193 | shared_examples :not_lockable_resource do 194 | describe "uri escaping" do 195 | it "allows url escaped utf-8" do 196 | put(url_root + 'D%C3%B6ner').should be_created 197 | get(url_root + 'D%C3%B6ner').should be_ok 198 | end 199 | 200 | it "allows url escaped iso-8859" do 201 | put(url_root + 'D%F6ner').should be_created 202 | get(url_root + 'D%F6ner').should be_ok 203 | end 204 | end 205 | 206 | describe "OPTIONS" do 207 | it "is successful" do 208 | options(url_root).should be_ok 209 | end 210 | 211 | it "sets the allow header with class 2 methods" do 212 | options(url_root) 213 | CLASS_1.each do |method| 214 | response.headers['allow'].should include(method) 215 | end 216 | end 217 | end 218 | 219 | describe "CONTENT-MD5 header exists" do 220 | context "doesn't match with body's checksum" do 221 | before do 222 | put(url_root + 'foo', :input => 'bar', 223 | 'HTTP_CONTENT_MD5' => 'baz') 224 | end 225 | 226 | it 'should return a Bad Request response' do 227 | response.should be_bad_request 228 | end 229 | 230 | it 'should not create the resource' do 231 | get(url_root + 'foo').should be_not_found 232 | end 233 | end 234 | 235 | context "matches with body's checksum" do 236 | before do 237 | put(url_root + 'foo', :input => 'bar', 238 | 'HTTP_CONTENT_MD5' => 'N7UdGUp1E+RbVvZSTy1R8g==') 239 | end 240 | 241 | it 'should be successful' do 242 | response.should be_created 243 | end 244 | 245 | it 'should create the resource' do 246 | get(url_root + 'foo').should be_ok 247 | response.body.should == 'bar' 248 | end 249 | end 250 | end 251 | 252 | it 'should return headers' do 253 | put(url_root + 'test.html', :input => '').should be_created 254 | head(url_root + 'test.html').should be_ok 255 | 256 | response.headers['etag'].should_not be_nil 257 | response.headers['content-type'].should match(/html/) 258 | response.headers['last-modified'].should_not be_nil 259 | end 260 | 261 | 262 | it 'should not find a nonexistent resource' do 263 | get(url_root + 'not_found').should be_not_found 264 | end 265 | 266 | it 'should not allow directory traversal' do 267 | get(url_root + '../htdocs').should be_forbidden 268 | end 269 | 270 | it 'should create a resource and allow its retrieval' do 271 | put(url_root + 'test', :input => 'body').should be_created 272 | get(url_root + 'test').should be_ok 273 | response.body.should == 'body' 274 | end 275 | 276 | it 'should create and find a url with escaped characters' do 277 | put(url_root + url_escape('/a b'), :input => 'body').should be_created 278 | get(url_root + url_escape('/a b')).should be_ok 279 | response.body.should == 'body' 280 | end 281 | 282 | it 'should delete a single resource' do 283 | put(url_root + 'test', :input => 'body').should be_created 284 | delete(url_root + 'test').should be_no_content 285 | end 286 | 287 | it 'should delete recursively' do 288 | mkcol(url_root + 'folder').should be_created 289 | put(url_root + 'folder/a', :input => 'body').should be_created 290 | put(url_root + 'folder/b', :input => 'body').should be_created 291 | 292 | delete(url_root + 'folder').should be_no_content 293 | get(url_root + 'folder').should be_not_found 294 | get(url_root + 'folder/a').should be_not_found 295 | get(url_root + 'folder/b').should be_not_found 296 | end 297 | 298 | it 'should return not found when deleting a non-existent resource' do 299 | delete(url_root + 'not_found').should be_not_found 300 | end 301 | 302 | it 'should not allow copy to another domain' do 303 | put(url_root + 'test', :input => 'body').should be_created 304 | copy('http://example.org' + url_root, 'HTTP_DESTINATION' => 'http://another/').should be_bad_gateway 305 | end 306 | 307 | it 'should not allow copy to the same resource' do 308 | put(url_root + 'test', :input => 'body').should be_created 309 | copy(url_root + 'test', 'HTTP_DESTINATION' => url_root + 'test').should be_forbidden 310 | end 311 | 312 | it 'should not allow an invalid destination uri' do 313 | put(url_root + 'test', :input => 'body').should be_created 314 | copy(url_root + 'test', 'HTTP_DESTINATION' => '%').should be_bad_request 315 | end 316 | 317 | it 'should copy a single resource' do 318 | put(url_root + 'test', :input => 'body').should be_created 319 | copy(url_root + 'test', 'HTTP_DESTINATION' => url_root + 'copy').should be_created 320 | get(url_root + 'copy').body.should == 'body' 321 | end 322 | 323 | it 'should copy a resource with escaped characters' do 324 | put(url_root + url_escape('/a b'), :input => 'body').should be_created 325 | copy(url_root + url_escape('/a b'), 'HTTP_DESTINATION' => url_root + url_escape('/a c')).should be_created 326 | get(url_root + url_escape('/a c')).should be_ok 327 | response.body.should == 'body' 328 | end 329 | 330 | it 'should deny a copy without overwrite' do 331 | put(url_root + 'test', :input => 'body').should be_created 332 | put(url_root + 'copy', :input => 'copy').should be_created 333 | copy(url_root + 'test', 'HTTP_DESTINATION' => url_root + 'copy', 'HTTP_OVERWRITE' => 'F').should be_precondition_failed 334 | 335 | get(url_root + 'copy').body.should == 'copy' 336 | end 337 | 338 | it 'should allow a copy with overwrite' do 339 | put(url_root + 'test', :input => 'body').should be_created 340 | put(url_root + 'copy', :input => 'copy').should be_created 341 | copy(url_root + 'test', 'HTTP_DESTINATION' => url_root + 'copy', 'HTTP_OVERWRITE' => 'T').should be_no_content 342 | get(url_root + 'copy').body.should == 'body' 343 | end 344 | 345 | it 'should deny a move to an existing resource without overwrite' do 346 | put(url_root + 'test', :input => 'body').should be_created 347 | put(url_root + 'copy', :input => 'copy').should be_created 348 | move(url_root + 'test', 'HTTP_DESTINATION' => url_root + 'copy', 'HTTP_OVERWRITE' => 'F').should be_precondition_failed 349 | end 350 | 351 | it 'should copy a collection' do 352 | mkcol(url_root + 'folder').should be_created 353 | copy(url_root + 'folder', 'HTTP_DESTINATION' => url_root + 'copy').should be_created 354 | propfind(url_root + 'copy', :input => propfind_xml(:resourcetype)) 355 | multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty 356 | end 357 | 358 | it 'should copy a collection resursively' do 359 | mkcol(url_root + 'folder').should be_created 360 | put(url_root + 'folder/a', :input => 'A').should be_created 361 | put(url_root + 'folder/b', :input => 'B').should be_created 362 | 363 | copy(url_root + 'folder', 'HTTP_DESTINATION' => url_root + 'copy').should be_created 364 | propfind(url_root + 'copy', :input => propfind_xml(:resourcetype)) 365 | multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty 366 | 367 | get(url_root + 'copy/a').body.should == 'A' 368 | get(url_root + 'copy/b').body.should == 'B' 369 | end 370 | 371 | it 'should move a collection recursively' do 372 | mkcol(url_root + 'folder').should be_created 373 | put(url_root + 'folder/a', :input => 'A').should be_created 374 | put(url_root + 'folder/b', :input => 'B').should be_created 375 | 376 | move(url_root + 'folder', 'HTTP_DESTINATION' => url_root + 'move').should be_created 377 | propfind(url_root + 'move', :input => propfind_xml(:resourcetype)) 378 | multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty 379 | 380 | get(url_root + 'move/a').body.should == 'A' 381 | get(url_root + 'move/b').body.should == 'B' 382 | get(url_root + 'folder/a').should be_not_found 383 | get(url_root + 'folder/b').should be_not_found 384 | end 385 | 386 | it 'should not move a collection onto an existing collection without overwrite' do 387 | mkcol(url_root + 'folder').should be_created 388 | mkcol(url_root + 'dest').should be_created 389 | 390 | move(url_root + 'folder', 'HTTP_DESTINATION' => url_root + 'dest', 'HTTP_OVERWRITE' => 'F').should be_precondition_failed 391 | end 392 | 393 | it 'should create a collection' do 394 | mkcol(url_root + 'folder').should be_created 395 | propfind(url_root + 'folder', :input => propfind_xml(:resourcetype)) 396 | multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty 397 | end 398 | 399 | it 'should not create a collection with a body' do 400 | mkcol(url_root + 'folder', :input => 'body').should be_unsupported_media_type 401 | end 402 | 403 | it 'should not find properties for nonexistent resources' do 404 | propfind(url_root + 'non').should be_not_found 405 | end 406 | 407 | it 'should find all properties' do 408 | xml = render do |xml| 409 | xml.propfind('xmlns' => "DAV:") do 410 | xml.allprop 411 | end 412 | end 413 | 414 | propfind('http://example.org' + url_root, :input => xml) 415 | 416 | multistatus_response('/d:href').first.text.strip.should == 'http://example.org' + url_root 417 | 418 | props = %w(creationdate displayname getlastmodified getetag resourcetype getcontenttype getcontentlength) 419 | props.each do |prop| 420 | multistatus_response('/d:propstat/d:prop/d:' + prop).should_not be_empty 421 | end 422 | end 423 | 424 | it 'should find named properties' do 425 | put(url_root + 'test.html', :input => '').should be_created 426 | propfind(url_root + 'test.html', :input => propfind_xml(:getcontenttype, :getcontentlength)) 427 | 428 | multistatus_response('/d:propstat/d:prop/d:getcontenttype').first.text.should == 'text/html' 429 | multistatus_response('/d:propstat/d:prop/d:getcontentlength').first.text.should == '7' 430 | end 431 | 432 | it 'should not set properties for a non-existent resource' do 433 | proppatch(url_root + 'not_found', :input => propset_xml([:foo, 'testing'])).should be_not_found 434 | end 435 | 436 | it 'should not return properties for non-existent resource' do 437 | propfind(url_root + 'prop', :input => propfind_xml(:foo)).should be_not_found 438 | end 439 | 440 | it 'should return the correct charset (utf-8)' do 441 | put(url_root + 'test.html', :input => '').should be_created 442 | propfind(url_root + 'test.html', :input => propfind_xml(:getcontenttype, :getcontentlength)) 443 | 444 | charset = @response.media_type_params['charset'] 445 | charset.should eql 'utf-8' 446 | end 447 | 448 | it 'should not support LOCK' do 449 | put(url_root + 'test', :input => 'body').should be_created 450 | 451 | xml = render do |xml| 452 | xml.lockinfo('xmlns:d' => "DAV:") do 453 | xml.lockscope { xml.exclusive } 454 | xml.locktype { xml.write } 455 | xml.owner { xml.href "http://test.de/" } 456 | end 457 | end 458 | 459 | lock(url_root + 'test', :input => xml).should be_method_not_allowed 460 | end 461 | 462 | it 'should not support UNLOCK' do 463 | put(url_root + 'test', :input => 'body').should be_created 464 | unlock(url_root + 'test', :input => '').should be_method_not_allowed 465 | end 466 | 467 | end 468 | 469 | context "Given a not lockable resource" do 470 | context "when mounted directly" do 471 | before do 472 | @controller = RackDAV::Handler.new( 473 | :root => DOC_ROOT, 474 | :resource_class => RackDAV::FileResource 475 | ) 476 | end 477 | 478 | let(:url_root){ '/' } 479 | include_examples :not_lockable_resource 480 | end 481 | 482 | context "When mounted via a URLMap" do 483 | let(:url_root){ '/dav/' } 484 | 485 | before do 486 | @controller = Rack::URLMap.new( 487 | "/dav" => RackDAV::Handler.new( 488 | :root => DOC_ROOT, 489 | :resource_class => RackDAV::FileResource 490 | ) 491 | ) 492 | end 493 | 494 | include_examples :not_lockable_resource 495 | end 496 | end 497 | 498 | private 499 | 500 | def request(method, uri, options={}) 501 | options = { 502 | 'REMOTE_USER' => 'manni' 503 | }.merge(options) 504 | request = Rack::MockRequest.new(@controller) 505 | @response = request.request(method, uri, options) 506 | end 507 | 508 | METHODS.each do |method| 509 | define_method(method.downcase) do |*args| 510 | request(method, *args) 511 | end 512 | end 513 | 514 | 515 | def render 516 | Nokogiri::XML::Builder.new(:encoding => "UTF-8") do |xml| 517 | yield xml 518 | end.to_xml 519 | end 520 | 521 | def url_escape(string) 522 | string.gsub(/([^ a-zA-Z0-9_.-]+)/n) do 523 | '%' + $1.unpack('H2' * $1.size).join('%').upcase 524 | end.tr(' ', '+') 525 | end 526 | 527 | def response_xml 528 | @response_xml ||= Nokogiri::XML(@response.body) 529 | end 530 | 531 | def response_locktoken 532 | response_xml.xpath("/d:prop/d:lockdiscovery/d:activelock/d:locktoken/d:href", 'd' => 'DAV:').first.text 533 | end 534 | 535 | def lockdiscovery_response(token) 536 | match = lambda do |pattern| 537 | response_xml.xpath("/d:prop/d:lockdiscovery/d:activelock" + pattern, 'd' => 'DAV:') 538 | end 539 | 540 | match[''].should_not be_empty 541 | 542 | match['/d:locktype'].should_not be_empty 543 | match['/d:lockscope'].should_not be_empty 544 | match['/d:depth'].should_not be_empty 545 | match['/d:owner'].should_not be_empty 546 | match['/d:timeout'].should_not be_empty 547 | match['/d:locktoken/d:href'].should_not be_empty 548 | match['/d:locktoken/d:href'].first.text.should == token 549 | end 550 | 551 | def multistatus_response(pattern, ns=nil) 552 | xmlns = { 'd' => 'DAV:' } 553 | xmlns.merge!(ns) unless ns.nil? 554 | 555 | @response.should be_multi_status 556 | response_xml.xpath("/d:multistatus/d:response", xmlns).should_not be_empty 557 | response_xml.xpath("/d:multistatus/d:response" + pattern, xmlns) 558 | end 559 | 560 | def propfind_xml(*props) 561 | render do |xml| 562 | xml.propfind('xmlns' => "DAV:") do 563 | xml.prop do 564 | props.each do |prop, attrs| 565 | xml.send(prop.to_sym, attrs) 566 | end 567 | end 568 | end 569 | end 570 | end 571 | 572 | def propset_xml(*props) 573 | render do |xml| 574 | xml.propertyupdate('xmlns' => 'DAV:') do 575 | xml.set do 576 | xml.prop do 577 | props.each do |prop, value, attrs| 578 | attrs = {} if attrs.nil? 579 | xml.send(prop.to_sym, value, attrs) 580 | end 581 | end 582 | end 583 | end 584 | end 585 | end 586 | end 587 | --------------------------------------------------------------------------------