thehack-atom-tools 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/COPYING +18 -0
- data/README +65 -0
- data/Rakefile +87 -0
- data/bin/atom-cp +159 -0
- data/bin/atom-grep +78 -0
- data/bin/atom-post +72 -0
- data/bin/atom-purge +82 -0
- data/lib/atom/cache.rb +178 -0
- data/lib/atom/collection.rb +125 -0
- data/lib/atom/element.rb +640 -0
- data/lib/atom/entry.rb +134 -0
- data/lib/atom/feed.rb +223 -0
- data/lib/atom/http.rb +417 -0
- data/lib/atom/service.rb +106 -0
- data/lib/atom/text.rb +231 -0
- data/lib/atom/tools.rb +163 -0
- data/setup.rb +1585 -0
- data/test/conformance/order.rb +118 -0
- data/test/conformance/title.rb +108 -0
- data/test/conformance/updated.rb +34 -0
- data/test/conformance/xhtmlcontentdiv.rb +18 -0
- data/test/conformance/xmlnamespace.rb +54 -0
- data/test/runtests.rb +14 -0
- data/test/test_constructs.rb +161 -0
- data/test/test_feed.rb +134 -0
- data/test/test_general.rb +72 -0
- data/test/test_http.rb +323 -0
- data/test/test_protocol.rb +168 -0
- data/test/test_xml.rb +445 -0
- metadata +83 -0
data/lib/atom/entry.rb
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
require "rexml/document"
|
|
2
|
+
|
|
3
|
+
require "atom/element"
|
|
4
|
+
require "atom/text"
|
|
5
|
+
|
|
6
|
+
module Atom
|
|
7
|
+
class Control < Atom::Element
|
|
8
|
+
attr_accessor :draft
|
|
9
|
+
|
|
10
|
+
is_element PP_NS, :control
|
|
11
|
+
|
|
12
|
+
on_parse [PP_NS, 'draft'] do |e,x|
|
|
13
|
+
e.set(:draft, x.text == 'yes')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
on_build do |e,x|
|
|
17
|
+
unless (v = e.get(:draft)).nil?
|
|
18
|
+
el = e.append_elem(x, ['app', PP_NS], 'draft')
|
|
19
|
+
el.text = (v ? 'yes' : 'no')
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module HasCategories
|
|
25
|
+
def HasCategories.included(klass)
|
|
26
|
+
klass.atom_elements :category, :categories, Atom::Category
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# categorize the entry with each of an array or a space-separated
|
|
30
|
+
# string
|
|
31
|
+
def tag_with(tags, delimiter = ' ')
|
|
32
|
+
return if not tags or tags.empty?
|
|
33
|
+
|
|
34
|
+
tag_list = unless tags.is_a?(String)
|
|
35
|
+
tags
|
|
36
|
+
else
|
|
37
|
+
tags = tags.split(delimiter)
|
|
38
|
+
tags.map! { |t| t.strip }
|
|
39
|
+
tags.reject! { |t| t.empty? }
|
|
40
|
+
tags.uniq
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
tag_list.each do |tag|
|
|
44
|
+
unless categories.any? { |c| c.term == tag }
|
|
45
|
+
categories.new :term => tag
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module HasLinks
|
|
52
|
+
def HasLinks.included(klass)
|
|
53
|
+
klass.atom_elements :link, :links, Atom::Link
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def find_link(criteria)
|
|
57
|
+
self.links.find do |l|
|
|
58
|
+
criteria.all? { |k,v| l.send(k) == v }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# An individual entry in a feed. As an Atom::Element, it can be
|
|
64
|
+
# manipulated using accessors for each of its child elements. You
|
|
65
|
+
# should be able to set them using an instance of any class that
|
|
66
|
+
# makes sense
|
|
67
|
+
#
|
|
68
|
+
# Entries have the following children:
|
|
69
|
+
#
|
|
70
|
+
# id:: a universally unique IRI which permanently identifies the entry
|
|
71
|
+
# title:: a human-readable title (Atom::Text)
|
|
72
|
+
# content:: contains or links to the content of an entry (Atom::Content)
|
|
73
|
+
# rights:: information about rights held in and over an entry (Atom::Text)
|
|
74
|
+
# source:: the source feed's metadata (unimplemented)
|
|
75
|
+
# published:: a Time "early in the life cycle of an entry"
|
|
76
|
+
# updated:: the most recent Time an entry was modified in a way the publisher considers significant
|
|
77
|
+
# summary:: a summary, abstract or excerpt of an entry (Atom::Text)
|
|
78
|
+
#
|
|
79
|
+
# There are also +categories+, +links+, +authors+ and +contributors+,
|
|
80
|
+
# each of which is an Array of its respective type and can be used
|
|
81
|
+
# thusly:
|
|
82
|
+
#
|
|
83
|
+
# author = entry.authors.new :name => "Captain Kangaroo", :email => "kanga@example.net"
|
|
84
|
+
#
|
|
85
|
+
class Entry < Atom::Element
|
|
86
|
+
is_atom_element :entry
|
|
87
|
+
|
|
88
|
+
# the master list of standard children and the types they map to
|
|
89
|
+
atom_string :id
|
|
90
|
+
|
|
91
|
+
atom_element :title, Atom::Title
|
|
92
|
+
atom_element :summary, Atom::Summary
|
|
93
|
+
atom_element :content, Atom::Content
|
|
94
|
+
|
|
95
|
+
atom_element :rights, Atom::Rights
|
|
96
|
+
|
|
97
|
+
# element :source, Atom::Feed # XXX complicated, eg. serialization
|
|
98
|
+
|
|
99
|
+
atom_time :published
|
|
100
|
+
atom_time :updated
|
|
101
|
+
time ['app', PP_NS], :edited
|
|
102
|
+
|
|
103
|
+
atom_elements :author, :authors, Atom::Author
|
|
104
|
+
atom_elements :contributor, :contributors, Atom::Contributor
|
|
105
|
+
|
|
106
|
+
element ['app', PP_NS], :control, Atom::Control
|
|
107
|
+
|
|
108
|
+
include HasCategories
|
|
109
|
+
include HasLinks
|
|
110
|
+
|
|
111
|
+
atom_link :edit_url, :rel => 'edit'
|
|
112
|
+
|
|
113
|
+
def inspect # :nodoc:
|
|
114
|
+
"#<Atom::Entry id:'#{self.id}'>"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def draft
|
|
118
|
+
control and control.draft
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
alias :draft? :draft
|
|
122
|
+
|
|
123
|
+
def draft!
|
|
124
|
+
self.draft = true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def draft= is_draft
|
|
128
|
+
unless control
|
|
129
|
+
instance_variable_set '@control', Atom::Control.new
|
|
130
|
+
end
|
|
131
|
+
control.draft = is_draft
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
data/lib/atom/feed.rb
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
require "atom/element"
|
|
2
|
+
require "atom/text"
|
|
3
|
+
require "atom/entry"
|
|
4
|
+
|
|
5
|
+
require "atom/http"
|
|
6
|
+
|
|
7
|
+
module Atom
|
|
8
|
+
class FeedGone < RuntimeError # :nodoc:
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# A feed of entries. As an Atom::Element, it can be manipulated using
|
|
12
|
+
# accessors for each of its child elements. You can set them with any
|
|
13
|
+
# object that makes sense; they will be returned in the types listed.
|
|
14
|
+
#
|
|
15
|
+
# Feeds have the following children:
|
|
16
|
+
#
|
|
17
|
+
# id:: a universally unique IRI which permanently identifies the feed
|
|
18
|
+
# title:: a human-readable title (Atom::Text)
|
|
19
|
+
# subtitle:: a human-readable description or subtitle (Atom::Text)
|
|
20
|
+
# updated:: the most recent Time the feed was modified in a way the publisher considers significant
|
|
21
|
+
# generator:: the agent used to generate a feed
|
|
22
|
+
# icon:: an IRI identifying an icon which visually identifies a feed (1:1 aspect ratio, looks OK small)
|
|
23
|
+
# logo:: an IRI identifying an image which visually identifies a feed (2:1 aspect ratio)
|
|
24
|
+
# rights:: rights held in and over a feed (Atom::Text)
|
|
25
|
+
#
|
|
26
|
+
# There are also +links+, +categories+, +authors+, +contributors+
|
|
27
|
+
# and +entries+, each of which is an Array of its respective type and
|
|
28
|
+
# can be used thusly:
|
|
29
|
+
#
|
|
30
|
+
# entry = feed.entries.new
|
|
31
|
+
# entry.title = "blah blah blah"
|
|
32
|
+
#
|
|
33
|
+
class Feed < Atom::Element
|
|
34
|
+
is_atom_element :feed
|
|
35
|
+
|
|
36
|
+
attr_reader :uri
|
|
37
|
+
|
|
38
|
+
# the Atom::Feed pointed to by link[@rel='previous']
|
|
39
|
+
attr_reader :prev
|
|
40
|
+
# the Atom::Feed pointed to by link[@rel='next']
|
|
41
|
+
attr_reader :next
|
|
42
|
+
|
|
43
|
+
# conditional get information from the last fetch
|
|
44
|
+
attr_reader :etag, :last_modified
|
|
45
|
+
|
|
46
|
+
atom_string :id
|
|
47
|
+
atom_element :title, Atom::Title
|
|
48
|
+
atom_element :subtitle, Atom::Subtitle
|
|
49
|
+
|
|
50
|
+
atom_time :updated
|
|
51
|
+
|
|
52
|
+
include HasLinks
|
|
53
|
+
include HasCategories
|
|
54
|
+
|
|
55
|
+
atom_elements :author, :authors, Atom::Author
|
|
56
|
+
atom_elements :contributor, :contributors, Atom::Contributor
|
|
57
|
+
|
|
58
|
+
atom_string :generator # XXX with uri and version attributes!
|
|
59
|
+
atom_string :icon
|
|
60
|
+
atom_string :logo
|
|
61
|
+
|
|
62
|
+
atom_element :rights, Atom::Rights
|
|
63
|
+
|
|
64
|
+
atom_elements :entry, :entries, Atom::Entry
|
|
65
|
+
|
|
66
|
+
include Enumerable
|
|
67
|
+
|
|
68
|
+
def inspect # :nodoc:
|
|
69
|
+
"<#{@uri} entries: #{entries.length} title='#{title}'>"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Create a new Feed that can be found at feed_uri and retrieved
|
|
73
|
+
# using an Atom::HTTP object http
|
|
74
|
+
def initialize feed_uri = nil, http = Atom::HTTP.new
|
|
75
|
+
@entries = []
|
|
76
|
+
@http = http
|
|
77
|
+
|
|
78
|
+
if feed_uri
|
|
79
|
+
@uri = feed_uri.to_uri
|
|
80
|
+
self.base = feed_uri
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
super()
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# iterates over a feed's entries
|
|
87
|
+
def each &block
|
|
88
|
+
@entries.each &block
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def empty?
|
|
92
|
+
@entries.empty?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# gets everything in the logical feed (could be a lot of stuff)
|
|
96
|
+
# (see RFC 5005)
|
|
97
|
+
def get_everything!
|
|
98
|
+
self.update!
|
|
99
|
+
|
|
100
|
+
prev = @prev
|
|
101
|
+
while prev
|
|
102
|
+
prev.update!
|
|
103
|
+
|
|
104
|
+
self.merge_entries! prev
|
|
105
|
+
prev = prev.prev
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
nxt = @next
|
|
109
|
+
while nxt
|
|
110
|
+
nxt.update!
|
|
111
|
+
|
|
112
|
+
self.merge_entries! nxt
|
|
113
|
+
nxt = nxt.next
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# merges the entries from another feed into this one
|
|
120
|
+
def merge_entries! other_feed
|
|
121
|
+
other_feed.each do |entry|
|
|
122
|
+
# TODO: add atom:source elements
|
|
123
|
+
self << entry
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# like #merge, but in place
|
|
128
|
+
def merge! other_feed
|
|
129
|
+
[:id, :title, :subtitle, :updated, :rights, :logo, :icon].each do |p|
|
|
130
|
+
if (v = other_feed.get(p))
|
|
131
|
+
set p, v
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
[:links, :categories, :authors, :contributors].each do |p|
|
|
136
|
+
other_feed.get(p).each do |e|
|
|
137
|
+
get(p) << e
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
@extensions = other_feed.extensions
|
|
142
|
+
|
|
143
|
+
merge_entries! other_feed
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# merges "important" properties of this feed with another one,
|
|
147
|
+
# returning a new feed
|
|
148
|
+
def merge other_feed
|
|
149
|
+
feed = self.clone
|
|
150
|
+
|
|
151
|
+
feed.merge! other_feed
|
|
152
|
+
|
|
153
|
+
feed
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# fetches this feed's URL, parses the result and #merge!s
|
|
157
|
+
# changes, new entries, &c.
|
|
158
|
+
#
|
|
159
|
+
# (note that this is different from Atom::Entry#updated!
|
|
160
|
+
def update!
|
|
161
|
+
raise(RuntimeError, "can't fetch without a uri.") unless @uri
|
|
162
|
+
|
|
163
|
+
res = @http.get(@uri, "Accept" => "application/atom+xml")
|
|
164
|
+
|
|
165
|
+
if @etag and res['etag'] == @etag
|
|
166
|
+
# we're already all up to date
|
|
167
|
+
return self
|
|
168
|
+
elsif res.code == "410"
|
|
169
|
+
raise Atom::FeedGone, "410 Gone (#{@uri})"
|
|
170
|
+
elsif res.code != "200"
|
|
171
|
+
raise Atom::HTTPException, "Unexpected HTTP response code: #{res.code}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# we'll be forgiving about feed content types.
|
|
175
|
+
res.validate_content_type(["application/atom+xml",
|
|
176
|
+
"application/xml",
|
|
177
|
+
"text/xml"])
|
|
178
|
+
|
|
179
|
+
@etag = res["ETag"] if res["ETag"]
|
|
180
|
+
|
|
181
|
+
xml = res.body
|
|
182
|
+
|
|
183
|
+
coll = REXML::Document.new(xml)
|
|
184
|
+
|
|
185
|
+
update_el = REXML::XPath.first(coll, "/atom:feed/atom:updated", { "atom" => Atom::NS } )
|
|
186
|
+
|
|
187
|
+
# the feed hasn't been updated, don't do anything.
|
|
188
|
+
if update_el and self.updated and self.updated >= Time.parse(update_el.text)
|
|
189
|
+
return self
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
coll = self.class.parse(coll.root, self.base.to_s)
|
|
193
|
+
merge! coll
|
|
194
|
+
|
|
195
|
+
if abs_uri = next_link
|
|
196
|
+
@next = self.class.new(abs_uri.to_s, @http)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
if abs_uri = previous_link
|
|
200
|
+
@prev = self.class.new(abs_uri.to_s, @http)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
self
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
atom_link :previous_link, :rel => 'previous'
|
|
207
|
+
atom_link :next_link, :rel => 'next'
|
|
208
|
+
|
|
209
|
+
# adds an entry to this feed. if this feed already contains an
|
|
210
|
+
# entry with the same id, the newest one is used.
|
|
211
|
+
def << entry
|
|
212
|
+
existing = entries.find do |e|
|
|
213
|
+
e.id == entry.id
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if not existing
|
|
217
|
+
@entries << entry
|
|
218
|
+
elsif not existing.updated or (existing.updated and entry.updated and entry.updated >= existing.updated)
|
|
219
|
+
@entries[@entries.index(existing)] = entry
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
data/lib/atom/http.rb
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "net/https"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
require "atom/cache"
|
|
6
|
+
|
|
7
|
+
require "sha1"
|
|
8
|
+
require "digest/md5"
|
|
9
|
+
|
|
10
|
+
module URI # :nodoc: all
|
|
11
|
+
class Generic; def to_uri; self; end; end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class String # :nodoc:
|
|
15
|
+
def to_uri; URI.parse(self); end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module Atom
|
|
19
|
+
UA = "atom-tools 2.0.1"
|
|
20
|
+
|
|
21
|
+
module DigestAuth
|
|
22
|
+
CNONCE = Digest::MD5.hexdigest("%x" % (Time.now.to_i + rand(65535)))
|
|
23
|
+
|
|
24
|
+
@@nonce_count = -1
|
|
25
|
+
|
|
26
|
+
# quoted-strings plus a few special cases for Digest
|
|
27
|
+
def parse_wwwauth_digest param_string
|
|
28
|
+
params = parse_quoted_wwwauth param_string
|
|
29
|
+
qop = params[:qop] ? params[:qop].split(",") : nil
|
|
30
|
+
|
|
31
|
+
param_string.gsub(/stale=([^,]*)/) do
|
|
32
|
+
params[:stale] = ($1.downcase == "true")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
params[:algorithm] = "MD5"
|
|
36
|
+
param_string.gsub(/algorithm=([^,]*)/) { params[:algorithm] = $1 }
|
|
37
|
+
|
|
38
|
+
params
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def h(data); Digest::MD5.hexdigest(data); end
|
|
42
|
+
def kd(secret, data); h(secret + ":" + data); end
|
|
43
|
+
|
|
44
|
+
# HTTP Digest authentication (RFC 2617)
|
|
45
|
+
def digest_authenticate(req, url, param_string = "")
|
|
46
|
+
raise "Digest authentication requires a WWW-Authenticate header" if param_string.empty?
|
|
47
|
+
|
|
48
|
+
params = parse_wwwauth_digest(param_string)
|
|
49
|
+
qop = params[:qop]
|
|
50
|
+
|
|
51
|
+
user, pass = username_and_password_for_realm(url, params[:realm])
|
|
52
|
+
|
|
53
|
+
if params[:algorithm] == "MD5"
|
|
54
|
+
a1 = user + ":" + params[:realm] + ":" + pass
|
|
55
|
+
else
|
|
56
|
+
# XXX MD5-sess
|
|
57
|
+
raise "I only support MD5 digest authentication (not #{params[:algorithm].inspect})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if qop.nil? or qop.member? "auth"
|
|
61
|
+
a2 = req.method + ":" + req.path
|
|
62
|
+
else
|
|
63
|
+
# XXX auth-int
|
|
64
|
+
raise "only 'auth' qop supported (none of: #{qop.inspect})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if qop.nil?
|
|
68
|
+
response = kd(h(a1), params[:nonce] + ":" + h(a2))
|
|
69
|
+
else
|
|
70
|
+
@@nonce_count += 1
|
|
71
|
+
nc = ('%08x' % @@nonce_count)
|
|
72
|
+
|
|
73
|
+
# XXX auth-int
|
|
74
|
+
data = "#{params[:nonce]}:#{nc}:#{CNONCE}:#{"auth"}:#{h(a2)}"
|
|
75
|
+
|
|
76
|
+
response = kd(h(a1), data)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
header = %Q<Digest username="#{user}", uri="#{req.path}", realm="#{params[:realm]}", response="#{response}", nonce="#{params[:nonce]}">
|
|
80
|
+
|
|
81
|
+
if params[:opaque]
|
|
82
|
+
header += %Q<, opaque="#{params[:opaque]}">
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if params[:algorithm] != "MD5"
|
|
86
|
+
header += ", algorithm=#{algo}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if qop
|
|
90
|
+
# XXX auth-int
|
|
91
|
+
header += %Q<, nc=#{nc}, cnonce="#{CNONCE}", qop=auth>
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
req["Authorization"] = header
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class HTTPException < RuntimeError # :nodoc:
|
|
99
|
+
end
|
|
100
|
+
class Unauthorized < Atom::HTTPException # :nodoc:
|
|
101
|
+
end
|
|
102
|
+
class WrongMimetype < Atom::HTTPException # :nodoc:
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# An object which handles the details of HTTP - particularly
|
|
106
|
+
# authentication and caching (neither of which are fully implemented).
|
|
107
|
+
#
|
|
108
|
+
# This object can be used on its own, or passed to an Atom::Service,
|
|
109
|
+
# Atom::Collection or Atom::Feed, where it will be used for requests.
|
|
110
|
+
#
|
|
111
|
+
# All its HTTP methods return a Net::HTTPResponse
|
|
112
|
+
class HTTP
|
|
113
|
+
include DigestAuth
|
|
114
|
+
|
|
115
|
+
# used by the default #when_auth
|
|
116
|
+
attr_accessor :user, :pass
|
|
117
|
+
|
|
118
|
+
# the token used for Google's AuthSub authentication
|
|
119
|
+
attr_accessor :token
|
|
120
|
+
|
|
121
|
+
# when set to :basic, :wsse or :authsub, this will send an
|
|
122
|
+
# Authentication header with every request instead of waiting for a
|
|
123
|
+
# challenge from the server.
|
|
124
|
+
#
|
|
125
|
+
# be careful; always_auth :basic will send your username and
|
|
126
|
+
# password in plain text to every URL this object requests.
|
|
127
|
+
#
|
|
128
|
+
# :digest won't work, since Digest authentication requires an
|
|
129
|
+
# initial challenge to generate a response
|
|
130
|
+
#
|
|
131
|
+
# defaults to nil
|
|
132
|
+
attr_accessor :always_auth
|
|
133
|
+
|
|
134
|
+
# automatically handle redirects, even for POST/PUT/DELETE requests?
|
|
135
|
+
#
|
|
136
|
+
# defaults to false, which will transparently redirect GET requests
|
|
137
|
+
# but return a Net::HTTPRedirection object when the server
|
|
138
|
+
# indicates to redirect a POST/PUT/DELETE
|
|
139
|
+
attr_accessor :allow_all_redirects
|
|
140
|
+
|
|
141
|
+
# if set, 'cache' should be a directory for a disk cache, or an object
|
|
142
|
+
# with the same interface as Atom::FileCache
|
|
143
|
+
def initialize cache = nil
|
|
144
|
+
if cache.is_a? String
|
|
145
|
+
@cache = FileCache.new(cache)
|
|
146
|
+
elsif cache
|
|
147
|
+
@cache = cache
|
|
148
|
+
else
|
|
149
|
+
@cache = NilCache.new
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# initialize default #when_auth
|
|
153
|
+
@get_auth_details = lambda do |abs_url, realm|
|
|
154
|
+
if @user and @pass
|
|
155
|
+
[@user, @pass]
|
|
156
|
+
else
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# GETs an url
|
|
163
|
+
def get url, headers = {}
|
|
164
|
+
http_request(url, Net::HTTP::Get, nil, headers)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# POSTs body to an url
|
|
168
|
+
def post url, body, headers = {}
|
|
169
|
+
http_request(url, Net::HTTP::Post, body, headers)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# PUTs body to an url
|
|
173
|
+
def put url, body, headers = {}
|
|
174
|
+
http_request(url, Net::HTTP::Put, body, headers)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# DELETEs to url
|
|
178
|
+
def delete url, body = nil, headers = {}
|
|
179
|
+
http_request(url, Net::HTTP::Delete, body, headers)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# a block that will be called when a remote server responds with
|
|
183
|
+
# 401 Unauthorized, so that your application can prompt for
|
|
184
|
+
# authentication details.
|
|
185
|
+
#
|
|
186
|
+
# the default is to use the values of @user and @pass.
|
|
187
|
+
#
|
|
188
|
+
# your block will be called with two parameters:
|
|
189
|
+
# abs_url:: the base URL of the request URL
|
|
190
|
+
# realm:: the realm used in the WWW-Authenticate header (maybe nil)
|
|
191
|
+
#
|
|
192
|
+
# your block should return [username, password], or nil
|
|
193
|
+
def when_auth &block # :yields: abs_url, realm
|
|
194
|
+
@get_auth_details = block
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# GET a URL and turn it into an Atom::Entry
|
|
198
|
+
def get_atom_entry(url)
|
|
199
|
+
res = get(url, "Accept" => "application/atom+xml")
|
|
200
|
+
|
|
201
|
+
# XXX handle other HTTP codes
|
|
202
|
+
if res.code != "200"
|
|
203
|
+
raise Atom::HTTPException, "failed to fetch entry: expected 200 OK, got #{res.code}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# be picky for atom:entrys
|
|
207
|
+
res.validate_content_type( [ "application/atom+xml" ] )
|
|
208
|
+
|
|
209
|
+
Atom::Entry.parse(res.body, url)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# PUT an Atom::Entry to a URL
|
|
213
|
+
def put_atom_entry(entry, url = entry.edit_url)
|
|
214
|
+
raise "Cowardly refusing to PUT a non-Atom::Entry (#{entry.class})" unless entry.is_a? Atom::Entry
|
|
215
|
+
headers = {"Content-Type" => "application/atom+xml" }
|
|
216
|
+
|
|
217
|
+
put(url, entry.to_s, headers)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
# parses plain quoted-strings
|
|
222
|
+
def parse_quoted_wwwauth param_string
|
|
223
|
+
params = {}
|
|
224
|
+
|
|
225
|
+
param_string.gsub(/(\w+)="(.*?)"/) { params[$1.to_sym] = $2 }
|
|
226
|
+
|
|
227
|
+
params
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# HTTP Basic authentication (RFC 2617)
|
|
231
|
+
def basic_authenticate(req, url, param_string = "")
|
|
232
|
+
params = parse_quoted_wwwauth(param_string)
|
|
233
|
+
|
|
234
|
+
user, pass = username_and_password_for_realm(url, params[:realm])
|
|
235
|
+
|
|
236
|
+
req.basic_auth user, pass
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# is this the right way to do it? who knows, there's no
|
|
240
|
+
# spec!
|
|
241
|
+
# <http://necronomicorp.com/lab/atom-authentication-sucks>
|
|
242
|
+
#
|
|
243
|
+
# thanks to H. Miyamoto for clearing things up.
|
|
244
|
+
def wsse_authenticate(req, url, params = {})
|
|
245
|
+
user, pass = username_and_password_for_realm(url, params["realm"])
|
|
246
|
+
|
|
247
|
+
nonce = rand(16**32).to_s(16)
|
|
248
|
+
nonce_enc = [nonce].pack('m').chomp
|
|
249
|
+
now = Time.now.gmtime.iso8601
|
|
250
|
+
|
|
251
|
+
digest = [Digest::SHA1.digest(nonce + now + pass)].pack("m").chomp
|
|
252
|
+
|
|
253
|
+
req['X-WSSE'] = %Q<UsernameToken Username="#{user}", PasswordDigest="#{digest}", Nonce="#{nonce_enc}", Created="#{now}">
|
|
254
|
+
req["Authorization"] = 'WSSE profile="UsernameToken"'
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def authsub_authenticate req, url
|
|
258
|
+
req["Authorization"] = %{AuthSub token="#{@token}"}
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def username_and_password_for_realm(url, realm)
|
|
262
|
+
abs_url = (url + "/").to_s
|
|
263
|
+
user, pass = @get_auth_details.call(abs_url, realm)
|
|
264
|
+
|
|
265
|
+
unless user and pass
|
|
266
|
+
raise Unauthorized, "You must provide a username and password"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
[ user, pass ]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# performs a generic HTTP request.
|
|
273
|
+
def http_request(url_s, method, body = nil, headers = {}, www_authenticate = nil, redirect_limit = 5)
|
|
274
|
+
cachekey = url_s.to_s
|
|
275
|
+
|
|
276
|
+
cached_value = @cache[cachekey]
|
|
277
|
+
if cached_value
|
|
278
|
+
sock = Net::BufferedIO.new(StringIO.new(cached_value))
|
|
279
|
+
info = Net::HTTPResponse.read_new(sock)
|
|
280
|
+
info.reading_body(sock, true) {}
|
|
281
|
+
|
|
282
|
+
if method == Net::HTTP::Put and info.key? 'etag' and not headers['If-Match']
|
|
283
|
+
headers['If-Match'] = info['etag']
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
if cached_value and not [Net::HTTP::Get, Net::HTTP::Head].member? method
|
|
288
|
+
@cache.delete(cachekey)
|
|
289
|
+
elsif cached_value
|
|
290
|
+
entry_disposition = _entry_disposition(info, headers)
|
|
291
|
+
|
|
292
|
+
if entry_disposition == :FRESH
|
|
293
|
+
info.extend Atom::HTTPResponse
|
|
294
|
+
|
|
295
|
+
return info
|
|
296
|
+
elsif entry_disposition == :STALE
|
|
297
|
+
if info.key? 'etag' and not headers['If-None-Match']
|
|
298
|
+
headers['If-None-Match'] = info['etag']
|
|
299
|
+
end
|
|
300
|
+
if info.key? 'last-modified' and not headers['Last-Modified']
|
|
301
|
+
headers['If-Modified-Since'] = info['last-modified']
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
req, url = new_request(url_s, method, headers)
|
|
307
|
+
|
|
308
|
+
# two reasons to authenticate;
|
|
309
|
+
if @always_auth
|
|
310
|
+
self.send("#{@always_auth}_authenticate", req, url)
|
|
311
|
+
elsif www_authenticate
|
|
312
|
+
dispatch_authorization www_authenticate, req, url
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
http_obj = Net::HTTP.new(url.host, url.port)
|
|
316
|
+
http_obj.use_ssl = true if url.scheme == "https"
|
|
317
|
+
|
|
318
|
+
res = http_obj.start do |h|
|
|
319
|
+
h.request(req, body)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# a bit of added convenience
|
|
323
|
+
res.extend Atom::HTTPResponse
|
|
324
|
+
|
|
325
|
+
case res
|
|
326
|
+
when Net::HTTPUnauthorized
|
|
327
|
+
if @always_auth or www_authenticate or not res["WWW-Authenticate"] # XXX and not stale (Digest only)
|
|
328
|
+
# we've tried the credentials you gave us once
|
|
329
|
+
# and failed, or the server gave us no way to fix it
|
|
330
|
+
raise Unauthorized, "Your authorization was rejected"
|
|
331
|
+
else
|
|
332
|
+
# once more, with authentication
|
|
333
|
+
res = http_request(url_s, method, body, headers, res["WWW-Authenticate"])
|
|
334
|
+
|
|
335
|
+
if res.kind_of? Net::HTTPUnauthorized
|
|
336
|
+
raise Unauthorized, "Your authorization was rejected"
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
when Net::HTTPRedirection
|
|
340
|
+
if res.code == "304" and method == Net::HTTP::Get
|
|
341
|
+
res.end2end_headers.each { |k| info[k] = res[k] }
|
|
342
|
+
|
|
343
|
+
res = info
|
|
344
|
+
|
|
345
|
+
res["Content-Length"] = res.body.length
|
|
346
|
+
|
|
347
|
+
res.extend Atom::HTTPResponse
|
|
348
|
+
|
|
349
|
+
_updateCache(headers, res, @cache, cachekey)
|
|
350
|
+
elsif res["Location"] and (allow_all_redirects or [Net::HTTP::Get, Net::HTTP::Head].member? method)
|
|
351
|
+
raise HTTPException, "Too many redirects" if redirect_limit.zero?
|
|
352
|
+
|
|
353
|
+
res = http_request res["Location"], method, body, headers, nil, (redirect_limit - 1)
|
|
354
|
+
end
|
|
355
|
+
when Net::HTTPOK, Net::HTTPNonAuthoritativeInformation
|
|
356
|
+
unless res.key? 'Content-Location'
|
|
357
|
+
res['Content-Location'] = url_s
|
|
358
|
+
end
|
|
359
|
+
_updateCache(headers, res, @cache, cachekey)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
res
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def new_request(url_string, method, init_headers = {})
|
|
366
|
+
headers = { "User-Agent" => UA }.merge(init_headers)
|
|
367
|
+
|
|
368
|
+
url = url_string.to_uri
|
|
369
|
+
|
|
370
|
+
rel = url.path
|
|
371
|
+
rel += "?" + url.query if url.query
|
|
372
|
+
|
|
373
|
+
[method.new(rel, headers), url]
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def dispatch_authorization www_authenticate, req, url
|
|
377
|
+
param_string = www_authenticate.sub(/^(\w+) /, "")
|
|
378
|
+
auth_method = ($~[1].downcase + "_authenticate").to_sym
|
|
379
|
+
|
|
380
|
+
if self.respond_to? auth_method, true # includes private methods
|
|
381
|
+
self.send(auth_method, req, url, param_string)
|
|
382
|
+
else
|
|
383
|
+
# didn't support the first offered, find the next header
|
|
384
|
+
next_to_try = www_authenticate.sub(/.* ([\w]+ )/, '\1')
|
|
385
|
+
if next_to_try == www_authenticate
|
|
386
|
+
# this was the last WWW-Authenticate header
|
|
387
|
+
raise Atom::Unauthorized, "No support for offered authentication types"
|
|
388
|
+
else
|
|
389
|
+
dispatch_authorization next_to_try, req, url
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
module HTTPResponse
|
|
396
|
+
HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade']
|
|
397
|
+
|
|
398
|
+
# this should probably support ranges (eg. text/*)
|
|
399
|
+
def validate_content_type( valid )
|
|
400
|
+
raise Atom::HTTPException, "HTTP response contains no Content-Type!" if not self.content_type or self.content_type.empty?
|
|
401
|
+
|
|
402
|
+
media_type = self.content_type.split(";").first
|
|
403
|
+
|
|
404
|
+
unless valid.member? media_type.downcase
|
|
405
|
+
raise Atom::WrongMimetype, "unexpected response Content-Type: #{media_type.inspect}. should be one of: #{valid.inspect}"
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def end2end_headers
|
|
410
|
+
hopbyhop = HOP_BY_HOP
|
|
411
|
+
if self['connection']
|
|
412
|
+
hopbyhop += self['connection'].split(',').map { |x| x.strip }
|
|
413
|
+
end
|
|
414
|
+
@header.keys.reject { |x| hopbyhop.member? x.downcase }
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|