require 'net/https' require 'uri' require 'nokogiri' require File.dirname(__FILE__) + "/dav/item" require 'base64' require 'digest/md5' require File.dirname(__FILE__) + "/dav/version" begin require 'curb' rescue LoadError end module Net #:nodoc: # Implement a WebDAV client class DAV MAX_REDIRECTS = 10 def last_status @handler.last_status end class NetHttpHandler attr_writer :user, :pass attr_accessor :disable_basic_auth attr_reader :last_status def verify_callback=(callback) @http.verify_callback = callback end def verify_server=(value) @http.verify_mode = value ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE end def initialize(uri) @disable_basic_auth = false @uri = uri case @uri.scheme when "http" @http = Net::HTTP.new(@uri.host, @uri.port) when "https" @http = Net::HTTP.new(@uri.host, @uri.port) @http.use_ssl = true self.verify_server = true else raise "unknown uri scheme" end end def start(&block) @http.start(&block) end def read_timeout @http.read_timeout end def read_timeout=(sec) @http.read_timeout = sec end def open_timeout @http.read_timeout end def open_timeout=(sec) @http.read_timeout = sec end def request_sending_stream(verb, path, stream, length, headers) headers ||= {} headers = {"User-Agent" => "Ruby"}.merge(headers) req = case verb when :put Net::HTTP::Put.new(path) else raise "unkown sending_stream verb #{verb}" end req.body_stream = stream req.content_length = length headers.each_pair { |key, value| req[key] = value } if headers req.content_type = 'application/octet-stream' res = handle_request(req, headers) res end def request_sending_body(verb, path, body, headers) headers ||= {} headers = {"User-Agent" => "Ruby"}.merge(headers) req = case verb when :put Net::HTTP::Put.new(path) else raise "unkown sending_body verb #{verb}" end req.body = body headers.each_pair { |key, value| req[key] = value } if headers req.content_type = 'application/octet-stream' res = handle_request(req, headers) res end def request_returning_body(verb, path, headers, &block) headers ||= {} headers = {"User-Agent" => "Ruby"}.merge(headers) req = case verb when :get Net::HTTP::Get.new(path) else raise "unkown returning_body verb #{verb}" end headers.each_pair { |key, value| req[key] = value } if headers res = handle_request(req, headers, MAX_REDIRECTS, &block) res.body end def request(verb, path, body, headers) headers ||= {} headers = {"User-Agent" => "Ruby"}.merge(headers) req = case verb when :propfind Net::HTTP::Propfind.new(path) when :mkcol Net::HTTP::Mkcol.new(path) when :delete Net::HTTP::Delete.new(path) when :move Net::HTTP::Move.new(path) when :copy Net::HTTP::Copy.new(path) when :proppatch Net::HTTP::Proppatch.new(path) when :lock Net::HTTP::Lock.new(path) when :unlock Net::HTTP::Unlock.new(path) else raise "unkown verb #{verb}" end req.body = body headers.each_pair { |key, value| req[key] = value } if headers req.content_type = 'text/xml; charset="utf-8"' res = handle_request(req, headers) res end def handle_request(req, headers, limit = MAX_REDIRECTS, &block) # You should choose better exception. raise ArgumentError, 'HTTP redirect too deep' if limit == 0 case @authorization when :basic req.basic_auth @user, @pass when :digest digest_auth(req, @user, @pass, response) end response = nil if block @http.request(req) {|res| # Only start returning a body if we will not retry res.read_body nil, &block if !res.is_a?(Net::HTTPUnauthorized) && !res.is_a?(Net::HTTPRedirection) response = res } else response = @http.request(req) end @last_status = response.code.to_i case response when Net::HTTPSuccess then return response when Net::HTTPUnauthorized then response.error! unless @user response.error! if req['authorization'] new_req = clone_req(req.path, req, headers) if response['www-authenticate'] =~ /^basic/i if disable_basic_auth raise "server requested basic auth, but that is disabled" end @authorization = :basic else @authorization = :digest end return handle_request(req, headers, limit - 1, &block) when Net::HTTPRedirection then location = URI.parse(response['location']) if (@uri.scheme != location.scheme || @uri.host != location.host || @uri.port != location.port) raise ArgumentError, "cannot redirect to a different host #{@uri} => #{location}" end new_req = clone_req(location.path, req, headers) return handle_request(new_req, headers, limit - 1, &block) else response.error! end end def clone_req(path, req, headers) new_req = req.class.new(path) new_req.body = req.body if req.body if (req.body_stream) req.body_stream.rewind new_req.body_stream = req.body_stream end new_req.content_length = req.content_length if req.content_length headers.each_pair { |key, value| new_req[key] = value } if headers new_req.content_type = req.content_type if req.content_type return new_req end CNONCE = Digest::MD5.hexdigest("%x" % (Time.now.to_i + rand(65535))).slice(0, 8) def digest_auth(request, user, password, response) # based on http://segment7.net/projects/ruby/snippets/digest_auth.rb @nonce_count = 0 if @nonce_count.nil? @nonce_count += 1 raise "bad www-authenticate header" unless (response['www-authenticate'] =~ /^(\w+) (.*)/) params = {} $2.gsub(/(\w+)="(.*?)"/) { params[$1] = $2 } a_1 = "#{user}:#{params['realm']}:#{password}" a_2 = "#{request.method}:#{request.path}" request_digest = '' request_digest << Digest::MD5.hexdigest(a_1) request_digest << ':' << params['nonce'] request_digest << ':' << ('%08x' % @nonce_count) request_digest << ':' << CNONCE request_digest << ':' << params['qop'] request_digest << ':' << Digest::MD5.hexdigest(a_2) header = [] header << "Digest username=\"#{user}\"" header << "realm=\"#{params['realm']}\"" header << "nonce=\"#{params['nonce']}\"" header << "uri=\"#{request.path}\"" header << "cnonce=\"#{CNONCE}\"" header << "nc=#{'%08x' % @nonce_count}" header << "qop=#{params['qop']}" header << "response=\"#{Digest::MD5.hexdigest(request_digest)}\"" header << "algorithm=\"MD5\"" header = header.join(', ') request['Authorization'] = header end def cert_file(cert_file) # expects a OpenSSL::X509::Certificate object as client certificate @http.cert = OpenSSL::X509::Certificate.new(File.read(cert_file)) #puts @http.cert.not_after #puts @http.cert.subject end def cert_key(cert_file, cert_file_password) # expects a OpenSSL::PKey::RSA or OpenSSL::PKey::DSA object if cert_file_password then @http.key = OpenSSL::PKey::RSA.new(File.read(cert_file),cert_file_password) else @http.key = OpenSSL::PKey::RSA.new(File.read(cert_file)) end end # path of a CA certification file in PEM format. The file can contain several CA certificates. def ca_file(ca_file) @http.ca_file = ca_file end end class CurlHandler < NetHttpHandler def verify_callback=(callback) super curl = make_curl $stderr.puts "verify_callback not implemented in Curl::Easy" end def verify_server=(value) super curl = make_curl curl.ssl_verify_peer = value curl.ssl_verify_host = value end def make_curl unless @curl @curl = Curl::Easy.new @curl.timeout = @http.read_timeout @curl.follow_location = true @curl.max_redirects = MAX_REDIRECTS if disable_basic_auth @curl.http_auth_types = Curl::CURLAUTH_DIGEST end end @curl end def request_returning_body(verb, path, headers) raise "unkown returning_body verb #{verb}" unless verb == :get url = @uri.merge(path) curl = make_curl curl.url = url.to_s headers.each_pair { |key, value| curl.headers[key] = value } if headers if (@user) curl.userpwd = "#{@user}:#{@pass}" else curl.userpwd = nil end res = nil if block_given? curl.on_body do |frag| yield frag frag.length end end curl.perform @last_status = curl.response_code unless curl.response_code >= 200 && curl.response_code < 300 header_block = curl.header_str.split(/\r?\n\r?\n/)[-1] msg = header_block.split(/\r?\n/)[0] msg.gsub!(/^HTTP\/\d+.\d+ /, '') raise Net::HTTPError.new(msg, nil) end curl.body_str end def cert_file(cert_file) # expects a cert file @curl.cert = cert_file end def cert_key(cert_file, cert_file_password) if cert_file_password then @curl.certpassword = cert_file_password end @curl.key = cert_key end def ca_file(ca_file) # path of a cacert bundle for this instance. This file will be used to validate SSL certificates. @curl.cacert = ca_file end end # Disable basic auth - to protect passwords from going in the clear # through a man-in-the-middle attack. def disable_basic_auth? @handler.disable_basic_auth end def disable_basic_auth=(value) @handler.disable_basic_auth = value end # Seconds to wait until reading one block (by one system call). # If the DAV object cannot read a block in this many seconds, # it raises a TimeoutError exception. # def read_timeout @handler.read_timeout end def read_timeout=(sec) @handler.read_timeout = sec end # Seconds to wait until connection is opened. # If the DAV object cannot open a connection in this many seconds, # it raises a TimeoutError exception. # def open_timeout @handler.read_timeout end def open_timeout=(sec) @handler.read_timeout = sec end # Creates a new Net::DAV object and opens the connection # to the host. Yields the object to the block. # # Example: # # res = Net::DAV.start(url) do |dav| # dav.find(url.path) do |item| # puts "#{item.uri} is size #{item.size}" # end # end def self.start(uri, options = nil, &block) # :yield: dav new(uri, options).start(&block) end # Creates a new Net::DAV object for the specified host # The path part of the URI is used to handle relative URLs # in subsequent requests. # You can pass :curl => false if you want to disable use # of the curb (libcurl) gem if present for acceleration def initialize(uri, options = nil) @last_status = 0 @have_curl = Curl rescue nil if options && options.has_key?(:curl) && !options[:curl] @have_curl = false end @uri = uri @uri = URI.parse(@uri) if @uri.is_a? String @handler = @have_curl ? CurlHandler.new(@uri) : NetHttpHandler.new(@uri) @headers = options && options[:headers] ? options[:headers] : {} end # Opens the connection to the host. Yields self to the block. # # Example: # # res = Net::DAV.new(url).start do |dav| # dav.find(url.path) do |item| # puts item.inspect # end # end def start(&block) # :yield: dav @handler.start do return yield(self) end end # Set credentials for basic authentication def credentials(user, pass) @handler.user = user @handler.pass = pass # Return something explicitly since this command might be run in a # console where the last statement would be printed. nil end # Set credentials for ssl certificate authentication def ssl_certificate(cert_file, *cert_file_password) @handler.cert_file(cert_file) @handler.cert_key(cert_file, cert_file_password) # Return something explicitly since this command might be run in a # console where the last statement would be printed. nil end # Set additional ssl authorities for ssl certificate authentication def ssl_authority(ca_file) @handler.ca_file(ca_file) nil end # Set extra headers for the dav request def headers(headers) @headers = headers end # Perform a PROPFIND request # # Example: # # Basic propfind: # # properties = propfind('/path/') # # Get ACL for resource: # # properties = propfind('/path/', :acl) # # Custom propfind: # # properties = propfind('/path/', '...') # # See http://webdav.org/specs/rfc3744.html#rfc.section.5.9 for more on # how to retrieve access control properties. def propfind(path,*options) headers = {'Depth' => '1'} if(options[0] == :acl) body = '' + '' else body = options[0] end if(!body) body = '' end res = @handler.request(:propfind, path, body, headers.merge(@headers)) Nokogiri::XML.parse(res.body) end # Find files and directories, yields Net::DAV::Item # # The :filename option can be a regexp or string, and is used # to filter the yielded items. # # If :suppress_errors is passed, exceptions that occurs when # reading directory information is ignored, and a warning is # printed out stderr instead. # # The default is to not traverse recursively, unless the :recursive # options is passed. # # Examples: # # res = Net::DAV.start(url) do |dav| # dav.find(url.path, :recursive => true) do |item| # puts "#{item.type} #{item.uri}" # puts item.content # end # end # # dav = Net::DAV.new(url) # dav.find(url.path, :filename => /\.html/, :suppress_errors => true) # puts item.url.to_s # end def find(path, options = {}) path = @uri.merge(path).path namespaces = {'x' => "DAV:"} begin doc = propfind(path) rescue Net::ProtocolError => e msg = e.to_s + ": " + path.to_s if(options[:suppress_errors])then # Ignore dir if propfind returns an error warn("Warning: " + msg) return nil else raise e.class.new(msg, nil) end end path.sub!(/\/$/, '') doc./('.//x:response', namespaces).each do |item| uri = @uri.merge(item.xpath("x:href", namespaces).inner_text) size = item.%(".//x:getcontentlength", namespaces).inner_text rescue nil type = item.%(".//x:collection", namespaces) ? :directory : :file res = Item.new(self, uri, type, size, item) if type == :file then if(options[:filename])then search_term = options[:filename] filename = File.basename(uri.path) if(search_term.class == Regexp and search_term.match(filename))then yield res elsif(search_term.class == String and search_term == filename)then yield res end else yield res end elsif uri.path == path || uri.path == path + "/" # This is the top-level dir, skip it elsif options[:recursive] && type == :directory if(!options[:filename])then yield res end # This is a subdir, recurse find(uri.path, options) do |sub_res| yield sub_res end else if(!options[:filename])then yield res end end end end # Change the base URL for use in handling relative paths def cd(url) new_uri = @uri.merge(url) if new_uri.host != @uri.host || new_uri.port != @uri.port || new_uri.scheme != @uri.scheme raise Exception , "uri must have same scheme, host and port" end @uri = new_uri end # Get the content of a resource as a string # # If called with a block, yields each fragment of the # entity body in turn as a string as it is read from # the socket. Note that in this case, the returned response # object will *not* contain a (meaningful) body. def get(path, &block) path = @uri.merge(path).path body = @handler.request_returning_body(:get, path, @headers, &block) body end # Stores the content of a stream to a URL # # Example: # File.open(file, "r") do |stream| # dav.put(url.path, stream, File.size(file)) # end def put(path, stream, length) path = @uri.merge(path).path res = @handler.request_sending_stream(:put, path, stream, length, @headers) res.body end # Stores the content of a string to a URL # # Example: # dav.put(url.path, "hello world") # def put_string(path, str) path = @uri.merge(path).path res = @handler.request_sending_body(:put, path, str, @headers) res.body end # Delete request # # Example: # dav.delete(uri.path) def delete(path) path = @uri.merge(path).path res = @handler.request(:delete, path, nil, @headers) res.body end # Send a move request to the server. # # Example: # dav.move(original_path, new_path) def move(path,destination) path = @uri.merge(path).path destination = @uri.merge(destination).to_s headers = {'Destination' => destination} res = @handler.request(:move, path, nil, headers.merge(@headers)) res.body end # Send a copy request to the server. # # Example: # dav.copy(original_path, destination) def copy(path,destination) path = @uri.merge(path).path destination = @uri.merge(destination).to_s headers = {'Destination' => destination} res = @handler.request(:copy, path, nil, headers.merge(@headers)) res.body end # Do a proppatch request to the server to # update properties on resources or collections. # # Example: # dav.proppatch(uri.path, # "" + # "#{new_date}" + # "" + # ) def proppatch(path, xml_snippet) path = @uri.merge(path).path headers = {'Depth' => '1'} body = '' + '' + xml_snippet + '' res = @handler.request(:proppatch, path, body, headers.merge(@headers)) Nokogiri::XML.parse(res.body) end # Send a lock request to the server # # On success returns an XML response body with a Lock-Token # # Example: # dav.lock(uri.path, "Owner") def lock(path, xml_snippet) path = @uri.merge(path).path headers = {'Depth' => '1'} body = '' + '' + xml_snippet + '' res = @handler.request(:lock, path, body, headers.merge(@headers)) Nokogiri::XML.parse(res.body) end # Send an unlock request to the server # # Example: # dav.unlock(uri.path, "opaquelocktoken:eee47ade-09ac-626b-02f7-e354175d984e") def unlock(path, locktoken) headers = {'Lock-Token' => '<'+locktoken+'>'} path = @uri.merge(path).path res = @handler.request(:unlock, path, nil, headers.merge(@headers)) end # Returns true if resource exists on server. # # Example: # dav.exists?('https://www.example.com/collection/') => true # dav.exists?('/collection/') => true def exists?(path) path = @uri.merge(path).path headers = {'Depth' => '1'} body = '' begin res = @handler.request(:propfind, path, body, headers.merge(@headers)) rescue return false end return (res.is_a? Net::HTTPSuccess) end # Makes a new directory (collection) def mkdir(path) path = @uri.merge(path).path res = @handler.request(:mkcol, path, nil, @headers) res.body end def verify_callback=(callback) @handler.verify_callback = callback end def verify_server=(value) @handler.verify_server = value end end end