lib/atom/http.rb in atom-tools-0.9.1 vs lib/atom/http.rb in atom-tools-0.9.2

- old
+ new

@@ -1,7 +1,8 @@ require "net/http" -require 'uri' +require "net/https" +require "uri" require "sha1" require "md5" module URI # :nodoc: all @@ -11,11 +12,11 @@ class String # :nodoc: def to_uri; URI.parse(self); end end module Atom - UA = "atom-tools 0.9.1" + UA = "atom-tools 0.9.2" module DigestAuth CNONCE = Digest::MD5.new("%x" % (Time.now.to_i + rand(65535))).hexdigest @@nonce_count = -1 @@ -90,12 +91,16 @@ req["Authorization"] = header end end - class Unauthorized < RuntimeError # :nodoc: + class HTTPException < RuntimeError # :nodoc: end + class Unauthorized < Atom::HTTPException # :nodoc: + end + class WrongMimetype < Atom::HTTPException # :nodoc: + end # An object which handles the details of HTTP - particularly # authentication and caching (neither of which are fully implemented). # # This object can be used on its own, or passed to an Atom::Service, @@ -106,14 +111,33 @@ include DigestAuth # used by the default #when_auth attr_accessor :user, :pass - # XXX doc me - # :basic, :wsse, nil + # the token used for Google's AuthSub authentication + attr_accessor :token + + # when set to :basic, :wsse or :authsub, this will send an + # Authentication header with every request instead of waiting for a + # challenge from the server. + # + # be careful; always_auth :basic will send your username and + # password in plain text to every URL this object requests. + # + # :digest won't work, since Digest authentication requires an + # initial challenge to generate a response + # + # defaults to nil attr_accessor :always_auth + # automatically handle redirects, even for POST/PUT/DELETE requests? + # + # defaults to false, which will transparently redirect GET requests + # but return a Net::HTTPRedirection object when the server + # indicates to redirect a POST/PUT/DELETE + attr_accessor :allow_all_redirects + def initialize # :nodoc: @get_auth_details = lambda do |abs_url, realm| if @user and @pass [@user, @pass] else @@ -156,10 +180,33 @@ # it should return a value of the form [username, password] def when_auth &block # :yields: abs_url, realm @get_auth_details = block end + # GET a URL and turn it into an Atom::Entry + def get_atom_entry(url) + res = get(url, "Accept" => "application/atom+xml") + + # be picky for atom:entrys + res.validate_content_type( [ "application/atom+xml" ] ) + + # XXX handle other HTTP codes + if res.code != "200" + raise Atom::HTTPException, "expected Atom::Entry, didn't get it" + end + + Atom::Entry.parse(res.body, url) + end + + # PUT an Atom::Entry to a URL + def put_atom_entry(entry, url = entry.edit_url) + raise "Cowardly refusing to PUT a non-Atom::Entry (#{entry.class})" unless entry.is_a? Atom::Entry + headers = {"Content-Type" => "application/atom+xml" } + + put(url, entry.to_s, headers) + end + private # parses plain quoted-strings def parse_quoted_wwwauth param_string params = {} @@ -175,26 +222,29 @@ user, pass = username_and_password_for_realm(url, params[:realm]) req.basic_auth user, pass end - # WSSE authentication <http://www.xml.com/pub/a/2003/12/17/dive.html> + # WSSE authentication + # <http://www.xml.com/pub/a/2003/12/17/dive.html> def wsse_authenticate(req, url, params = {}) - # from <http://www.koders.com/ruby/fidFB0C7F9A0F36CB0F30B2280BDDC4F43FF1FA4589.aspx?s=ruby+cgi>. - # (thanks midore!) user, pass = username_and_password_for_realm(url, params["realm"]) nonce = Array.new(10){ rand(0x100000000) }.pack('I*') - nonce_base64 = [nonce].pack("m").chomp - now = Time.now.utc.iso8601 + nonce_b64 = [nonce].pack("m").chomp + + now = Time.now.iso8601 digest = [Digest::SHA1.digest(nonce + now + pass)].pack("m").chomp - credentials = sprintf(%Q<UsernameToken Username="%s", PasswordDigest="%s", Nonce="%s", Created="%s">, - user, digest, nonce_base64, now) - req['X-WSSE'] = credentials + + req['X-WSSE'] = %Q<UsernameToken Username="#{user}", PasswordDigest="#{digest}", Nonce="#{nonce_b64}", Created="#{now}"> req["Authorization"] = 'WSSE profile="UsernameToken"' end + def authsub_authenticate req, url + req["Authorization"] = %{AuthSub token="#{@token}"} + end + def username_and_password_for_realm(url, realm) abs_url = (url + "/").to_s user, pass = @get_auth_details.call(abs_url, realm) unless user and pass @@ -203,11 +253,11 @@ [ user, pass ] end # performs a generic HTTP request. - def http_request(url_s, method, body = nil, init_headers = {}, www_authenticate = nil) + def http_request(url_s, method, body = nil, init_headers = {}, www_authenticate = nil, redirect_limit = 5) req, url = new_request(url_s, method, init_headers) # two reasons to authenticate; if @always_auth self.send("#{@always_auth}_authenticate", req, url) @@ -215,27 +265,42 @@ # XXX multiple challenges, multiple headers param_string = www_authenticate.sub!(/^(\w+) /, "") auth_type = $~[1] self.send("#{auth_type.downcase}_authenticate", req, url, param_string) end - - res = Net::HTTP.start(url.host, url.port) { |h| h.request(req, body) } - if res.kind_of? Net::HTTPUnauthorized + http_obj = Net::HTTP.new(url.host, url.port) + http_obj.use_ssl = true if url.scheme == "https" + + res = http_obj.start do |h| + h.request(req, body) + end + + case res + when Net::HTTPUnauthorized if @always_auth or www_authenticate # XXX and not stale (Digest only) # we've tried the credentials you gave us once and failed - raise Unauthorized, "Your username and password were rejected" + raise Unauthorized, "Your authorization was rejected" else # once more, with authentication res = http_request(url_s, method, body, init_headers, res["WWW-Authenticate"]) if res.kind_of? Net::HTTPUnauthorized - raise Unauthorized, "Your username and password were rejected" + raise Unauthorized, "Your authorization was rejected" end end + when Net::HTTPRedirection + if res["Location"] and (allow_all_redirects or [Net::HTTP::Get, Net::HTTP::Head].member? method) + raise HTTPException, "Too many redirects" if redirect_limit.zero? + + res = http_request res["Location"], method, body, init_headers, nil, (redirect_limit - 1) + end end + # a bit of added convenience + res.extend Atom::HTTPResponse + res end def new_request(url_string, method, init_headers = {}) headers = { "User-Agent" => UA }.merge(init_headers) @@ -244,8 +309,21 @@ rel = url.path rel += "?" + url.query if url.query [method.new(rel, headers), url] + end + end + + module HTTPResponse + # this should probably support ranges (eg. text/*) + def validate_content_type( valid ) + raise Atom::HTTPException, "HTTP response contains no Content-Type!" unless self.content_type + + media_type = self.content_type.split(";").first + + unless valid.member? media_type.downcase + raise Atom::WrongMimetype, "unexpected response Content-Type: #{media_type.inspect}. should be one of: #{valid.inspect}" + end end end end