Dav4Rack gem replaced with a local copy

This commit is contained in:
Karel Picman 2018-03-14 17:35:40 +01:00
parent 8ece0ac007
commit a8ce29e2c0
25 changed files with 3130 additions and 1 deletions

View File

@ -6,6 +6,10 @@ Changelog for Redmine DMSF
Javascript on pages is loaded asynchronously
Obsolete Dav4Rack gem replaced with an up to date fork by Planio (Consequently WebDAV caching has been removed, sorry...)
Instead of usage their gem in the Gemfile:
gem 'dav4rack', git: 'https://github.com/planio-gmbh/dav4rack.git', branch: 'master'
The library is a part of the project (lib/dav4rack).
Cloned from gem https://github.com/planio-gmbh/dav4rack.git
Project members can be chosen as recipients when sending documents by email
Responsive view
Direct editing of document in MS Office

11
Gemfile
View File

@ -26,8 +26,17 @@ gem 'rubyzip', '>= 1.0.0'
gem 'zip-zip'
gem 'simple_enum'
gem 'uuidtools'
gem 'dav4rack', git: 'https://github.com/planio-gmbh/dav4rack.git', branch: 'master'
gem 'dalli'
# Redmine extensions
unless %w(easyproject easy_gantt).any? { |plugin| Dir.exist?(File.expand_path("../../#{plugin}", __FILE__)) }
gem 'redmine_extensions', '~> 0.2.5'
end
# Dav4Rack
gem 'rake'
gem 'mongo'
gem 'unicorn'
gem 'byebug', platforms: :mri
gem 'ruby-prof'
gem 'ox'

15
lib/dav4rack.rb Normal file
View File

@ -0,0 +1,15 @@
require 'time'
require 'uri'
require 'nokogiri'
require 'ox'
require 'rack'
require 'dav4rack/utils'
require 'dav4rack/http_status'
require 'dav4rack/resource'
require 'dav4rack/handler'
require 'dav4rack/controller'
module DAV4Rack
IS_18 = RUBY_VERSION[0,3] == '1.8'
end

426
lib/dav4rack/controller.rb Normal file
View File

@ -0,0 +1,426 @@
# frozen_string_literal: true
require 'uri'
require 'dav4rack/destination_header'
require 'dav4rack/request'
require 'dav4rack/xml_elements'
require 'dav4rack/xml_response'
module DAV4Rack
class Controller
include DAV4Rack::HTTPStatus
include DAV4Rack::Utils
attr_reader :request, :response, :resource
# request:: DAV4Rack::Request
# response:: Rack::Response
# options:: Options hash
# Create a new Controller.
#
# options will be passed on to the resource
def initialize(request, response, options={})
@options = options
@request = request
@response = response
@dav_extensions = options[:dav_extensions]
@always_include_dav_header = !!options[:always_include_dav_header]
@allow_unauthenticated_options_on_root = !!options[:allow_unauthenticated_options_on_root]
setup_resource
if @always_include_dav_header
add_dav_header
end
end
# main entry point, called by the Handler
def process
if skip_authorization? || authenticate
status = process_action || OK
else
status = HTTPStatus::Unauthorized
end
rescue HTTPStatus::Status => e
status = e
ensure
if status
response.status = status.code
if status.code == 401
response.body = authentication_error_message
response['WWW-Authenticate'] = "Basic realm=\"#{authentication_realm}\""
end
end
end
private
# delegates to the handler method matching this requests http method.
# must return an HTTPStatus. If nil / false, the resulting status will be
# 200/OK
def process_action
send request.request_method.downcase
end
# if true, resource#authenticate will never be called
def skip_authorization?
# Microsoft Web Client workaround (from RedmineDmsf plugin):
#
# MS web client will not attempt any authentication (it'd seem)
# until it's acknowledged a completed OPTIONS request.
#
# If the request method is OPTIONS return true, controller will simply
# call the options method within, which accesses nothing, just returns
# headers about the dav env.
return @allow_unauthenticated_options_on_root &&
request.request_method.downcase == 'options' &&
(request.path_info == '/' || request.path_info.empty?)
end
# Return response to OPTIONS
def options
status = resource.options request, response
if(status == OK)
add_dav_header
response['Allow'] ||= 'OPTIONS,HEAD,GET,PUT,POST,DELETE,PROPFIND,PROPPATCH,MKCOL,COPY,MOVE,LOCK,UNLOCK'
response['Ms-Author-Via'] ||= 'DAV'
end
status
end
# Return response to HEAD
def head
if(resource.exist?)
response['Etag'] = resource.etag
response['Content-Type'] = resource.content_type
response['Content-Length'] = resource.content_length.to_s
response['Last-Modified'] = resource.last_modified.httpdate
resource.head(request, response)
OK
else
NotFound
end
end
# Return response to GET
def get
if(resource.exist?)
res = resource.get(request, response)
if(res == OK && !resource.collection?)
response['Etag'] ||= resource.etag
response['Content-Type'] ||= resource.content_type
response['Content-Length'] ||= resource.content_length.to_s
response['Last-Modified'] ||= resource.last_modified.httpdate
end
res
else
NotFound
end
end
# Return response to PUT
def put
if request.get_header('HTTP_CONTENT_RANGE')
# An origin server that allows PUT on a given target resource MUST send
# a 400 (Bad Request) response to a PUT request that contains a
# Content-Range header field.
# Reference: http://tools.ietf.org/html/rfc7231#section-4.3.4
Logger.error 'Content-Range on PUT requests is forbidden.'
BadRequest
elsif(resource.collection?)
Forbidden
elsif(!resource.parent_exists? || !resource.parent_collection?)
Conflict
else
resource.lock_check if resource.supports_locking?
status = resource.put(request, response)
if status == Created
response['Location'] ||= request.url_for resource.path
end
response.body = response['Location'] || ''
status
end
end
# Return response to POST
def post
resource.post(request, response)
end
# Return response to DELETE
def delete
if(resource.exist?)
resource.lock_check if resource.supports_locking?
resource.delete
else
NotFound
end
end
# Return response to MKCOL
def mkcol
if request.content_length.to_i > 0
return UnsupportedMediaType
end
return MethodNotAllowed if resource.exist?
resource.lock_check if resource.supports_locking?
status = resource.make_collection
if(resource.use_compat_mkcol_response?)
url = request.url_for resource.path, collection: true
r = XmlResponse.new(response, resource.namespaces)
r.multistatus do |xml|
xml << r.response(url, status)
end
MultiStatus
else
status
end
end
# Move Resource to new location.
def move
return NotFound unless(resource.exist?)
return BadRequest unless request.depth == :infinity
return BadRequest unless dest = request.destination
if status = dest.validate
return status
end
resource.lock_check if resource.supports_locking?
return resource.move dest.path_info, request.overwrite?
end
# Return response to COPY
def copy
return NotFound unless(resource.exist?)
return BadRequest unless request.depth == :infinity or request.depth == 0
return BadRequest unless dest = request.destination
if status = dest.validate(host: request.host,
resource_path: resource.path)
return status
end
resource.copy dest.path_info, request.overwrite?, request.depth
end
# Return response to PROPFIND
def propfind
return NotFound unless resource.exist?
ns = request.ns
document = request.document if request.content_length.to_i > 0
propfind = document.xpath("//#{ns}propfind") if document
# propname request
if propfind and propfind.xpath("//#{ns}propname").first
r = XmlResponse.new(response, resource.namespaces)
r.multistatus do |xml|
xml << Ox::Raw.new(resource.propnames_xml_with_depth)
end
return MultiStatus
end
properties = if propfind.nil? or
propfind.empty? or
propfind.xpath("//#{ns}allprop").first
resource.properties
elsif prop = propfind.xpath("//#{ns}prop").first
prop.children
.find_all{ |item| item.element? }
.map{ |item|
# We should do this, but Nokogiri transforms prefix w/ null href
# into something valid. Oops.
# TODO: Hacky grep fix that's horrible
hsh = to_element_hash(item)
if(hsh.namespace.nil? && !ns.empty?)
return BadRequest if prop.to_s.scan(%r{<#{item.name}[^>]+xmlns=""}).empty?
end
hsh
}.compact
else
Logger.error "no properties found"
raise BadRequest
end
properties = properties.map{|property| {element: property}}
properties = resource.propfind_add_additional_properties(properties)
prop_xml = resource.properties_xml_with_depth(get: properties)
r = XmlResponse.new(response, resource.namespaces)
r.multistatus do |xml|
xml << r.raw(prop_xml)
end
MultiStatus
end
# Return response to PROPPATCH
def proppatch
return NotFound unless resource.exist?
return BadRequest unless doc = request.document
resource.lock_check if resource.supports_locking?
properties = {}
doc.xpath("/#{request.ns}propertyupdate").children.each do |element|
action = element.name
if action == 'set' || action == 'remove'
properties[action] ||= []
if prp = element.children.detect{|e|e.name == 'prop'}
prp.children.each do |elm|
next if elm.name == 'text'
properties[action] << { element: to_element_hash(elm), value: elm.text }
end
end
end
end
r = XmlResponse.new(response, resource.namespaces)
r.multistatus do |xml|
xml << Ox::Raw.new(resource.properties_xml_with_depth(properties))
end
MultiStatus
end
# Lock current resource
# NOTE: This will pass an argument hash to Resource#lock and
# wait for a success/failure response.
def lock
depth = request.depth
return BadRequest unless depth == 0 || depth == :infinity
asked = { depth: depth }
if timeout = request.env['Timeout']
asked[:timeout] = timeout.split(',').map{|x|x.strip}
end
ns = request.ns
if doc = request.document and lockinfo = doc.xpath("//#{ns}lockinfo")
asked[:scope] = lockinfo.xpath("//#{ns}lockscope").children.find_all{|n|n.element?}.map{|n|n.name}.first
asked[:type] = lockinfo.xpath("#{ns}locktype").children.find_all{|n|n.element?}.map{|n|n.name}.first
asked[:owner] = lockinfo.xpath("//#{ns}owner/#{ns}href").children.map{|n|n.text}.first
end
r = XmlResponse.new(response, resource.namespaces)
begin
lock_time, locktoken = resource.lock(asked)
r.render_lockdiscovery(
time: lock_time,
token: locktoken,
depth: asked[:depth].to_s,
scope: asked[:scope],
type: asked[:type],
owner: asked[:owner]
)
response.headers['Lock-Token'] = "<#{locktoken}>"
return resource.exist? ? OK : Created
rescue LockFailure => e
r.multistatus do |xml|
e.path_status.each_pair do |path, status|
xml << r.response(path, status)
end
end
return MultiStatus
end
end
# Unlock current resource
def unlock
resource.unlock(request.lock_token)
end
# Perform authentication
#
# implement your authentication by overriding Resource#authenticate
def authenticate
uname = nil
password = nil
if request.authorization?
auth = Rack::Auth::Basic::Request.new(request.env)
if(auth.basic? && auth.credentials)
uname = auth.credentials[0]
password = auth.credentials[1]
end
end
resource.authenticate uname, password
end
def authentication_error_message
resource.authentication_error_message
end
def authentication_realm
resource.authentication_realm
end
# override this for custom resource initialization
def setup_resource
@resource = resource_class.new(
request.unescaped_path_info, request, response, @options
)
end
# Class of the resource in use
def resource_class
unless @options[:resource_class]
require 'dav4rack/resources/file_resource'
@options[:resource_class] = FileResource
@options[:root] ||= Dir.pwd
end
@options[:resource_class]
end
def add_dav_header
unless response['Dav']
dav_support = ['1']
if resource.supports_locking?
# compliance is resource specific, only advertise 2 (locking) if
# supported on the resource.
dav_support << '2'
end
dav_support += @dav_extensions if @dav_extensions
response['Dav'] = dav_support * ', '
end
end
end
end

View File

@ -0,0 +1,39 @@
require 'addressable/uri'
module DAV4Rack
class DestinationHeader
attr_reader :host, :path, :path_info
def initialize(value, script_name: nil)
@script_name = script_name.to_s
@value = value.to_s.strip
parse
end
def parse
uri = Addressable::URI.parse @value
@host = uri.host
@path = Addressable::URI.unencode uri.path
if @script_name
if @path =~ /\A(?<path>#{Regexp.escape @script_name}(?<path_info>\/.*))\z/
@path_info = $~[:path_info]
else
raise ArgumentError, 'invalid destination header value'
end
end
end
# host must be the same, but path must differ
def validate(host: nil, resource_path: nil)
if host and self.host and self.host != host
DAV4Rack::HTTPStatus::BadGateway
elsif self.path == resource_path
DAV4Rack::HTTPStatus::Forbidden
end
end
end
end

39
lib/dav4rack/file.rb Normal file
View File

@ -0,0 +1,39 @@
require 'time'
require 'rack/utils'
require 'rack/mime'
module DAV4Rack
# DAV4Rack::File simply allows us to use Rack::File but with the
# specific location we deem appropriate
#
# FIXME is that used anywhere?
class File < Rack::File
attr_accessor :path
alias :to_path :path
def initialize(path)
@path = path
end
def _call(env)
begin
if F.file?(@path) && F.readable?(@path)
serving(env)
else
raise Errno::EPERM
end
rescue SystemCallError
not_found
end
end
def not_found
body = "File not found: #{Rack::Utils.unescape(env["PATH_INFO"])}\n"
[404, {"Content-Type" => "text/plain",
"Content-Length" => body.size.to_s,
"X-Cascade" => "pass"},
[body]]
end
end
end

View File

@ -0,0 +1,162 @@
require 'pstore'
module DAV4Rack
class FileResourceLock
attr_accessor :path
attr_accessor :token
attr_accessor :timeout
attr_accessor :depth
attr_accessor :user
attr_accessor :scope
attr_accessor :kind
attr_accessor :owner
attr_reader :created_at
attr_reader :root
class << self
def explicitly_locked?(path, croot=nil)
store = init_pstore(croot)
!!store.transaction(true){
store[:paths][path]
}
end
def implicitly_locked?(path, croot=nil)
store = init_pstore(croot)
!!store.transaction(true){
store[:paths].keys.detect do |check|
check.start_with?(path)
end
}
end
def explicit_locks(path, croot, args={})
end
def implicit_locks(path, croot)
end
def find_by_path(path, croot=nil)
lock = self.class.new(:path => path, :root => croot)
lock.token.nil? ? nil : lock
end
def find_by_token(token, croot=nil)
store = init_pstore(croot)
struct = store.transaction(true){
store[:tokens][token]
}
if(struct)
new(path: struct[:path], root: croot)
else
nil
end
end
def generate(path, user, token, croot)
lock = self.new(:root => croot)
lock.user = user
lock.path = path
lock.token = token
lock.save
lock
end
def root=(path)
@root = path
end
def root
@root || '/tmp/dav4rack'
end
def init_pstore(croot)
path = File.join(croot, '.attribs', 'locks.pstore')
FileUtils.mkdir_p(File.dirname(path)) unless File.directory?(File.dirname(path))
store = IS_18 ? PStore.new(path) : PStore.new(path, true)
store.transaction do
unless(store[:paths])
store[:paths] = {}
store[:tokens] = {}
store.commit
end
end
store
end
end
def initialize(args={})
@path = args[:path]
@root = args[:root]
@owner = args[:owner]
@store = init_pstore(@root)
@max_timeout = args[:max_timeout] || 86400
@default_timeout = args[:max_timeout] || 60
load_if_exists!
@new_record = true if token.nil?
end
def owner?(user)
user == owner
end
def reload
load_if_exists
self
end
def remaining_timeout
t = timeout.to_i - (Time.now.to_i - created_at.to_i)
t < 0 ? 0 : t
end
def save
struct = {
:path => path,
:token => token,
:timeout => timeout,
:depth => depth,
:created_at => Time.now,
:owner => owner
}
@store.transaction do
@store[:paths][path] = struct
@store[:tokens][token] = struct
@store.commit
end
@new_record = false
self
end
def destroy
@store.transaction do
@store[:paths].delete(path)
@store[:tokens].delete(token)
@store.commit
end
nil
end
private
def load_if_exists!
struct = @store.transaction do
@store[:paths][path]
end
if(struct)
@path = struct[:path]
@token = struct[:token]
@timeout = struct[:timeout]
@depth = struct[:depth]
@created_at = struct[:created_at]
@owner = struct[:owner]
end
self
end
def init_pstore(croot=nil)
self.class.init_pstore(croot || @root)
end
end
end

80
lib/dav4rack/handler.rb Normal file
View File

@ -0,0 +1,80 @@
# frozen_string_literal: true
require 'dav4rack/logger'
module DAV4Rack
class Handler
include DAV4Rack::HTTPStatus
# Options:
#
# - resource_class: your Resource implementation
# - controller_class: custom Controller implementation (optional).
# - root_uri_path: Path the handler is mapped to. Any resources
# instantiated will only see the part of the path below this root.
# - recursive_propfind_allowed (true) : set to false to disable
# potentially expensive Depth: Infinity propfinds
# - all options are passed on to your resource implementation and are
# accessible there as @options.
#
def initialize(options={})
@options = options.dup
Logger.set(*@options[:log_to])
end
def call(env)
start = Time.now
request = setup_request env
response = Rack::Response.new
Logger.info "Processing WebDAV request: #{request.path} (for #{request.ip} at #{Time.now}) [#{request.request_method}]"
controller = setup_controller request, response
controller.process
postprocess_response response
# Apache wants the body dealt with, so just read it and junk it
buf = true
buf = request.body.read(8192) while buf
if Logger.debug? and response.body.is_a?(String)
Logger.debug "Response String:\n#{response.body}"
end
Logger.info "Completed in: #{((Time.now.to_f - start.to_f) * 1000).to_i} ms | #{response.status} [#{request.url}]"
response.finish
rescue Exception => e
Logger.error "WebDAV Error: #{e}"
raise e
end
private
def postprocess_response(response)
if response.body.is_a?(String)
response['Content-Length'] ||= response.body.length.to_s
end
response.body = [response.body] unless response.body.respond_to?(:each)
end
def setup_request(env)
::DAV4Rack::Request.new env, @options
end
def setup_controller(request, response)
controller_class.new(request, response, @options)
end
def controller_class
@options[:controller_class] || ::DAV4Rack::Controller
end
end
end

100
lib/dav4rack/http_status.rb Normal file
View File

@ -0,0 +1,100 @@
# frozen_string_literal: true
module DAV4Rack
module HTTPStatus
class Status < Exception
class << self
attr_accessor :code, :reason_phrase
alias_method :to_i, :code
def status_line
"#{code} #{reason_phrase}"
end
end
def code
self.class.code
end
def reason_phrase
self.class.reason_phrase
end
def status_line
self.class.status_line
end
def to_i
self.class.to_i
end
end
StatusMessage = {
100 => 'Continue',
101 => 'Switching Protocols',
102 => 'Processing',
200 => 'OK',
201 => 'Created',
202 => 'Accepted',
203 => 'Non-Authoritative Information',
204 => 'No Content',
205 => 'Reset Content',
206 => 'Partial Content',
207 => 'Multi-Status',
300 => 'Multiple Choices',
301 => 'Moved Permanently',
302 => 'Found',
303 => 'See Other',
304 => 'Not Modified',
305 => 'Use Proxy',
307 => 'Temporary Redirect',
400 => 'Bad Request',
401 => 'Unauthorized',
402 => 'Payment Required',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
406 => 'Not Acceptable',
407 => 'Proxy Authentication Required',
408 => 'Request Timeout',
409 => 'Conflict',
410 => 'Gone',
411 => 'Length Required',
412 => 'Precondition Failed',
413 => 'Request Entity Too Large',
414 => 'Request-URI Too Large',
415 => 'Unsupported Media Type',
416 => 'Request Range Not Satisfiable',
417 => 'Expectation Failed',
422 => 'Unprocessable Entity',
423 => 'Locked',
424 => 'Failed Dependency',
500 => 'Internal Server Error',
501 => 'Not Implemented',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
504 => 'Gateway Timeout',
505 => 'HTTP Version Not Supported',
507 => 'Insufficient Storage'
}
StatusClasses = { }
StatusMessage.each do |code, reason_phrase|
klass = Class.new(Status)
klass.code = code
klass.reason_phrase = reason_phrase
klass_name = reason_phrase.gsub(/[ \-]/,'')
const_set(klass_name, klass)
StatusClasses[code] = klass
end
end
end

View File

@ -0,0 +1,22 @@
require 'dav4rack/interceptor_resource'
module DAV4Rack
class Interceptor
def initialize(app, args={})
@roots = args[:mappings].keys
@args = args
@app = app
@intercept_methods = %w(OPTIONS PROPFIND PROPPATCH MKCOL COPY MOVE LOCK UNLOCK)
@intercept_methods -= args[:ignore_methods] if args[:ignore_methods]
end
def call(env)
path = env['PATH_INFO'].downcase
method = env['REQUEST_METHOD'].upcase
app = nil
if(@roots.detect{|x| path =~ /^#{Regexp.escape(x.downcase)}\/?/}.nil? && @intercept_methods.include?(method))
app = DAV4Rack::Handler.new(:resource_class => InterceptorResource, :mappings => @args[:mappings], :log_to => @args[:log_to])
end
app ? app.call(env) : @app.call(env)
end
end
end

View File

@ -0,0 +1,119 @@
require 'digest/sha1'
module DAV4Rack
class InterceptorResource < Resource
attr_reader :path, :options
def initialize(*args)
super
@root_paths = @options[:mappings].keys
@mappings = @options[:mappings]
end
def children
childs = @root_paths.find_all{|x|x =~ /^#{Regexp.escape(@path)}/}
childs = childs.map{|a| child a.gsub(/^#{Regexp.escape(@path)}/, '').split('/').delete_if{|x|x.empty?}.first }.flatten
end
def collection?
true if exist?
end
def exist?
!@root_paths.find_all{|x| x =~ /^#{Regexp.escape(@path)}/}.empty?
end
def creation_date
Time.now
end
def last_modified
Time.now
end
def last_modified=(time)
Time.now
end
def etag
Digest::SHA1.hexdigest(@path)
end
def content_type
'text/html'
end
def content_length
0
end
def get(request, response)
raise Forbidden
end
def put(request, response)
raise Forbidden
end
def post(request, response)
raise Forbidden
end
def delete
raise Forbidden
end
def copy(dest)
raise Forbidden
end
def move(dest)
raise Forbidden
end
def make_collection
raise Forbidden
end
def ==(other)
path == other.path
end
def name
::File.basename(path)
end
def display_name
::File.basename(path.to_s)
end
def child(name, option={})
new_path = path.dup
new_path = '/' + new_path unless new_path[0,1] == '/'
new_path.slice!(-1) if new_path[-1,1] == '/'
name = '/' + name unless name[-1,1] == '/'
new_path = "#{new_path}#{name}"
new_public = public_path.dup
new_public = '/' + new_public unless new_public[0,1] == '/'
new_public.slice!(-1) if new_public[-1,1] == '/'
new_public = "#{new_public}#{name}"
if(key = @root_paths.find{|x| new_path =~ /^#{Regexp.escape(x.downcase)}\/?/})
@mappings[key][:resource_class].new(new_public, new_path.gsub(key, ''), request, response, {:root_uri_path => key, :user => @user}.merge(options).merge(@mappings[key]))
else
self.class.new(new_public, new_path, request, response, {:user => @user}.merge(options))
end
end
def descendants
list = []
children.each do |child|
list << child
list.concat(child.descendants)
end
list
end
end
end

40
lib/dav4rack/lock.rb Normal file
View File

@ -0,0 +1,40 @@
module DAV4Rack
class Lock
def initialize(args={})
@args = args
@store = nil
@args[:created_at] = Time.now
@args[:updated_at] = Time.now
end
def store
@store
end
def store=(s)
raise TypeError.new 'Expecting LockStore' unless s.respond_to? :remove
@store = s
end
def destroy
if(@store)
@store.remove(self)
end
end
def remaining_timeout
@args[:timeout].to_i - (Time.now.to_i - @args[:created_at].to_i)
end
def method_missing(*args)
if(@args.has_key?(args.first.to_sym))
@args[args.first.to_sym]
elsif(args.first.to_s[-1,1] == '=')
@args[args.first.to_s[0, args.first.to_s.length - 1].to_sym] = args[1]
else
raise NoMethodError.new "Undefined method #{args.first} for #{self}"
end
end
end
end

View File

@ -0,0 +1,61 @@
require 'dav4rack/lock'
module DAV4Rack
class LockStore
class << self
def create
@locks_by_path = {}
@locks_by_token = {}
end
def add(lock)
@locks_by_path[lock.path] = lock
@locks_by_token[lock.token] = lock
end
def remove(lock)
@locks_by_path.delete(lock.path)
@locks_by_token.delete(lock.token)
end
def find_by_path(path)
@locks_by_path.map do |lpath, lock|
lpath == path && lock.remaining_timeout > 0 ? lock : nil
end.compact.first
end
def find_by_token(token)
@locks_by_token.map do |ltoken, lock|
ltoken == token && lock.remaining_timeout > 0 ? lock : nil
end.compact.first
end
def explicit_locks(path)
@locks_by_path.map do |lpath, lock|
lpath == path && lock.remaining_timeout > 0 ? lock : nil
end.compact
end
def implicit_locks(path)
@locks_by_path.map do |lpath, lock|
lpath =~ /^#{Regexp.escape(path)}/ && lock.remaining_timeout > 0 && lock.depth > 0 ? lock : nil
end.compact
end
def explicitly_locked?(path)
self.explicit_locks(path).size > 0
end
def implicitly_locked?(path)
self.implicit_locks(path).size > 0
end
def generate(path, user, token)
l = Lock.new(:path => path, :user => user, :token => token)
l.store = self
add(l)
l
end
end
end
end
DAV4Rack::LockStore.create

30
lib/dav4rack/logger.rb Normal file
View File

@ -0,0 +1,30 @@
require 'logger'
module DAV4Rack
# This is a simple wrapper for the Logger class. It allows easy access
# to log messages from the library.
class Logger
class << self
# args:: Arguments for Logger -> [path, level] (level is optional) or a Logger instance
# Set the path to the log file.
def set(*args)
if(%w(info debug warn fatal).all?{|meth| args.first.respond_to?(meth)})
@@logger = args.first
elsif(args.first.respond_to?(:to_s) && !args.first.to_s.empty?)
@@logger = ::Logger.new(args.first.to_s, 'weekly')
elsif(args.first)
raise 'Invalid type specified for logger'
end
if(args.size > 1)
@@logger.level = args[1]
end
end
def method_missing(*args)
if(defined? @@logger)
@@logger.send *args
end
end
end
end
end

149
lib/dav4rack/remote_file.rb Normal file
View File

@ -0,0 +1,149 @@
require 'net/http'
require 'uri'
require 'digest/sha1'
require 'rack/file'
module DAV4Rack
#FIXME unused?
class RemoteFile < Rack::File
attr_accessor :path
alias :to_path :path
# path:: path to file (Actual path, preferably a URL since this is a *REMOTE* file)
# args:: Hash of arguments:
# :size -> Integer - number of bytes
# :mime_type -> String - mime type
# :last_modified -> String/Time - Time of last modification
# :sendfile -> True or String to define sendfile header variation
# :cache_directory -> Where to store cached files
# :cache_ref -> Reference to be used for cache file name (useful for changing URLs like S3)
# :sendfile_prefix -> String directory prefix. Eg: 'webdav' will result in: /wedav/#{path.sub('http://', '')}
# :sendfile_fail_gracefully -> Boolean if true will simply proxy if unable to determine proper sendfile
def initialize(path, args={})
@path = path
@args = args
@heads = {}
@cache_file = args[:cache_directory] ? cache_file_path : nil
@redefine_prefix = nil
if(@cache_file && File.exists?(@cache_file))
@root = ''
@path_info = @cache_file
@path = @path_info
elsif(args[:sendfile])
@redefine_prefix = 'sendfile'
@sendfile_header = args[:sendfile].is_a?(String) ? args[:sendfile] : nil
else
setup_remote
end
do_redefines(@redefine_prefix) if @redefine_prefix
end
# env:: Environment variable hash
# Process the call
def call(env)
serving(env)
end
# env:: Environment variable hash
# Return an empty result with the proper header information
def sendfile_serving(env)
header = @sendfile_header || env['sendfile.type'] || env['HTTP_X_SENDFILE_TYPE']
unless(header)
raise 'Failed to determine proper sendfile header value' unless @args[:sendfile_fail_gracefully]
setup_remote
do_redefines('remote')
call(env)
end
prefix = (@args[:sendfile_prefix] || env['HTTP_X_ACCEL_REMOTE_MAPPING']).to_s.sub(/^\//, '').sub(/\/$/, '')
[200, {
"Last-Modified" => last_modified,
"Content-Type" => content_type,
"Content-Length" => size,
"Redirect-URL" => @path,
"Redirect-Host" => @path.scan(%r{^https?://([^/\?]+)}).first.first,
header => "/#{prefix}"
},
['']]
end
# env:: Environment variable hash
# Return self to be processed
def remote_serving(e)
[200, {
"Last-Modified" => last_modified,
"Content-Type" => content_type,
"Content-Length" => size
}, self]
end
# Get the remote file
def remote_each
if(@store)
yield @store
else
@con.request_get(@call_path) do |res|
res.read_body(@store) do |part|
@cf.write part if @cf
yield part
end
end
end
end
# Size based on remote headers or given size
def size
@heads['content-length'] || @size.to_s
end
private
# Content type based on provided or remote headers
def content_type
@mime_type || @heads['content-type']
end
# Last modified type based on provided, remote headers or current time
def last_modified
@heads['last-modified'] || @modified || Time.now.httpdate
end
# Builds the path for the cached file
def cache_file_path
raise IOError.new 'Write permission is required for cache directory' unless File.writable?(@args[:cache_directory])
"#{@args[:cache_directory]}/#{Digest::SHA1.hexdigest((@args[:cache_ref] || @path).to_s + size.to_s + last_modified.to_s)}.cache"
end
# prefix:: prefix of methods to be redefined
# Redefine methods to do what we want in the proper situation
def do_redefines(prefix)
self.public_methods.each do |method|
m = method.to_s.dup
next unless m.slice!(0, prefix.to_s.length + 1) == "#{prefix}_"
self.class.class_eval "undef :'#{m}'"
self.class.class_eval "alias :'#{m}' :'#{method}'"
end
end
# Sets up all the requirements for proxying a remote file
def setup_remote
if(@cache_file)
begin
@cf = File.open(@cache_file, 'w+')
rescue
@cf = nil
end
end
@uri = URI.parse(@path)
@con = Net::HTTP.new(@uri.host, @uri.port)
@call_path = @uri.path + (@uri.query ? "?#{@uri.query}" : '')
res = @con.request_get(@call_path)
@heads = res.to_hash
res.value
@store = nil
@redefine_prefix = 'remote'
end
end
end

191
lib/dav4rack/request.rb Normal file
View File

@ -0,0 +1,191 @@
# frozen_string_literal: true
require 'uri'
require 'addressable/uri'
require 'dav4rack/logger'
module DAV4Rack
class Request < Rack::Request
# Root URI path for the resource
attr_reader :root_uri_path
# options:
#
# recursive_propfind_allowed (true) : set to false to disable
# potentially expensive recursive propfinds
#
def initialize(env, options = {})
super env
@options = { recursive_propfind_allowed: true }.merge options
sanitize_path_info
end
def authorization?
!!env['HTTP_AUTHORIZATION']
end
# path relative to root uri
def unescaped_path_info
@unescaped_path_info ||= self.class.unescape_path path_info
end
# the full path (script_name aka rack mount point + path_info)
def unescaped_path
@unescaped_path ||= self.class.unescape_path path
end
def self.unescape_path(p)
p = p.dup
p.force_encoding 'UTF-8'
Addressable::URI.unencode p
end
# Namespace being used within XML document
def ns(wanted_uri = XmlElements::DAV_NAMESPACE)
if document and
root = document.root and
ns_defs = root.namespace_definitions and
ns_defs.size > 0
result = ns_defs.detect{ |nd| nd.href == wanted_uri } || ns_defs.first
result = result.prefix.nil? ? 'xmlns' : result.prefix.to_s
result += ':' unless result.empty?
result
else
''
end
end
# Lock token if provided by client
def lock_token
get_header 'HTTP_LOCK_TOKEN'
end
# Requested depth
def depth
@http_depth ||= begin
if d = get_header('HTTP_DEPTH') and (d == '0' or d == '1')
d.to_i
elsif infinity_depth_allowed?
:infinity
else
1
end
end
end
# Destination header
def destination
@destination ||= if h = get_header('HTTP_DESTINATION')
DestinationHeader.new h, script_name: script_name
end
end
# Overwrite is allowed
def overwrite?
get_header('HTTP_OVERWRITE').to_s.upcase != 'F'
end
# content_length as a Fixnum (nil if the header is unset / empty)
def content_length
if length = (super || get_header('HTTP_X_EXPECTED_ENTITY_LENGTH'))
length.to_i
end
end
# parsed XML request body if any (Nokogiri XML doc)
def document
@request_document ||= parse_request_body if content_length && content_length > 0
end
# builds a URL for path using this requests scheme, host, port and
# script_name
# path must be valid UTF-8 and will be url encoded by this method
def url_for(rel_path, collection: false)
path = path_for rel_path, collection: collection
"#{scheme}://#{host}:#{port}#{path}"
end
# returns an url encoded, absolute path for the given relative path
def path_for(rel_path, collection: false)
path = Addressable::URI.encode_component rel_path, Addressable::URI::CharacterClasses::PATH
if collection and path[-1] != ?/
path << '/'
end
"#{script_name}#{expand_path path}"
end
# expands '/foo/../bar' to '/bar'
def expand_path(path)
path.squeeze! '/'
path = Addressable::URI.normalize_component path, Addressable::URI::CharacterClasses::PATH
URI("http://example.com/").merge(path).path
end
REDIRECTABLE_CLIENTS = [
/cyberduck/i,
/konqueror/i
]
# Does client allow GET redirection
# TODO: Get a comprehensive list in here.
# TODO: Allow this to be dynamic so users can add regexes to match if they know of a client
# that can be supported that is not listed.
def client_allows_redirect?
ua = self.user_agent
REDIRECTABLE_CLIENTS.any? { |re| ua =~ re }
end
MS_CLIENTS = [
/microsoft-webdav/i,
/microsoft office/i
]
# Basic user agent testing for MS authored client
def is_ms_client?
ua = self.user_agent
MS_CLIENTS.any? { |re| ua =~ re }
end
def get_header(name)
@env[name]
end
private
# true if Depth: Infinity is allowed for this request.
#
# http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND
# Servers ... should support "infinity" requests. In practice,
# support for infinite-depth requests may be disabled, due to the
# performance and security concerns associated with this behavior
def infinity_depth_allowed?
request_method != 'PROPFIND' or @options[:recursive_propfind_allowed]
end
def sanitize_path_info
self.path_info.force_encoding 'UTF-8'
self.path_info = expand_path path_info
end
def parse_request_body
return Nokogiri.XML(body.read){ |config|
config.strict
} if body
rescue
DAV4Rack::Logger.error $!.message
raise ::DAV4Rack::HTTPStatus::BadRequest
end
end
end

657
lib/dav4rack/resource.rb Normal file
View File

@ -0,0 +1,657 @@
# frozen_string_literal: true
require 'uuidtools'
require 'dav4rack/lock_store'
require 'dav4rack/xml_elements'
module DAV4Rack
class LockFailure < RuntimeError
attr_reader :path_status
def initialize(*args)
super(*args)
@path_status = {}
end
def add_failure(path, status)
@path_status[path] = status
end
end
class Resource
include DAV4Rack::Utils
include DAV4Rack::XmlElements
attr_reader :path, :request,
:response, :propstat_relative_path, :root_xml_attributes, :namespaces
attr_accessor :user
@@blocks = {}
class << self
# This lets us define a bunch of before and after blocks that are
# either called before all methods on the resource, or only specific
# methods on the resource
def method_missing(*args, &block)
class_sym = self.name.to_sym
@@blocks[class_sym] ||= {:before => {}, :after => {}}
m = args.shift
parts = m.to_s.split('_')
type = parts.shift.to_s.to_sym
method = parts.empty? ? nil : parts.join('_').to_sym
if(@@blocks[class_sym][type] && block_given?)
if(method)
@@blocks[class_sym][type][method] ||= []
@@blocks[class_sym][type][method] << block
else
@@blocks[class_sym][type][:'__all__'] ||= []
@@blocks[class_sym][type][:'__all__'] << block
end
else
raise NoMethodError.new("Undefined method #{m} for class #{self}")
end
end
end
include DAV4Rack::HTTPStatus
# path:: Internal resource path (unescaped PATH_INFO)
# request:: Rack::Request
# options:: Any options provided for this resource
# Creates a new instance of the resource.
# NOTE: path and public_path will only differ if the root_uri has been set for the resource. The
# controller will strip out the starting path so the resource can easily determine what
# it is working on. For example:
# request -> /my/webdav/directory/actual/path
# public_path -> /my/webdav/directory/actual/path
# path -> /actual/path
# NOTE: Customized Resources should not use initialize for setup. Instead
# use the #setup method
def initialize(path, request, response, options)
if path.nil? || path.empty? || path[0] != ?/
raise ArgumentError, 'path must be present and start with a /'
end
@path = path
@propstat_relative_path = !!options[:propstat_relative_path]
@root_xml_attributes = options.delete(:root_xml_attributes) || {}
@namespaces = (options[:namespaces] || {}).merge({DAV_NAMESPACE => DAV_NAMESPACE_NAME})
@request = request
@response = response
unless(options.has_key?(:lock_class))
@lock_class = LockStore
else
@lock_class = options[:lock_class]
raise NameError.new("Unknown lock type constant provided: #{@lock_class}") unless @lock_class.nil? || defined?(@lock_class)
end
@options = options
@max_timeout = options[:max_timeout] || 86400
@default_timeout = options[:default_timeout] || 60
@user = @options[:user] || request.ip
setup
end
# returns a new instance for the given path
def new_for_path(path)
self.class.new path, request, response,
@options.merge(user: @user, namespaces: @namespaces)
end
# override to implement custom authentication
# should return true for successful authentication, false otherwise
def authenticate(username, password)
true
end
def authentication_error_message
'Not Authorized'
end
def authentication_realm
'Locked content'
end
# override in child classes for custom setup
def setup
end
private :setup
# Returns if resource supports locking
def supports_locking?
false #true
end
# Returns supported lock types (an array of [lockscope, locktype] pairs)
# i.e. [%w(D:exclusive D:write)]
def supported_locks
[]
end
# If this is a collection, return the child resources.
def children
NotImplemented
end
# Is this resource a collection?
def collection?
NotImplemented
end
# Does this resource exist?
def exist?
NotImplemented
end
# Does the parent resource exist?
def parent_exists?
parent.exist?
end
# Is the parent resource a collection?
def parent_collection?
parent.collection?
end
# Return the creation time.
def creation_date
raise NotImplemented
end
# Return the time of last modification.
def last_modified
raise NotImplemented
end
# Set the time of last modification.
def last_modified=(time)
# Is this correct?
raise NotImplemented
end
# Return an Etag, an unique hash value for this resource.
def etag
raise NotImplemented
end
# Return the resource type. Generally only used to specify
# resource is a collection.
def resource_type
:collection if collection?
end
# Return the mime type of this resource.
def content_type
raise NotImplemented
end
# Return the size in bytes for this resource.
def content_length
raise NotImplemented
end
# HTTP OPTIONS request.
# resources should override this to set the Allow header to indicate the
# allowed methods. By default, all WebDAV methods are advertised on all
# resources.
def options(request, response)
OK
end
# HTTP GET request.
#
# Write the content of the resource to the response.body.
def get(request, response)
NotImplemented
end
def head(request, response)
#no-op, but called by the controller
end
# HTTP PUT request.
#
# Save the content of the request.body.
def put(request, response)
NotImplemented
end
# HTTP POST request.
#
# Usually forbidden.
def post(request, response)
NotImplemented
end
# HTTP DELETE request.
#
# Delete this resource.
def delete
NotImplemented
end
# HTTP COPY request.
#
# Copy this resource to given destination path.
def copy(dest_path, overwrite = false, depth = nil)
NotImplemented
end
# HTTP MOVE request.
#
# Move this resource to given destination path.
def move(dest_path, overwrite=false)
NotImplemented
end
# args:: Hash of lock arguments
# Request for a lock on the given resource. A valid lock should lock
# all descendents. Failures should be noted and returned as an exception
# using LockFailure.
# Valid args keys: :timeout -> requested timeout
# :depth -> lock depth
# :scope -> lock scope
# :type -> lock type
# :owner -> lock owner
# Should return a tuple: [lock_time, locktoken] where lock_time is the
# given timeout
# NOTE: See section 9.10 of RFC 4918 for guidance about
# how locks should be generated and the expected responses
# (http://www.webdav.org/specs/rfc4918.html#rfc.section.9.10)
def lock(args)
raise NotImplemented unless @lock_class
raise Conflict unless parent_exists?
lock_check(args[:scope])
lock = @lock_class.explicit_locks(@path).find{|l| l.scope == args[:scope] && l.kind == args[:type] && l.user == @user}
unless(lock)
token = UUIDTools::UUID.random_create.to_s
lock = @lock_class.generate(@path, @user, token)
lock.scope = args[:scope]
lock.kind = args[:type]
lock.owner = args[:owner]
lock.depth = args[:depth].is_a?(Symbol) ? args[:depth] : args[:depth].to_i
if(args[:timeout])
lock.timeout = args[:timeout] <= @max_timeout && args[:timeout] > 0 ? args[:timeout] : @max_timeout
else
lock.timeout = @default_timeout
end
lock.save if lock.respond_to? :save
end
begin
lock_check(args[:type])
rescue DAV4Rack::LockFailure => lock_failure
lock.destroy
raise lock_failure
rescue HTTPStatus::Status => status
status
end
[lock.remaining_timeout, lock.token]
end
# lock_scope:: scope of lock
# Check if resource is locked. Raise DAV4Rack::LockFailure if locks are in place.
def lock_check(lock_scope=nil)
return unless @lock_class
if(@lock_class.explicitly_locked?(@path))
raise Locked if @lock_class.explicit_locks(@path).find_all{|l|l.scope == 'exclusive' && l.user != @user}.size > 0
elsif(@lock_class.implicitly_locked?(@path))
if(lock_scope.to_s == 'exclusive')
locks = @lock_class.implicit_locks(@path)
failure = DAV4Rack::LockFailure.new("Failed to lock: #{@path}")
locks.each do |lock|
failure.add_failure(@path, Locked)
end
raise failure
else
locks = @lock_class.implict_locks(@path).find_all{|l| l.scope == 'exclusive' && l.user != @user}
if(locks.size > 0)
failure = LockFailure.new("Failed to lock: #{@path}")
locks.each do |lock|
failure.add_failure(@path, Locked)
end
raise failure
end
end
end
end
# token:: Lock token
# Remove the given lock
def unlock(token)
return NotImplemented unless @lock_class
token = token.slice(1, token.length - 2)
if(token.nil? || token.empty?)
BadRequest
else
lock = @lock_class.find_by_token(token)
if(lock.nil? || lock.user != @user)
Forbidden
elsif(lock.path !~ /^#{Regexp.escape(@path)}.*$/)
Conflict
else
lock.destroy
NoContent
end
end
end
# Create this resource as collection.
def make_collection
NotImplemented
end
# other:: Resource
# Returns if current resource is equal to other resource
def ==(other)
path == other.path
end
# Name of the resource
def name
File.basename(path)
end
# Name of the resource to be displayed to the client
def display_name
name
end
# Available properties
#
# These are returned by PROPFIND without body, or with an allprop body.
DAV_PROPERTIES = %w(
getetag
resourcetype
getcontenttype
getcontentlength
getlastmodified
creationdate
displayname
).map{|prop| { name: prop, ns_href: DAV_NAMESPACE } }.freeze
def properties
props = DAV_PROPERTIES
if supports_locking?
props = props.dup # do not attempt to modify the (frozen) constant
props << { name: 'supportedlock', ns_href: DAV_NAMESPACE }
end
props
end
# Properties to be returned for <propname/> PROPFIND
#
# this should include the names of all properties defined on the resource
def propname_properties
props = self.properties
if supports_locking?
props = props.dup if props.frozen?
props << { name: 'lockdiscovery', ns_href: DAV_NAMESPACE }
end
props
end
# name:: String - Property name
# Returns the value of the given property
def get_property(element)
return NotFound if (element[:ns_href] != DAV_NAMESPACE)
case element[:name]
when 'resourcetype' then resource_type
when 'displayname' then display_name
when 'creationdate' then use_ms_compat_creationdate? ? creation_date.httpdate : creation_date.xmlschema
when 'getcontentlength' then content_length.to_s
when 'getcontenttype' then content_type
when 'getetag' then etag
when 'getlastmodified' then last_modified.httpdate
when 'supportedlock' then supported_locks_xml
when 'lockdiscovery' then lockdiscovery_xml
else NotImplemented
end
end
# name:: String - Property name
# value:: New value
# Set the property to the given value
#
# This default implementation does not allow any properties to be changed.
def set_property(element, value)
return Forbidden
end
# name:: Property name
# Remove the property from the resource
def remove_property(element)
Forbidden
end
# name:: Name of child
# Create a new child with the given name
# NOTE:: Include trailing '/' if child is collection
def child(name)
new_path = @path.dup
new_path << ?/ unless new_path[-1] == ?/
new_path << name
new_for_path new_path
end
# Return parent of this resource
def parent
return nil if @path == '/'
unless @path.to_s.empty?
new_for_path File.split(@path).first
end
end
# Return list of descendants
def descendants
list = []
children.each do |child|
list << child
list.concat(child.descendants)
end
list
end
# Index page template for GETs on collection
def index_page
'<html><head> <title>%s</title>
<meta http-equiv="content-type" content="text/html; charset=utf-8" /></head>
<body> <h1>%s</h1> <hr /> <table> <tr> <th class="name">Name</th>
<th class="size">Size</th> <th class="type">Type</th>
<th class="mtime">Last Modified</th> </tr> %s </table> <hr /> </body></html>'
end
def properties_xml_with_depth(process_properties, depth = request.depth)
xml_with_depth self, depth do |element, ox_doc|
ox_doc << element.properties_xml(process_properties)
end
end
def propnames_xml_with_depth(depth = request.depth)
xml_with_depth self, depth do |element, ox_doc|
ox_doc << element.propnames_xml
end
end
# Returns a complete URL for this resource.
# If the propstat_relative_path option is set, just an absolute path will
# be returned.
# If this is a collection, the result will end with a '/'
def href
@href ||= build_href(path, collection: self.collection?)
end
# Returns a complete URL for the given path.
#
# If the propstat_relative_path option is set, just an absolute path will
# be returned. If the :collection argument is true, the returned path will
# end with a '/'
def build_href(path, collection: false)
if propstat_relative_path
request.path_for path, collection: collection
else
request.url_for path, collection: collection
end
end
def propnames_xml
response = Ox::Element.new(D_RESPONSE)
response << ox_element(D_HREF, href)
propstats response, { OK => Hash[propname_properties.map{|p| [p,nil]}] }
response
end
def properties_xml(process_properties)
response = Ox::Element.new(D_RESPONSE)
response << ox_element(D_HREF, href)
process_properties.each do |type, properties|
propstats(response, self.send("#{type}_properties_with_status",properties))
end
response
end
def supported_locks_xml
supported_locks.map do |scope, type|
ox_lockentry scope, type
end
end
# array of lock info hashes
# required keys are :time, :token, :depth
# other valid keys are :scope, :type, :root and :owner
def lockdiscovery
[]
end
# returns an array of activelock ox elements
def lockdiscovery_xml
if supports_locking?
lockdiscovery.map do |lock|
ox_activelock(**lock)
end
end
end
def get_properties_with_status(properties)
stats = Hash.new { |h, k| h[k] = [] }
properties.each do |property|
val = self.get_property(property[:element])
if val.is_a?(Class)
stats[val] << property[:element]
else
stats[OK] << [property[:element], val]
end
end
stats
end
def set_properties_with_status(properties)
stats = Hash.new { |h, k| h[k] = [] }
properties.each do |property|
val = self.set_property(property[:element], property[:value])
if val.is_a?(Class)
stats[val] << property[:element]
else
stats[OK] << [property[:element], val]
end
end
stats
end
# resource:: Resource
# elements:: Property hashes (name, namespace, children)
# Removes the given properties from a resource
def remove_properties_with_status(properties)
stats = Hash.new { |h, k| h[k] = [] }
properties.each do |property|
val = self.remove_property(property[:element])
if val.is_a?(Class)
stats[val] << property[:element]
else
stats[OK] << [property[:element], val]
end
end
stats
end
# adds the given xml namespace to namespaces and returns the prefix
def add_namespace(ns, prefix = "unknown#{rand 65536}")
return nil if ns.nil? || ns.empty?
unless namespaces.key? ns
namespaces[ns] = prefix
return prefix
end
end
# returns the prefix for the given namespace, adding it if necessary
def prefix_for(ns_href)
namespaces[ns_href] || add_namespace(ns_href)
end
# response:: parent Ox::Element
# stats:: Array of stats
# Build propstats response
def propstats(response, stats)
return if stats.empty?
stats.each do |status, props|
propstat = Ox::Element.new(D_PROPSTAT)
prop = Ox::Element.new(D_PROP)
props.each do |element, value|
name = element[:name]
if prefix = prefix_for(element[:ns_href])
name = "#{prefix}:#{name}"
end
prop_element = Ox::Element.new(name)
ox_append prop_element, value, prefix: prefix
prop << prop_element
end
propstat << prop
propstat << ox_element(D_STATUS, "#{http_version} #{status.status_line}")
response << propstat
end
end
def use_compat_mkcol_response?
@options[:compat_mkcol] || @options[:compat_all]
end
# Returns true if using an MS client
def use_ms_compat_creationdate?
if(@options[:compat_ms_mangled_creationdate] || @options[:compat_all])
request.is_ms_client?
end
end
# Callback function that adds additional properties to the propfind REQUEST
# These properties will then be parsed and processed as though they were sent
# by the client. This makes sure we can add whatever property we want
# to the response and make it look like the client asked for them.
def propfind_add_additional_properties(properties)
# Default implementation doesn't need to add anything
properties
end
end
end

View File

@ -0,0 +1,379 @@
# encoding: UTF-8
# frozen_string_literal: true
require 'pstore'
require 'webrick/httputils'
require 'dav4rack/file_resource_lock'
require 'dav4rack/security_utils'
module DAV4Rack
class FileResource < Resource
include WEBrick::HTTPUtils
include DAV4Rack::Utils
# If this is a collection, return the child resources.
def children
Dir[file_path + '/*'].map do |path|
child ::File.basename(path)
end
end
# Is this resource a collection?
def collection?
::File.directory?(file_path)
end
# Does this recource exist?
def exist?
::File.exist?(file_path)
end
# Return the creation time.
def creation_date
stat.ctime
end
# Return the time of last modification.
def last_modified
stat.mtime
end
# Set the time of last modification.
def last_modified=(time)
::File.utime(Time.now, time, file_path)
end
# Return an Etag, an unique hash value for this resource.
def etag
sprintf('%x-%x-%x', stat.ino, stat.size, stat.mtime.to_i)
end
# Return the mime type of this resource.
def content_type
if stat.directory?
"text/html"
else
mime_type(file_path, DefaultMimeTypes)
end
end
# Return the size in bytes for this resource.
def content_length
stat.size
end
# HTTP GET request.
#
# Write the content of the resource to the response.body.
def get(request, response)
return NotFound unless exist?
if stat.directory?
response.body = "".dup
Rack::Directory.new(root).call(request.env)[2].each do |line|
response.body << line
end
response['Content-Length'] = response.body.bytesize.to_s
OK
else
status, headers, body = Rack::File.new(root).call(request.env)
headers.each do |k, v|
response[k] = v
end
response.body = body
StatusClasses[status]
end
end
# HTTP PUT request.
#
# Save the content of the request.body.
def put(request, response)
write(request.body)
Created
end
# HTTP POST request.
#
# Usually forbidden.
def post(request, response)
raise HTTPStatus::Forbidden
end
# HTTP DELETE request.
#
# Delete this resource.
def delete
if stat.directory?
FileUtils.rm_rf(file_path)
else
::File.unlink(file_path)
end
::File.unlink(prop_path) if ::File.exist?(prop_path)
@_prop_hash = nil
NoContent
end
# HTTP COPY request.
#
# Copy this resource to given destination path.
def copy(dest_path, overwrite, depth = nil)
is_new = true
dest = new_for_path dest_path
unless dest.parent.exist? and dest.parent.collection?
return Conflict
end
if dest.exist?
if overwrite
FileUtils.rm_r dest.file_path, secure: true
else
return PreconditionFailed
end
is_new = false
end
if collection?
if request.depth == 0
Dir.mkdir dest.file_path
else
FileUtils.cp_r(file_path, dest.file_path)
end
else
FileUtils.cp(file_path, dest.file_path.sub(/\/$/, ''))
FileUtils.cp(prop_path, dest.prop_path) if ::File.exist? prop_path
end
is_new ? Created : NoContent
end
# HTTP MOVE request.
#
# Move this resource to given destination resource.
def move(*args)
result = copy(*args)
delete if [Created, NoContent].include?(result)
result
end
# HTTP MKCOL request.
#
# Create this resource as collection.
def make_collection
if(request.body.read.to_s == '')
if(::File.directory?(file_path))
MethodNotAllowed
else
if(::File.directory?(::File.dirname(file_path)) && !::File.exist?(file_path))
Dir.mkdir(file_path)
Created
else
Conflict
end
end
else
UnsupportedMediaType
end
end
# Write to this resource from given IO.
def write(io)
tempfile = "#{file_path}.#{Process.pid}.#{object_id}"
open(tempfile, "wb") do |file|
while part = io.read(8192)
file << part
end
end
::File.rename(tempfile, file_path)
ensure
::File.unlink(tempfile) rescue nil
end
# name:: String - Property name
# Returns the value of the given property
def get_property(name)
if name[:ns_href] == DAV_NAMESPACE
super
else
custom_props(name)
end
end
# name:: String - Property name
# value:: New value
# Set the property to the given value
def set_property(name, value)
# let Resource handle DAV properties
if name[:ns_href] == DAV_NAMESPACE
super
else
set_custom_props name, value
end
end
def remove_property(element)
prop_hash.transaction do
prop_hash.delete(to_element_key(element))
prop_hash.commit
end
val = prop_hash.transaction{ prop_hash[to_element_key(element)] }
end
def lock(args)
unless(parent_exists?)
Conflict
else
lock_check(args[:type])
lock = FileResourceLock.explicit_locks(@path, root, :scope => args[:scope], :kind => args[:type], :user => @user)
unless(lock)
token = UUIDTools::UUID.random_create.to_s
lock = FileResourceLock.generate(@path, @user, token, root)
lock.scope = args[:scope]
lock.kind = args[:type]
lock.owner = args[:owner]
lock.depth = args[:depth]
if(args[:timeout])
lock.timeout = args[:timeout] <= @max_timeout && args[:timeout] > 0 ? args[:timeout] : @max_timeout
else
lock.timeout = @default_timeout
end
lock.save
end
begin
lock_check(args[:type])
rescue DAV4Rack::LockFailure => lock_failure
lock.destroy
raise lock_failure
rescue HTTPStatus::Status => status
status
end
[lock.remaining_timeout, lock.token]
end
end
def unlock(token)
token = token.slice(1, token.length - 2)
if(token.nil? || token.empty?)
BadRequest
else
lock = FileResourceLock.find_by_token(token, root)
if(lock.nil? || lock.user != @user)
Forbidden
elsif(lock.path !~ /^#{Regexp.escape(@path)}.*$/)
Conflict
else
lock.destroy
NoContent
end
end
end
def authenticate(user, pass)
if(@options[:username])
# This comparison uses & so that it doesn't short circuit and
# uses `variable_size_secure_compare` so that length information
# isn't leaked.
SecurityUtils.variable_size_secure_compare(
user, @options[:username]
) &
SecurityUtils.variable_size_secure_compare(
pass, @options[:password]
)
else
true
end
end
protected
def file_path
::File.expand_path ::File.join(root, path)
end
def prop_path
path = ::File.join(store_directory, "#{::File.join(::File.dirname(file_path), ::File.basename(file_path)).gsub('/', '_')}.pstore")
unless(::File.directory?(::File.dirname(path)))
FileUtils.mkdir_p(::File.dirname(path))
end
path
end
private
def lock_check(lock_type=nil)
if(FileResourceLock.explicitly_locked?(@path, root))
raise Locked if lock_type && lock_type == 'exclusive'
#raise Locked if FileResourceLock.explicit_locks(@path, root).find(:all, :conditions => ["scope = 'exclusive' AND user_id != ?", @user.id]).size > 0
elsif(FileResourceLock.implicitly_locked?(@path, root))
if(lock_type.to_s == 'exclusive')
locks = FileResourceLock.implicit_locks(@path)
failure = DAV4Rack::LockFailure.new("Failed to lock: #{@path}")
locks.each do |lock|
failure.add_failure(@path, Locked)
end
raise failure
else
locks = FileResourceLock.implict_locks(@path).find(:all, :conditions => ["scope = 'exclusive' AND user_id != ?", @user.id])
if(locks.size > 0)
failure = LockFailure.new("Failed to lock: #{@path}")
locks.each do |lock|
failure.add_failure(@path, Locked)
end
raise failure
end
end
end
end
def set_custom_props(element, val)
prop_hash.transaction do
prop_hash[to_element_key(element)] = val
prop_hash.commit
end
end
def custom_props(element)
val = prop_hash.transaction(true) do
prop_hash[to_element_key(element)]
end
val || NotFound
end
def store_directory
path = ::File.join(root, '.attrib_store')
unless(::File.directory?(::File.dirname(path)))
FileUtils.mkdir_p(::File.dirname(path))
end
path
end
def lock_path
path = ::File.join(store_directory, 'locks.pstore')
unless(::File.directory?(::File.dirname(path)))
FileUtils.mkdir_p(::File.dirname(path))
end
path
end
def prop_hash
@_prop_hash ||= IS_18 ? PStore.new(prop_path) : PStore.new(prop_path, true)
end
def root
@options[:root]
end
def stat
@stat ||= ::File.stat(file_path)
end
end
end

View File

@ -0,0 +1,268 @@
# coding: utf-8
# frozen_string_literal: true
require 'mime/types'
require 'mongo'
require 'erb'
require 'dav4rack/security_utils'
module DAV4Rack
class MongoResource < DAV4Rack::Resource
def self.database=(db)
@database = db
end
def self.database; @database end
def setup
@filesystem = Mongo::Grid::FSBucket.new(self.class.database)
if @options[:bson]
@bson = @options[:bson]
elsif path.empty? || path == '/'
@bson = {'filename' => '/'}
else
@bson = @filesystem.find(filename: /^#{Regexp.escape(path)}\/?$/).first rescue nil
end
end
def child(bson)
our_options = @options.dup
@options[:bson] = bson
child = new_for_path bson['filename']
@options = our_options
child
end
# If this is a collection, return the child resources.
def children
@filesystem.find(filename: /^#{Regexp.escape(@bson['filename'])}[^\/]+\/?$/).map do |bson|
child bson
end
end
# Is this resource a collection?
def collection?
@bson && _collection?(@bson['filename'])
end
# Does this recource exist?
def exist?
!!@bson
end
# Return the creation time.
def creation_date
@bson['uploadDate'] || Date.new
end
# Return the time of last modification.
def last_modified
@bson['uploadDate'] || Date.new
end
# Set the time of last modification.
def last_modified=(time)
end
# Return an Etag, an unique hash value for this resource.
def etag
@bson['_id'].to_s
end
# Return the mime type of this resource.
def content_type
@bson['contentType'] || "text/html"
end
# Return the size in bytes for this resource.
def content_length
@bson['length'] || 0
end
# HTTP GET request.
#
# Write the content of the resource to the response.body.
def get(request, response)
return NotFound unless exist?
if collection?
response.body = "<html>".dup
response.body << "<h2>" + ERB::Util.html_escape(path) + "</h2>"
children.each do |child|
name = ERB::Util.html_escape child.name
path = ERB::Util.html_escape request.path_for(child.path,
collection: child.collection?)
response.body << "<a href='" + path + "'>" + name + "</a>"
response.body << "</br>"
end
response.body << "</html>"
response['Content-Type'] = 'text/html'
else
@filesystem.open_download_stream_by_name(path, revision: -1) do |s|
# not sure if the copying is necessary, but keeping the reference to
# s outside the block seems unsafe as we dont know if/when that
# stream will be closed
#response.body = s
response.body = s.read
response['Content-Type'] = @bson['contentType']
end
end
OK
rescue Mongo::Error::InvalidFileRevision
NotFound
end
# HTTP PUT request.
#
# Save the content of the request.body.
def put(request, response)
exists = exist?
@filesystem.open_upload_stream(
path, content_type: content_type_for(path)
) { |f| f.write request.body }
exists ? NoContent : Created
end
# HTTP POST request.
#
# Usually forbidden.
def post(request, response)
raise HTTPStatus::Forbidden
end
# HTTP DELETE request.
#
# Delete this resource.
def delete
if collection?
@filesystem.find(filename: /^#{Regexp.escape(@bson['filename'])}/).each do |bson|
@filesystem.delete(bson['_id'])
end
else
@filesystem.delete(@bson['_id'])
end
NoContent
end
# HTTP COPY request.
#
# Copy this resource to given destination path.
def copy(dest_path, overwrite = false, depth = :infinity)
dest = new_for_path dest_path
dest.collection! if collection?
src = @bson['filename']
dst = dest.path
exists = @filesystem.find(filename: /^#{Regexp.escape(dst)}/).any?
if overwrite
@filesystem.find(filename: /^#{Regexp.escape(dst)}/).each do |bson|
@filesystem.delete bson['_id']
end
elsif @filesystem.find(filename: /^#{Regexp.escape(dst)}/).any?
return PreconditionFailed
end
@filesystem.find(filename: /^#{Regexp.escape(src)}/).each do |bson|
src_name = bson['filename']
dst_name = dst + src_name.slice(src.length, src_name.length)
@filesystem.open_download_stream_by_name(src_name) do |i|
@filesystem.open_upload_stream(dst_name,
content_type: bson['contentType']) do |o|
i.each{|chunk| o.write chunk }
end
end
end
exists ? NoContent : Created
end
# HTTP MOVE request.
#
# Move this resource to given destination path.
def move(dest_path, overwrite = false)
dest = new_for_path dest_path
dest.collection! if collection?
src = @bson['filename']
dst = dest.path
exists = @filesystem.find(filename: /^#{Regexp.escape(dst)}/).any?
if overwrite
@filesystem.find(filename: /^#{Regexp.escape(dst)}/).each do |bson|
@filesystem.delete bson['_id']
end
elsif @filesystem.find(filename: /^#{Regexp.escape(dst)}/).any?
return PreconditionFailed
end
@filesystem.find(filename: /^#{Regexp.escape(src)}/).each do |bson|
src_name = bson['filename']
dst_name = dst + src_name.slice(src.length, src_name.length)
# http://mongoid.org/docs/persistence/atomic.html
# http://rubydoc.info/github/mongoid/mongoid/master/Mongoid/Collection#update-instance_method
mongo_collection.find_one_and_update({'_id' => bson['_id']}, {'$set' => {'filename' => dst_name}}, safe: true)
end
exists ? NoContent : Created
end
# HTTP MKCOL request.
#
# Create this resource as collection.
def make_collection
if @filesystem.find(filename: path).any?
raise 'resource exists'
end
collection!
@filesystem.open_upload_stream(path) { |f| }
Created
end
def collection!
path << '/' unless _collection?(path)
end
def authenticate(user, pass)
if(@options[:username])
# This comparison uses & so that it doesn't short circuit and
# uses `variable_size_secure_compare` so that length information
# isn't leaked.
SecurityUtils.variable_size_secure_compare(
user, @options[:username]
) &
SecurityUtils.variable_size_secure_compare(
pass, @options[:password]
)
else
true
end
end
private
def mongo_collection
@mongo_collection ||= self.class.database['fs.files']
end
def content_type_for(filename)
MIME::Types.type_for(filename).first.to_s || 'text/html'
end
def _collection?(path)
path && path.end_with?('/')
end
end
end

View File

@ -0,0 +1,24 @@
require 'digest'
module DAV4Rack
# Implements secure string comparison methods.
# Taken straight from ActiveSupport
module SecurityUtils
def secure_compare(a, b)
return false unless a.bytesize == b.bytesize
l = a.unpack "C#{a.bytesize}"
res = 0
b.each_byte { |byte| res |= byte ^ l.shift }
res == 0
end
module_function :secure_compare
def variable_size_secure_compare(a, b)
secure_compare(::Digest::SHA256.hexdigest(a), ::Digest::SHA256.hexdigest(b))
end
module_function :variable_size_secure_compare
end
end

48
lib/dav4rack/utils.rb Normal file
View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
require 'ostruct'
module DAV4Rack
# Simple wrapper for formatted elements
class DAVElement < OpenStruct
def [](key)
self.send(key)
end
end
module Utils
DEFAULT_HTTP_VERSION = 'HTTP/1.1'
def to_element_hash(element)
ns = element.namespace
DAVElement.new(
:namespace => ns,
:name => element.name,
:ns_href => (ns.href if ns),
:children => element.children.collect{|e|
to_element_hash(e) if e.element?
}.compact,
:attributes => attributes_hash(element)
)
end
def to_element_key(element)
ns = element.namespace
"#{ns.href if ns}!!#{element.name}"
end
def http_version
DEFAULT_HTTP_VERSION
end
private
def attributes_hash(node)
node.attributes.inject({}) do |ret, (key,attr)|
ret[attr.name] = attr.value
ret
end
end
end
end

17
lib/dav4rack/version.rb Normal file
View File

@ -0,0 +1,17 @@
module DAV4Rack
class Version
attr_reader :major, :minor, :tiny
def initialize(version)
version = version.split('.')
@major, @minor, @tiny = [version[0].to_i, version[1].to_i, version[2].to_i]
end
def to_s
"#{@major}.#{@minor}.#{@tiny}"
end
end
VERSION = Version.new('1.0.0')
end

View File

@ -0,0 +1,129 @@
# frozen_string_literal: true
module DAV4Rack
module XmlElements
DAV_NAMESPACE = 'DAV:'
DAV_NAMESPACE_NAME = 'd'
DAV_XML_NS = 'xmlns:d'
XML_VERSION = '1.0'
XML_CONTENT_TYPE = 'application/xml; charset=utf-8'
%w(
activelock
depth
error
href
lockdiscovery
lockentry
lockroot
lockscope
locktoken
lock-token-submitted
locktype
multistatus
owner
prop
propstat
response
status
timeout
).each do |name|
const_set "D_#{name.upcase.gsub('-', '_')}", "#{DAV_NAMESPACE_NAME}:#{name}"
end
INFINITY = 'infinity'
ZERO = '0'
def ox_element(name, content = nil)
e = Ox::Element.new(name)
if content
e << content
end
e
end
def ox_append(element, value, prefix: DAV_NAMESPACE_NAME)
case value
when Ox::Element
element << value
when Symbol
element << Ox::Element.new("#{prefix}:#{value}")
when Enumerable
value.each{|v| ox_append element, v, prefix: prefix }
else
element << value.to_s if value
end
end
def ox_lockentry(scope, type)
Ox::Element.new(D_LOCKENTRY).tap do |e|
e << ox_element(D_LOCKSCOPE, Ox::Element.new(scope))
e << ox_element(D_LOCKTYPE, Ox::Element.new(type))
end
end
def ox_response(path, status)
Ox::Element.new(D_RESPONSE).tap do |e|
# path = "#{scheme}://#{host}:#{port}#{URI.escape(path)}"
e << ox_element(D_HREF, path)
e << ox_element(D_STATUS, "#{http_version} #{status.status_line}")
end
end
# returns an activelock Ox::Element for the given lock data
def ox_activelock(time: nil, token:, depth:,
scope: nil, type: nil, owner: nil, root: nil)
Ox::Element.new(D_ACTIVELOCK).tap do |activelock|
if scope
activelock << ox_element(D_LOCKSCOPE, scope)
end
if type
activelock << ox_element(D_LOCKTYPE, type)
end
activelock << ox_element(D_DEPTH, depth)
activelock << ox_element(D_TIMEOUT,
(time ? "Second-#{time}" : INFINITY))
token = ox_element(D_HREF, token)
activelock << ox_element(D_LOCKTOKEN, token)
if owner
activelock << ox_element(D_OWNER, owner)
end
if root
root = ox_element(D_HREF, root)
activelock << ox_element(D_LOCKROOT, root)
end
end
end
# block is called for each element (at least self, depending on depth also
# with children / further descendants)
def xml_with_depth(resource, depth, &block)
partial_document = Ox::Document.new()
yield resource, partial_document
case depth
when 0
when 1
resource.children.each do |child|
yield child, partial_document
end
else
resource.descendants.each do |desc|
yield desc, partial_document
end
end
Ox.dump(partial_document, {indent: -1})
end
end
end

View File

@ -0,0 +1,120 @@
# frozen_string_literal: true
module DAV4Rack
class XmlResponse
include XmlElements
def initialize(response, namespaces, http_version: 'HTTP/1.1')
@response = response
@namespaces = namespaces
@http_version = http_version
end
def render_xml(xml_body)
@namespaces.each do |href, prefix|
xml_body["xmlns:#{prefix}"] = href
end
xml_doc = Ox::Document.new(:version => '1.0')
xml_doc << xml_body
@response.body = Ox.dump(xml_doc, {indent: -1, with_xml: true})
@response["Content-Type"] = 'application/xml; charset=utf-8'
@response["Content-Length"] = @response.body.bytesize.to_s
end
def multistatus
multistatus = Ox::Element.new(D_MULTISTATUS)
yield multistatus
render_xml multistatus
end
def render_failed_precondition(status, href)
error = Ox::Element.new(D_ERROR)
case status.code
when 423
l = Ox::Element.new(D_LOCK_TOKEN_SUBMITTED)
l << ox_element(D_HREF, href)
error << l
end
render_xml error
end
def render_lock_errors(errors)
multistatus do |xml|
errors.each do |href, status|
r = response href, status
if status.code == 423
r << ox_element(D_ERROR, Ox::Element.new(D_LOCK_TOKEN_SUBMITTED))
end
xml << r
end
end
end
def render_lockdiscovery(*args)
render_xml ox_element(D_PROP,
ox_element(D_LOCKDISCOVERY,
activelock(*args))
)
end
#
# helpers for creating single elements
#
# returns an activelock Ox::Element for the given lock data
def activelock(time: nil, token:, depth:,
scope: nil, type: nil, owner: nil, root: nil)
Ox::Element.new(D_ACTIVELOCK).tap do |activelock|
if scope
activelock << ox_element(D_LOCKSCOPE, scope)
end
if type
activelock << ox_element(D_LOCKTYPE, type)
end
activelock << ox_element(D_DEPTH, depth)
activelock << ox_element(D_TIMEOUT,
(time ? "Second-#{time}" : INFINITY))
token = ox_element(D_HREF, token)
activelock << ox_element(D_LOCKTOKEN, token)
if owner
activelock << ox_element(D_OWNER, owner)
end
if root
root = ox_element(D_HREF, root)
activelock << ox_element(D_LOCKROOT, root)
end
end
end
def response(href, status)
r = Ox::Element.new(D_RESPONSE)
r << ox_element(D_HREF, href)
r << self.status(status)
r
end
def raw(xml)
Ox::Raw.new xml
end
def status(status)
ox_element D_STATUS, "#{@http_version} #{status.status_line}"
end
end
end

View File

@ -41,6 +41,7 @@ if defined?(EasyExtensions)
end
# Load up classes that make up our WebDAV solution ontop of DAV4Rack
require 'dav4rack'
require 'redmine_dmsf/webdav/custom_middleware'
require 'redmine_dmsf/webdav/base_resource'
require 'redmine_dmsf/webdav/dmsf_resource'