dnsomatic 0.4.0 → 0.4.1
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/bin/dnsomatic +3 -1
- data/lib/dnsomatic.rb +18 -19
- data/lib/dnsomatic/config.rb +59 -59
- data/lib/dnsomatic/iplookup.rb +29 -29
- data/lib/dnsomatic/logger.rb +1 -1
- data/lib/dnsomatic/open-uri.rb +683 -0
- data/lib/dnsomatic/opts.rb +63 -63
- data/lib/dnsomatic/updater.rb +17 -14
- metadata +17 -16
data/bin/dnsomatic
CHANGED
data/lib/dnsomatic.rb
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
require 'yaml'
|
2
|
-
require 'open-uri'
|
2
|
+
require 'dnsomatic/open-uri'
|
3
3
|
|
4
4
|
# :title: dnsomatic - a DNS-o-Matic update client
|
5
5
|
#
|
@@ -75,19 +75,19 @@ require 'open-uri'
|
|
75
75
|
# * mx - a hostname that will handle mail delivery for this host. it must
|
76
76
|
# resolve to an IP or DNS-o-Matic will ignore it.
|
77
77
|
# * backmx - a lower priority mx record. sames rules as mx. you may also list
|
78
|
-
#
|
78
|
+
# these as NOCHG, which tells DNS-o-Matic to leave them as is.
|
79
79
|
# * hostname - the hostname to update. the defaults specify this as
|
80
|
-
#
|
81
|
-
#
|
80
|
+
# all.dnsomatic.com, which tells dnsomatic to update all listed
|
81
|
+
# records with the same values.
|
82
82
|
# * wildcard - indicates whether foo.hostname and bar.hostname and baz.hostname should also resolve to the same IP as hostname.
|
83
|
-
#
|
84
|
-
#
|
85
|
-
#
|
83
|
+
# - ON = enable
|
84
|
+
# - NOCHG = leave it as is
|
85
|
+
# - _other_ = disable
|
86
86
|
# * offline - sets the hostname of offline mode, which may do some redirection
|
87
|
-
#
|
88
|
-
#
|
89
|
-
#
|
90
|
-
#
|
87
|
+
# things depending on the service being updated.
|
88
|
+
# - YES = enable
|
89
|
+
# - NOCHG = leave it as is
|
90
|
+
# - _other_ = disable
|
91
91
|
#
|
92
92
|
# = Usage
|
93
93
|
#
|
@@ -106,7 +106,7 @@ require 'open-uri'
|
|
106
106
|
# -h, --help Display this help text
|
107
107
|
|
108
108
|
module DNSOMatic
|
109
|
-
VERSION = '0.4.
|
109
|
+
VERSION = '0.4.1'
|
110
110
|
USERAGENT = "Ruby_DNS-o-Matic/#{VERSION}"
|
111
111
|
|
112
112
|
# We provide our easily distinguishable exception class so that we can easily
|
@@ -117,12 +117,11 @@ module DNSOMatic
|
|
117
117
|
uri = URI.parse(url)
|
118
118
|
|
119
119
|
begin
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
end
|
120
|
+
opts = { 'User-Agent' => USERAGENT, :ssl_verify => false }
|
121
|
+
if uri.user and uri.password
|
122
|
+
opts[:http_basic_authentication] = [uri.user, uri.password]
|
123
|
+
end
|
124
|
+
res = open(url, opts)
|
126
125
|
res.read
|
127
126
|
rescue OpenURI::HTTPError, SocketError => e
|
128
127
|
msg = "Error communicating with #{uri.host}\n"
|
@@ -145,7 +144,7 @@ module DNSOMatic
|
|
145
144
|
def self.yaml_write(file, data)
|
146
145
|
begin
|
147
146
|
File.open(file, 'w') do |f|
|
148
|
-
|
147
|
+
f.puts data.to_yaml
|
149
148
|
end
|
150
149
|
rescue Exception => e
|
151
150
|
msg = "An exception (#{e.class}) occurred while writing a yaml file: #{file}\n"
|
data/lib/dnsomatic/config.rb
CHANGED
@@ -20,18 +20,18 @@ module DNSOMatic
|
|
20
20
|
#in most cases, a user can simply set username and password in a defaults:
|
21
21
|
#stanza and fire the client.
|
22
22
|
@defaults = { 'hostname' => 'all.dnsomatic.com',
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
23
|
+
'wildcard' => 'NOCHG',
|
24
|
+
'mx' => 'NOCHG',
|
25
|
+
'backmx' => 'NOCHG',
|
26
|
+
'offline' => 'NOCHG',
|
27
|
+
'webipfetchurl' => 'http://myip.dnsomatic.com/' }
|
28
28
|
|
29
29
|
@type_validators = { 'hostname' => 'host',
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
30
|
+
'wildcard' => 'on_nochg',
|
31
|
+
'mx' => 'host',
|
32
|
+
'backmx' => 'yes_nochg',
|
33
|
+
'offline' => 'yes_nochg',
|
34
|
+
'webipfetchurl' => 'url' }
|
35
35
|
|
36
36
|
load()
|
37
37
|
end
|
@@ -50,12 +50,12 @@ module DNSOMatic
|
|
50
50
|
def updaters(prune_to = nil)
|
51
51
|
#don't create updater objects until they're actually requested.
|
52
52
|
#(saves a little overhead if just displaying the config)
|
53
|
-
|
53
|
+
|
54
54
|
if @updaters.nil?
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
55
|
+
@updaters = {}
|
56
|
+
@config.each_key do |token|
|
57
|
+
@updaters[token] = Updater.new(@config[token])
|
58
|
+
end
|
59
59
|
end
|
60
60
|
|
61
61
|
prune_to.nil? ? @updaters : one_key(@updaters, prune_to)
|
@@ -65,11 +65,11 @@ module DNSOMatic
|
|
65
65
|
|
66
66
|
def one_key(hsh, key)
|
67
67
|
if ! hsh.has_key?(key)
|
68
|
-
|
69
|
-
|
70
|
-
|
68
|
+
msg = "Invalid host stanza filter ('#{key}').\n"
|
69
|
+
msg += "You config doesn't define anything with that name."
|
70
|
+
raise(DNSOMatic::ConfErr, msg)
|
71
71
|
else
|
72
|
-
|
72
|
+
{ key => hsh[key] }
|
73
73
|
end
|
74
74
|
end
|
75
75
|
|
@@ -78,57 +78,57 @@ module DNSOMatic
|
|
78
78
|
raise DNSOMatic::Error, "Invalid configuration format in #{@cf}" unless conf.kind_of?(Hash)
|
79
79
|
|
80
80
|
if conf.has_key?('defaults')
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
81
|
+
#allow the user to override our built-in defaults
|
82
|
+
@defaults.merge!(conf['defaults'])
|
83
|
+
#if they've provided only the defaults stanza, we'll use it to perform
|
84
|
+
#the update, otherwise remove it as it has been folded into @defaults
|
85
|
+
conf.delete('defaults') if conf.keys.size > 1
|
86
86
|
end
|
87
87
|
|
88
88
|
conf.each_key do |name|
|
89
|
-
|
89
|
+
stanza = @defaults.merge(conf[name])
|
90
90
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
91
|
+
#just in case
|
92
|
+
stanza.each_pair do |k,v|
|
93
|
+
stanza[k] = fmt(v)
|
94
|
+
end
|
95
95
|
|
96
|
-
|
96
|
+
validate(name, stanza)
|
97
97
|
|
98
|
-
|
99
|
-
|
98
|
+
#save our merged version in case we're just dump our config to stdout
|
99
|
+
@config[name] = stanza
|
100
100
|
end
|
101
101
|
end
|
102
102
|
|
103
103
|
def validate(name, stanza)
|
104
104
|
#first, ensure we have the _required_ fields in an update def stanza
|
105
105
|
%w(username password).each do |required|
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
106
|
+
#still test for existence in case the defaults get munged.
|
107
|
+
if !stanza.has_key?(required) or stanza[required].nil?
|
108
|
+
msg = "Invalid configuration for Host Updater named '#{name}'\n"
|
109
|
+
msg += "Please define the field: #{required}.\n"
|
110
|
+
raise(DNSOMatic::Error, msg)
|
111
|
+
end
|
112
112
|
end
|
113
113
|
|
114
114
|
@type_validators.each do |field, validator|
|
115
|
-
|
115
|
+
self.send("validate_#{validator}", field, stanza[field])
|
116
116
|
end
|
117
|
-
|
117
|
+
|
118
118
|
#the dnsomatic api spec indicates that mx/back mx can be either NOCHG
|
119
119
|
#or a hostname that must resolve to an IP.
|
120
120
|
%w(mx backmx).each do |mxtype|
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
121
|
+
mxval = stanza[mxtype]
|
122
|
+
next if mxval.eql?('NOCHG')
|
123
|
+
|
124
|
+
begin
|
125
|
+
Resolv.getaddress(mxval)
|
126
|
+
rescue Resolv::ResolvError => e
|
127
|
+
msg = "Invalid value for #{mxtype}.\n"
|
128
|
+
msg += "It must be either NOCHG or a valid hostname.\n"
|
129
|
+
msg += e.message + "\n"
|
130
|
+
raise(DNSOMatic::Error, msg)
|
131
|
+
end
|
132
132
|
end
|
133
133
|
end
|
134
134
|
|
@@ -137,17 +137,17 @@ module DNSOMatic
|
|
137
137
|
#don't want to burden user with prefixing the value with !str, we'll
|
138
138
|
#attempt to deduce what they meant here...
|
139
139
|
if [TrueClass, FalseClass].include?(val.class)
|
140
|
-
|
140
|
+
val ? 'ON' : 'OFF'
|
141
141
|
elsif val.kind_of?(NilClass)
|
142
|
-
|
142
|
+
'NOCHG'
|
143
143
|
elsif val.kind_of?(String)
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
144
|
+
case val.downcase
|
145
|
+
when 'no': 'OFF'
|
146
|
+
when 'yes': 'ON'
|
147
|
+
else val.gsub(/\s+/, '')
|
148
|
+
end
|
149
149
|
else
|
150
|
-
|
150
|
+
val.to_s.gsub(/\s+/, '')
|
151
151
|
end
|
152
152
|
end
|
153
153
|
|
data/lib/dnsomatic/iplookup.rb
CHANGED
@@ -17,7 +17,7 @@ module DNSOMatic
|
|
17
17
|
# the URL. A commonly used example is http://www.whatismyip.org
|
18
18
|
def initialize(url)
|
19
19
|
@url = url
|
20
|
-
@status = CHANGED
|
20
|
+
@status = CHANGED #all new lookups are changed.
|
21
21
|
@ip = getip()
|
22
22
|
@last_update = Time.now
|
23
23
|
Logger::log("Fetched new IP #{@ip} from #{@url}.")
|
@@ -34,51 +34,51 @@ module DNSOMatic
|
|
34
34
|
# and potentially alter our status as returned by changed?.
|
35
35
|
def update
|
36
36
|
if min_elapsed?
|
37
|
-
|
38
|
-
|
37
|
+
Logger::log("Returned cached IP #{@ip} from #{@url}.")
|
38
|
+
@status = UNCHANGED
|
39
39
|
else
|
40
|
-
|
41
|
-
|
40
|
+
ip = getip()
|
41
|
+
@last_update = @status ? Time.now : @last_update
|
42
42
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
43
|
+
if !@ip.eql?(ip)
|
44
|
+
Logger::log("Detected IP change (#{@ip} -> #{ip}) from #{@url}.")
|
45
|
+
else
|
46
|
+
Logger::log("No IP change detected from #{@url}.")
|
47
|
+
end
|
48
48
|
|
49
|
-
|
50
|
-
|
49
|
+
@status = (max_elapsed? or !@ip.eql?(ip)) ? CHANGED : UNCHANGED
|
50
|
+
@ip = ip
|
51
51
|
end
|
52
52
|
end
|
53
53
|
|
54
54
|
private
|
55
55
|
def min_elapsed?
|
56
56
|
if Time.now - @last_update <= @@opts.minimum
|
57
|
-
|
58
|
-
|
57
|
+
Logger::log("Minimum lookup interval (#{@@opts.minimum}s) not expired.")
|
58
|
+
true
|
59
59
|
else
|
60
|
-
|
60
|
+
false
|
61
61
|
end
|
62
62
|
end
|
63
63
|
|
64
64
|
def max_elapsed?
|
65
65
|
if Time.now - @last_update >= @@opts.maximum
|
66
|
-
|
67
|
-
|
66
|
+
Logger::log("Maximum update interval (#{@@opts.maximum}s) has elapsed. Update will be forced.")
|
67
|
+
true
|
68
68
|
else
|
69
|
-
|
69
|
+
false
|
70
70
|
end
|
71
71
|
end
|
72
72
|
|
73
73
|
def getip
|
74
74
|
ip = DNSOMatic::http_fetch(@url)
|
75
75
|
if !ip.match(/(\d{1,3}\.){3}\d{1,3}/)
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
76
|
+
msg = "Strange return value from IP Lookup service: #{@url}\n"
|
77
|
+
msg += "Body of HTTP response was:\n"
|
78
|
+
msg += ip
|
79
|
+
raise(DNSOMatic::Error, msg)
|
80
80
|
else
|
81
|
-
|
81
|
+
ip
|
82
82
|
end
|
83
83
|
end
|
84
84
|
end
|
@@ -105,9 +105,9 @@ module DNSOMatic
|
|
105
105
|
# IPStatus objects to.
|
106
106
|
def setcachefile(file)
|
107
107
|
if !File.writable?(File.dirname(file))
|
108
|
-
|
108
|
+
raise(DNSOMatic::Error, "Unwritable cache file directory.")
|
109
109
|
elsif File.exists?(file) and !File.writable(file)
|
110
|
-
|
110
|
+
raise(DNSOMatic::Error, "Unwritable cache file")
|
111
111
|
end
|
112
112
|
@cache_file = file
|
113
113
|
end
|
@@ -124,9 +124,9 @@ module DNSOMatic
|
|
124
124
|
#a new IPStatus object, we don't differntiate between seen and unseen
|
125
125
|
#here.
|
126
126
|
if @cache[url]
|
127
|
-
|
127
|
+
@cache[url].update
|
128
128
|
else
|
129
|
-
|
129
|
+
@cache[url] = IPStatus.new(url)
|
130
130
|
end
|
131
131
|
|
132
132
|
save() #ensure that we get spooled to disk.
|
@@ -136,12 +136,12 @@ module DNSOMatic
|
|
136
136
|
private
|
137
137
|
def load
|
138
138
|
if File.exists?(@cache_file) and @persist
|
139
|
-
|
139
|
+
@cache = DNSOMatic::yaml_read(@cache_file)
|
140
140
|
end
|
141
141
|
end
|
142
142
|
|
143
143
|
def save
|
144
|
-
DNSOMatic::yaml_write(@cache_file, @cache) if @persist
|
144
|
+
DNSOMatic::yaml_write(@cache_file, @cache) if @persist
|
145
145
|
end
|
146
146
|
end
|
147
147
|
end
|
data/lib/dnsomatic/logger.rb
CHANGED
@@ -0,0 +1,683 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'stringio'
|
3
|
+
require 'time'
|
4
|
+
|
5
|
+
module Kernel
|
6
|
+
private
|
7
|
+
alias open_uri_original_open open # :nodoc:
|
8
|
+
|
9
|
+
# makes possible to open various resources including URIs.
|
10
|
+
# If the first argument respond to `open' method,
|
11
|
+
# the method is called with the rest arguments.
|
12
|
+
#
|
13
|
+
# If the first argument is a string which begins with xxx://,
|
14
|
+
# it is parsed by URI.parse. If the parsed object respond to `open' method,
|
15
|
+
# the method is called with the rest arguments.
|
16
|
+
#
|
17
|
+
# Otherwise original open is called.
|
18
|
+
#
|
19
|
+
# Since open-uri.rb provides URI::HTTP#open, URI::HTTPS#open and
|
20
|
+
# URI::FTP#open,
|
21
|
+
# Kernel[#.]open can accepts such URIs and strings which begins with
|
22
|
+
# http://, https:// and ftp://.
|
23
|
+
# In these case, the opened file object is extended by OpenURI::Meta.
|
24
|
+
def open(name, *rest, &block) # :doc:
|
25
|
+
if name.respond_to?(:open)
|
26
|
+
name.open(*rest, &block)
|
27
|
+
elsif name.respond_to?(:to_str) &&
|
28
|
+
%r{\A[A-Za-z][A-Za-z0-9+\-\.]*://} =~ name &&
|
29
|
+
(uri = URI.parse(name)).respond_to?(:open)
|
30
|
+
uri.open(*rest, &block)
|
31
|
+
else
|
32
|
+
open_uri_original_open(name, *rest, &block)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
module_function :open
|
36
|
+
end
|
37
|
+
|
38
|
+
# OpenURI is an easy-to-use wrapper for net/http, net/https and net/ftp.
|
39
|
+
#
|
40
|
+
#== Example
|
41
|
+
#
|
42
|
+
# It is possible to open http/https/ftp URL as usual like opening a file:
|
43
|
+
#
|
44
|
+
# open("http://www.ruby-lang.org/") {|f|
|
45
|
+
# f.each_line {|line| p line}
|
46
|
+
# }
|
47
|
+
#
|
48
|
+
# The opened file has several methods for meta information as follows since
|
49
|
+
# it is extended by OpenURI::Meta.
|
50
|
+
#
|
51
|
+
# open("http://www.ruby-lang.org/en") {|f|
|
52
|
+
# f.each_line {|line| p line}
|
53
|
+
# p f.base_uri # <URI::HTTP:0x40e6ef2 URL:http://www.ruby-lang.org/en/>
|
54
|
+
# p f.content_type # "text/html"
|
55
|
+
# p f.charset # "iso-8859-1"
|
56
|
+
# p f.content_encoding # []
|
57
|
+
# p f.last_modified # Thu Dec 05 02:45:02 UTC 2002
|
58
|
+
# }
|
59
|
+
#
|
60
|
+
# Additional header fields can be specified by an optional hash argument.
|
61
|
+
#
|
62
|
+
# open("http://www.ruby-lang.org/en/",
|
63
|
+
# "User-Agent" => "Ruby/#{RUBY_VERSION}",
|
64
|
+
# "From" => "foo@bar.invalid",
|
65
|
+
# "Referer" => "http://www.ruby-lang.org/") {|f|
|
66
|
+
# # ...
|
67
|
+
# }
|
68
|
+
#
|
69
|
+
# The environment variables such as http_proxy, https_proxy and ftp_proxy
|
70
|
+
# are in effect by default. :proxy => nil disables proxy.
|
71
|
+
#
|
72
|
+
# open("http://www.ruby-lang.org/en/raa.html", :proxy => nil) {|f|
|
73
|
+
# # ...
|
74
|
+
# }
|
75
|
+
#
|
76
|
+
# URI objects can be opened in a similar way.
|
77
|
+
#
|
78
|
+
# uri = URI.parse("http://www.ruby-lang.org/en/")
|
79
|
+
# uri.open {|f|
|
80
|
+
# # ...
|
81
|
+
# }
|
82
|
+
#
|
83
|
+
# URI objects can be read directly. The returned string is also extended by
|
84
|
+
# OpenURI::Meta.
|
85
|
+
#
|
86
|
+
# str = uri.read
|
87
|
+
# p str.base_uri
|
88
|
+
#
|
89
|
+
# Author:: Tanaka Akira <akr@m17n.org>
|
90
|
+
|
91
|
+
module OpenURI
|
92
|
+
Options = {
|
93
|
+
:proxy => true,
|
94
|
+
:progress_proc => true,
|
95
|
+
:content_length_proc => true,
|
96
|
+
:http_basic_authentication => true,
|
97
|
+
:ssl_verify => true
|
98
|
+
}
|
99
|
+
|
100
|
+
def OpenURI.check_options(options) # :nodoc:
|
101
|
+
options.each {|k, v|
|
102
|
+
next unless Symbol === k
|
103
|
+
unless Options.include? k
|
104
|
+
raise ArgumentError, "unrecognized option: #{k}"
|
105
|
+
end
|
106
|
+
}
|
107
|
+
end
|
108
|
+
|
109
|
+
def OpenURI.scan_open_optional_arguments(*rest) # :nodoc:
|
110
|
+
if !rest.empty? && (String === rest.first || Integer === rest.first)
|
111
|
+
mode = rest.shift
|
112
|
+
if !rest.empty? && Integer === rest.first
|
113
|
+
perm = rest.shift
|
114
|
+
end
|
115
|
+
end
|
116
|
+
return mode, perm, rest
|
117
|
+
end
|
118
|
+
|
119
|
+
def OpenURI.open_uri(name, *rest) # :nodoc:
|
120
|
+
uri = URI::Generic === name ? name : URI.parse(name)
|
121
|
+
mode, perm, rest = OpenURI.scan_open_optional_arguments(*rest)
|
122
|
+
options = rest.shift if !rest.empty? && Hash === rest.first
|
123
|
+
raise ArgumentError.new("extra arguments") if !rest.empty?
|
124
|
+
options ||= {}
|
125
|
+
OpenURI.check_options(options)
|
126
|
+
|
127
|
+
unless mode == nil ||
|
128
|
+
mode == 'r' || mode == 'rb' ||
|
129
|
+
mode == File::RDONLY
|
130
|
+
raise ArgumentError.new("invalid access mode #{mode} (#{uri.class} resource is read only.)")
|
131
|
+
end
|
132
|
+
|
133
|
+
io = open_loop(uri, options)
|
134
|
+
if block_given?
|
135
|
+
begin
|
136
|
+
yield io
|
137
|
+
ensure
|
138
|
+
io.close
|
139
|
+
end
|
140
|
+
else
|
141
|
+
io
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def OpenURI.open_loop(uri, options) # :nodoc:
|
146
|
+
case opt_proxy = options.fetch(:proxy, true)
|
147
|
+
when true
|
148
|
+
find_proxy = lambda {|u| u.find_proxy}
|
149
|
+
when nil, false
|
150
|
+
find_proxy = lambda {|u| nil}
|
151
|
+
when String
|
152
|
+
opt_proxy = URI.parse(opt_proxy)
|
153
|
+
find_proxy = lambda {|u| opt_proxy}
|
154
|
+
when URI::Generic
|
155
|
+
find_proxy = lambda {|u| opt_proxy}
|
156
|
+
else
|
157
|
+
raise ArgumentError.new("Invalid proxy option: #{opt_proxy}")
|
158
|
+
end
|
159
|
+
|
160
|
+
uri_set = {}
|
161
|
+
buf = nil
|
162
|
+
while true
|
163
|
+
redirect = catch(:open_uri_redirect) {
|
164
|
+
buf = Buffer.new
|
165
|
+
uri.buffer_open(buf, find_proxy.call(uri), options)
|
166
|
+
nil
|
167
|
+
}
|
168
|
+
if redirect
|
169
|
+
if redirect.relative?
|
170
|
+
# Although it violates RFC2616, Location: field may have relative
|
171
|
+
# URI. It is converted to absolute URI using uri as a base URI.
|
172
|
+
redirect = uri + redirect
|
173
|
+
end
|
174
|
+
unless OpenURI.redirectable?(uri, redirect)
|
175
|
+
raise "redirection forbidden: #{uri} -> #{redirect}"
|
176
|
+
end
|
177
|
+
if options.include? :http_basic_authentication
|
178
|
+
# send authentication only for the URI directly specified.
|
179
|
+
options = options.dup
|
180
|
+
options.delete :http_basic_authentication
|
181
|
+
end
|
182
|
+
uri = redirect
|
183
|
+
raise "HTTP redirection loop: #{uri}" if uri_set.include? uri.to_s
|
184
|
+
uri_set[uri.to_s] = true
|
185
|
+
else
|
186
|
+
break
|
187
|
+
end
|
188
|
+
end
|
189
|
+
io = buf.io
|
190
|
+
io.base_uri = uri
|
191
|
+
io
|
192
|
+
end
|
193
|
+
|
194
|
+
def OpenURI.redirectable?(uri1, uri2) # :nodoc:
|
195
|
+
# This test is intended to forbid a redirection from http://... to
|
196
|
+
# file:///etc/passwd.
|
197
|
+
# However this is ad hoc. It should be extensible/configurable.
|
198
|
+
uri1.scheme.downcase == uri2.scheme.downcase ||
|
199
|
+
(/\A(?:http|ftp)\z/i =~ uri1.scheme && /\A(?:http|ftp)\z/i =~ uri2.scheme)
|
200
|
+
end
|
201
|
+
|
202
|
+
def OpenURI.open_http(buf, target, proxy, options) # :nodoc:
|
203
|
+
if proxy
|
204
|
+
raise "Non-HTTP proxy URI: #{proxy}" if proxy.class != URI::HTTP
|
205
|
+
end
|
206
|
+
|
207
|
+
if target.userinfo && "1.9.0" <= RUBY_VERSION
|
208
|
+
# don't raise for 1.8 because compatibility.
|
209
|
+
raise ArgumentError, "userinfo not supported. [RFC3986]"
|
210
|
+
end
|
211
|
+
|
212
|
+
require 'net/http'
|
213
|
+
klass = Net::HTTP
|
214
|
+
if URI::HTTP === target
|
215
|
+
# HTTP or HTTPS
|
216
|
+
if proxy
|
217
|
+
klass = Net::HTTP::Proxy(proxy.host, proxy.port)
|
218
|
+
end
|
219
|
+
target_host = target.host
|
220
|
+
target_port = target.port
|
221
|
+
request_uri = target.request_uri
|
222
|
+
else
|
223
|
+
# FTP over HTTP proxy
|
224
|
+
target_host = proxy.host
|
225
|
+
target_port = proxy.port
|
226
|
+
request_uri = target.to_s
|
227
|
+
end
|
228
|
+
|
229
|
+
http = klass.new(target_host, target_port)
|
230
|
+
if target.class == URI::HTTPS
|
231
|
+
require 'net/https'
|
232
|
+
http.use_ssl = true
|
233
|
+
if options[:ssl_verify] == false
|
234
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
235
|
+
else
|
236
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
237
|
+
end
|
238
|
+
store = OpenSSL::X509::Store.new
|
239
|
+
store.set_default_paths
|
240
|
+
http.cert_store = store
|
241
|
+
end
|
242
|
+
|
243
|
+
header = {}
|
244
|
+
options.each {|k, v| header[k] = v if String === k }
|
245
|
+
|
246
|
+
resp = nil
|
247
|
+
http.start {
|
248
|
+
req = Net::HTTP::Get.new(request_uri, header)
|
249
|
+
if options.include? :http_basic_authentication
|
250
|
+
user, pass = options[:http_basic_authentication]
|
251
|
+
req.basic_auth user, pass
|
252
|
+
end
|
253
|
+
http.request(req) {|response|
|
254
|
+
resp = response
|
255
|
+
if options[:content_length_proc] && Net::HTTPSuccess === resp
|
256
|
+
if resp.key?('Content-Length')
|
257
|
+
options[:content_length_proc].call(resp['Content-Length'].to_i)
|
258
|
+
else
|
259
|
+
options[:content_length_proc].call(nil)
|
260
|
+
end
|
261
|
+
end
|
262
|
+
resp.read_body {|str|
|
263
|
+
buf << str
|
264
|
+
if options[:progress_proc] && Net::HTTPSuccess === resp
|
265
|
+
options[:progress_proc].call(buf.size)
|
266
|
+
end
|
267
|
+
}
|
268
|
+
}
|
269
|
+
}
|
270
|
+
io = buf.io
|
271
|
+
io.rewind
|
272
|
+
io.status = [resp.code, resp.message]
|
273
|
+
resp.each {|name,value| buf.io.meta_add_field name, value }
|
274
|
+
case resp
|
275
|
+
when Net::HTTPSuccess
|
276
|
+
when Net::HTTPMovedPermanently, # 301
|
277
|
+
Net::HTTPFound, # 302
|
278
|
+
Net::HTTPSeeOther, # 303
|
279
|
+
Net::HTTPTemporaryRedirect # 307
|
280
|
+
throw :open_uri_redirect, URI.parse(resp['location'])
|
281
|
+
else
|
282
|
+
raise OpenURI::HTTPError.new(io.status.join(' '), io)
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
class HTTPError < StandardError
|
287
|
+
def initialize(message, io)
|
288
|
+
super(message)
|
289
|
+
@io = io
|
290
|
+
end
|
291
|
+
attr_reader :io
|
292
|
+
end
|
293
|
+
|
294
|
+
class Buffer # :nodoc:
|
295
|
+
def initialize
|
296
|
+
@io = StringIO.new
|
297
|
+
@size = 0
|
298
|
+
end
|
299
|
+
attr_reader :size
|
300
|
+
|
301
|
+
StringMax = 10240
|
302
|
+
def <<(str)
|
303
|
+
@io << str
|
304
|
+
@size += str.length
|
305
|
+
if StringIO === @io && StringMax < @size
|
306
|
+
require 'tempfile'
|
307
|
+
io = Tempfile.new('open-uri')
|
308
|
+
io.binmode
|
309
|
+
Meta.init io, @io if @io.respond_to? :meta
|
310
|
+
io << @io.string
|
311
|
+
@io = io
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
def io
|
316
|
+
Meta.init @io unless @io.respond_to? :meta
|
317
|
+
@io
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
# Mixin for holding meta-information.
|
322
|
+
module Meta
|
323
|
+
def Meta.init(obj, src=nil) # :nodoc:
|
324
|
+
obj.extend Meta
|
325
|
+
obj.instance_eval {
|
326
|
+
@base_uri = nil
|
327
|
+
@meta = {}
|
328
|
+
}
|
329
|
+
if src
|
330
|
+
obj.status = src.status
|
331
|
+
obj.base_uri = src.base_uri
|
332
|
+
src.meta.each {|name, value|
|
333
|
+
obj.meta_add_field(name, value)
|
334
|
+
}
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
338
|
+
# returns an Array which consists status code and message.
|
339
|
+
attr_accessor :status
|
340
|
+
|
341
|
+
# returns a URI which is base of relative URIs in the data.
|
342
|
+
# It may differ from the URI supplied by a user because redirection.
|
343
|
+
attr_accessor :base_uri
|
344
|
+
|
345
|
+
# returns a Hash which represents header fields.
|
346
|
+
# The Hash keys are downcased for canonicalization.
|
347
|
+
attr_reader :meta
|
348
|
+
|
349
|
+
def meta_add_field(name, value) # :nodoc:
|
350
|
+
@meta[name.downcase] = value
|
351
|
+
end
|
352
|
+
|
353
|
+
# returns a Time which represents Last-Modified field.
|
354
|
+
def last_modified
|
355
|
+
if v = @meta['last-modified']
|
356
|
+
Time.httpdate(v)
|
357
|
+
else
|
358
|
+
nil
|
359
|
+
end
|
360
|
+
end
|
361
|
+
|
362
|
+
RE_LWS = /[\r\n\t ]+/n
|
363
|
+
RE_TOKEN = %r{[^\x00- ()<>@,;:\\"/\[\]?={}\x7f]+}n
|
364
|
+
RE_QUOTED_STRING = %r{"(?:[\r\n\t !#-\[\]-~\x80-\xff]|\\[\x00-\x7f])*"}n
|
365
|
+
RE_PARAMETERS = %r{(?:;#{RE_LWS}?#{RE_TOKEN}#{RE_LWS}?=#{RE_LWS}?(?:#{RE_TOKEN}|#{RE_QUOTED_STRING})#{RE_LWS}?)*}n
|
366
|
+
|
367
|
+
def content_type_parse # :nodoc:
|
368
|
+
v = @meta['content-type']
|
369
|
+
# The last (?:;#{RE_LWS}?)? matches extra ";" which violates RFC2045.
|
370
|
+
if v && %r{\A#{RE_LWS}?(#{RE_TOKEN})#{RE_LWS}?/(#{RE_TOKEN})#{RE_LWS}?(#{RE_PARAMETERS})(?:;#{RE_LWS}?)?\z}no =~ v
|
371
|
+
type = $1.downcase
|
372
|
+
subtype = $2.downcase
|
373
|
+
parameters = []
|
374
|
+
$3.scan(/;#{RE_LWS}?(#{RE_TOKEN})#{RE_LWS}?=#{RE_LWS}?(?:(#{RE_TOKEN})|(#{RE_QUOTED_STRING}))/no) {|att, val, qval|
|
375
|
+
val = qval.gsub(/[\r\n\t !#-\[\]-~\x80-\xff]+|(\\[\x00-\x7f])/) { $1 ? $1[1,1] : $& } if qval
|
376
|
+
parameters << [att.downcase, val]
|
377
|
+
}
|
378
|
+
["#{type}/#{subtype}", *parameters]
|
379
|
+
else
|
380
|
+
nil
|
381
|
+
end
|
382
|
+
end
|
383
|
+
|
384
|
+
# returns "type/subtype" which is MIME Content-Type.
|
385
|
+
# It is downcased for canonicalization.
|
386
|
+
# Content-Type parameters are stripped.
|
387
|
+
def content_type
|
388
|
+
type, *parameters = content_type_parse
|
389
|
+
type || 'application/octet-stream'
|
390
|
+
end
|
391
|
+
|
392
|
+
# returns a charset parameter in Content-Type field.
|
393
|
+
# It is downcased for canonicalization.
|
394
|
+
#
|
395
|
+
# If charset parameter is not given but a block is given,
|
396
|
+
# the block is called and its result is returned.
|
397
|
+
# It can be used to guess charset.
|
398
|
+
#
|
399
|
+
# If charset parameter and block is not given,
|
400
|
+
# nil is returned except text type in HTTP.
|
401
|
+
# In that case, "iso-8859-1" is returned as defined by RFC2616 3.7.1.
|
402
|
+
def charset
|
403
|
+
type, *parameters = content_type_parse
|
404
|
+
if pair = parameters.assoc('charset')
|
405
|
+
pair.last.downcase
|
406
|
+
elsif block_given?
|
407
|
+
yield
|
408
|
+
elsif type && %r{\Atext/} =~ type &&
|
409
|
+
@base_uri && /\Ahttp\z/i =~ @base_uri.scheme
|
410
|
+
"iso-8859-1" # RFC2616 3.7.1
|
411
|
+
else
|
412
|
+
nil
|
413
|
+
end
|
414
|
+
end
|
415
|
+
|
416
|
+
# returns a list of encodings in Content-Encoding field
|
417
|
+
# as an Array of String.
|
418
|
+
# The encodings are downcased for canonicalization.
|
419
|
+
def content_encoding
|
420
|
+
v = @meta['content-encoding']
|
421
|
+
if v && %r{\A#{RE_LWS}?#{RE_TOKEN}#{RE_LWS}?(?:,#{RE_LWS}?#{RE_TOKEN}#{RE_LWS}?)*}o =~ v
|
422
|
+
v.scan(RE_TOKEN).map {|content_coding| content_coding.downcase}
|
423
|
+
else
|
424
|
+
[]
|
425
|
+
end
|
426
|
+
end
|
427
|
+
end
|
428
|
+
|
429
|
+
# Mixin for HTTP and FTP URIs.
|
430
|
+
module OpenRead
|
431
|
+
# OpenURI::OpenRead#open provides `open' for URI::HTTP and URI::FTP.
|
432
|
+
#
|
433
|
+
# OpenURI::OpenRead#open takes optional 3 arguments as:
|
434
|
+
# OpenURI::OpenRead#open([mode [, perm]] [, options]) [{|io| ... }]
|
435
|
+
#
|
436
|
+
# `mode', `perm' is same as Kernel#open.
|
437
|
+
#
|
438
|
+
# However, `mode' must be read mode because OpenURI::OpenRead#open doesn't
|
439
|
+
# support write mode (yet).
|
440
|
+
# Also `perm' is just ignored because it is meaningful only for file
|
441
|
+
# creation.
|
442
|
+
#
|
443
|
+
# `options' must be a hash.
|
444
|
+
#
|
445
|
+
# Each pairs which key is a string in the hash specify a extra header
|
446
|
+
# field for HTTP.
|
447
|
+
# I.e. it is ignored for FTP without HTTP proxy.
|
448
|
+
#
|
449
|
+
# The hash may include other options which key is a symbol:
|
450
|
+
#
|
451
|
+
# [:proxy]
|
452
|
+
# Synopsis:
|
453
|
+
# :proxy => "http://proxy.foo.com:8000/"
|
454
|
+
# :proxy => URI.parse("http://proxy.foo.com:8000/")
|
455
|
+
# :proxy => true
|
456
|
+
# :proxy => false
|
457
|
+
# :proxy => nil
|
458
|
+
#
|
459
|
+
# If :proxy option is specified, the value should be String, URI,
|
460
|
+
# boolean or nil.
|
461
|
+
# When String or URI is given, it is treated as proxy URI.
|
462
|
+
# When true is given or the option itself is not specified,
|
463
|
+
# environment variable `scheme_proxy' is examined.
|
464
|
+
# `scheme' is replaced by `http', `https' or `ftp'.
|
465
|
+
# When false or nil is given, the environment variables are ignored and
|
466
|
+
# connection will be made to a server directly.
|
467
|
+
#
|
468
|
+
# [:http_basic_authentication]
|
469
|
+
# Synopsis:
|
470
|
+
# :http_basic_authentication=>[user, password]
|
471
|
+
#
|
472
|
+
# If :http_basic_authentication is specified,
|
473
|
+
# the value should be an array which contains 2 strings:
|
474
|
+
# username and password.
|
475
|
+
# It is used for HTTP Basic authentication defined by RFC 2617.
|
476
|
+
#
|
477
|
+
# [:content_length_proc]
|
478
|
+
# Synopsis:
|
479
|
+
# :content_length_proc => lambda {|content_length| ... }
|
480
|
+
#
|
481
|
+
# If :content_length_proc option is specified, the option value procedure
|
482
|
+
# is called before actual transfer is started.
|
483
|
+
# It takes one argument which is expected content length in bytes.
|
484
|
+
#
|
485
|
+
# If two or more transfer is done by HTTP redirection, the procedure
|
486
|
+
# is called only one for a last transfer.
|
487
|
+
#
|
488
|
+
# When expected content length is unknown, the procedure is called with
|
489
|
+
# nil.
|
490
|
+
# It is happen when HTTP response has no Content-Length header.
|
491
|
+
#
|
492
|
+
# [:progress_proc]
|
493
|
+
# Synopsis:
|
494
|
+
# :progress_proc => lambda {|size| ...}
|
495
|
+
#
|
496
|
+
# If :progress_proc option is specified, the proc is called with one
|
497
|
+
# argument each time when `open' gets content fragment from network.
|
498
|
+
# The argument `size' `size' is a accumulated transfered size in bytes.
|
499
|
+
#
|
500
|
+
# If two or more transfer is done by HTTP redirection, the procedure
|
501
|
+
# is called only one for a last transfer.
|
502
|
+
#
|
503
|
+
# :progress_proc and :content_length_proc are intended to be used for
|
504
|
+
# progress bar.
|
505
|
+
# For example, it can be implemented as follows using Ruby/ProgressBar.
|
506
|
+
#
|
507
|
+
# pbar = nil
|
508
|
+
# open("http://...",
|
509
|
+
# :content_length_proc => lambda {|t|
|
510
|
+
# if t && 0 < t
|
511
|
+
# pbar = ProgressBar.new("...", t)
|
512
|
+
# pbar.file_transfer_mode
|
513
|
+
# end
|
514
|
+
# },
|
515
|
+
# :progress_proc => lambda {|s|
|
516
|
+
# pbar.set s if pbar
|
517
|
+
# }) {|f| ... }
|
518
|
+
#
|
519
|
+
# OpenURI::OpenRead#open returns an IO like object if block is not given.
|
520
|
+
# Otherwise it yields the IO object and return the value of the block.
|
521
|
+
# The IO object is extended with OpenURI::Meta.
|
522
|
+
def open(*rest, &block)
|
523
|
+
OpenURI.open_uri(self, *rest, &block)
|
524
|
+
end
|
525
|
+
|
526
|
+
# OpenURI::OpenRead#read([options]) reads a content referenced by self and
|
527
|
+
# returns the content as string.
|
528
|
+
# The string is extended with OpenURI::Meta.
|
529
|
+
# The argument `options' is same as OpenURI::OpenRead#open.
|
530
|
+
def read(options={})
|
531
|
+
self.open(options) {|f|
|
532
|
+
str = f.read
|
533
|
+
Meta.init str, f
|
534
|
+
str
|
535
|
+
}
|
536
|
+
end
|
537
|
+
end
|
538
|
+
end
|
539
|
+
|
540
|
+
module URI
|
541
|
+
class Generic
|
542
|
+
# returns a proxy URI.
|
543
|
+
# The proxy URI is obtained from environment variables such as http_proxy,
|
544
|
+
# ftp_proxy, no_proxy, etc.
|
545
|
+
# If there is no proper proxy, nil is returned.
|
546
|
+
#
|
547
|
+
# Note that capitalized variables (HTTP_PROXY, FTP_PROXY, NO_PROXY, etc.)
|
548
|
+
# are examined too.
|
549
|
+
#
|
550
|
+
# But http_proxy and HTTP_PROXY is treated specially under CGI environment.
|
551
|
+
# It's because HTTP_PROXY may be set by Proxy: header.
|
552
|
+
# So HTTP_PROXY is not used.
|
553
|
+
# http_proxy is not used too if the variable is case insensitive.
|
554
|
+
# CGI_HTTP_PROXY can be used instead.
|
555
|
+
def find_proxy
|
556
|
+
name = self.scheme.downcase + '_proxy'
|
557
|
+
proxy_uri = nil
|
558
|
+
if name == 'http_proxy' && ENV.include?('REQUEST_METHOD') # CGI?
|
559
|
+
# HTTP_PROXY conflicts with *_proxy for proxy settings and
|
560
|
+
# HTTP_* for header information in CGI.
|
561
|
+
# So it should be careful to use it.
|
562
|
+
pairs = ENV.reject {|k, v| /\Ahttp_proxy\z/i !~ k }
|
563
|
+
case pairs.length
|
564
|
+
when 0 # no proxy setting anyway.
|
565
|
+
proxy_uri = nil
|
566
|
+
when 1
|
567
|
+
k, v = pairs.shift
|
568
|
+
if k == 'http_proxy' && ENV[k.upcase] == nil
|
569
|
+
# http_proxy is safe to use because ENV is case sensitive.
|
570
|
+
proxy_uri = ENV[name]
|
571
|
+
else
|
572
|
+
proxy_uri = nil
|
573
|
+
end
|
574
|
+
else # http_proxy is safe to use because ENV is case sensitive.
|
575
|
+
proxy_uri = ENV.to_hash[name]
|
576
|
+
end
|
577
|
+
if !proxy_uri
|
578
|
+
# Use CGI_HTTP_PROXY. cf. libwww-perl.
|
579
|
+
proxy_uri = ENV["CGI_#{name.upcase}"]
|
580
|
+
end
|
581
|
+
elsif name == 'http_proxy'
|
582
|
+
unless proxy_uri = ENV[name]
|
583
|
+
if proxy_uri = ENV[name.upcase]
|
584
|
+
warn 'The environment variable HTTP_PROXY is discouraged. Use http_proxy.'
|
585
|
+
end
|
586
|
+
end
|
587
|
+
else
|
588
|
+
proxy_uri = ENV[name] || ENV[name.upcase]
|
589
|
+
end
|
590
|
+
|
591
|
+
if proxy_uri && self.host
|
592
|
+
require 'socket'
|
593
|
+
begin
|
594
|
+
addr = IPSocket.getaddress(self.host)
|
595
|
+
proxy_uri = nil if /\A127\.|\A::1\z/ =~ addr
|
596
|
+
rescue SocketError
|
597
|
+
end
|
598
|
+
end
|
599
|
+
|
600
|
+
if proxy_uri
|
601
|
+
proxy_uri = URI.parse(proxy_uri)
|
602
|
+
name = 'no_proxy'
|
603
|
+
if no_proxy = ENV[name] || ENV[name.upcase]
|
604
|
+
no_proxy.scan(/([^:,]*)(?::(\d+))?/) {|host, port|
|
605
|
+
if /(\A|\.)#{Regexp.quote host}\z/i =~ self.host &&
|
606
|
+
(!port || self.port == port.to_i)
|
607
|
+
proxy_uri = nil
|
608
|
+
break
|
609
|
+
end
|
610
|
+
}
|
611
|
+
end
|
612
|
+
proxy_uri
|
613
|
+
else
|
614
|
+
nil
|
615
|
+
end
|
616
|
+
end
|
617
|
+
end
|
618
|
+
|
619
|
+
class HTTP
|
620
|
+
def buffer_open(buf, proxy, options) # :nodoc:
|
621
|
+
OpenURI.open_http(buf, self, proxy, options)
|
622
|
+
end
|
623
|
+
|
624
|
+
include OpenURI::OpenRead
|
625
|
+
end
|
626
|
+
|
627
|
+
class FTP
|
628
|
+
def buffer_open(buf, proxy, options) # :nodoc:
|
629
|
+
if proxy
|
630
|
+
OpenURI.open_http(buf, self, proxy, options)
|
631
|
+
return
|
632
|
+
end
|
633
|
+
require 'net/ftp'
|
634
|
+
|
635
|
+
directories = self.path.split(%r{/}, -1)
|
636
|
+
directories.shift if directories[0] == '' # strip a field before leading slash
|
637
|
+
directories.each {|d|
|
638
|
+
d.gsub!(/%([0-9A-Fa-f][0-9A-Fa-f])/) { [$1].pack("H2") }
|
639
|
+
}
|
640
|
+
unless filename = directories.pop
|
641
|
+
raise ArgumentError, "no filename: #{self.inspect}"
|
642
|
+
end
|
643
|
+
directories.each {|d|
|
644
|
+
if /[\r\n]/ =~ d
|
645
|
+
raise ArgumentError, "invalid directory: #{d.inspect}"
|
646
|
+
end
|
647
|
+
}
|
648
|
+
if /[\r\n]/ =~ filename
|
649
|
+
raise ArgumentError, "invalid filename: #{filename.inspect}"
|
650
|
+
end
|
651
|
+
typecode = self.typecode
|
652
|
+
if typecode && /\A[aid]\z/ !~ typecode
|
653
|
+
raise ArgumentError, "invalid typecode: #{typecode.inspect}"
|
654
|
+
end
|
655
|
+
|
656
|
+
# The access sequence is defined by RFC 1738
|
657
|
+
ftp = Net::FTP.open(self.host)
|
658
|
+
# todo: extract user/passwd from .netrc.
|
659
|
+
user = 'anonymous'
|
660
|
+
passwd = nil
|
661
|
+
user, passwd = self.userinfo.split(/:/) if self.userinfo
|
662
|
+
ftp.login(user, passwd)
|
663
|
+
directories.each {|cwd|
|
664
|
+
ftp.voidcmd("CWD #{cwd}")
|
665
|
+
}
|
666
|
+
if typecode
|
667
|
+
# xxx: typecode D is not handled.
|
668
|
+
ftp.voidcmd("TYPE #{typecode.upcase}")
|
669
|
+
end
|
670
|
+
if options[:content_length_proc]
|
671
|
+
options[:content_length_proc].call(ftp.size(filename))
|
672
|
+
end
|
673
|
+
ftp.retrbinary("RETR #{filename}", 4096) { |str|
|
674
|
+
buf << str
|
675
|
+
options[:progress_proc].call(buf.size) if options[:progress_proc]
|
676
|
+
}
|
677
|
+
ftp.close
|
678
|
+
buf.io.rewind
|
679
|
+
end
|
680
|
+
|
681
|
+
include OpenURI::OpenRead
|
682
|
+
end
|
683
|
+
end
|