]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/core/utils/httputil.rb
Stringlib/rbot/core/utils/extends.rbutfy_xml() method that tries to transcode a webpa...
[user/henk/code/ruby/rbot.git] / lib / rbot / core / utils / httputil.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: rbot HTTP provider
5 #
6 # Author:: Tom Gilbert <tom@linuxbrit.co.uk>
7 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
8 # Author:: Dmitry "jsn" Kim <dmitry point kim at gmail point com>
9 #
10 # Copyright:: (C) 2002-2005 Tom Gilbert
11 # Copyright:: (C) 2006 Tom Gilbert, Giuseppe Bilotta
12 # Copyright:: (C) 2007 Giuseppe Bilotta, Dmitry Kim
13
14 require 'resolv'
15 require 'net/http'
16 begin
17   require 'net/https'
18 rescue LoadError => e
19   error "Couldn't load 'net/https':  #{e.inspect}"
20   error "Secured HTTP connections will fail"
21 end
22
23 module ::Net 
24   class HTTPResponse 
25     # Read chunks from the body until we have at least _size_ bytes, yielding 
26     # the partial text at each chunk. Return the partial body. 
27     def partial_body(size=0, &block) 
28
29       partial = String.new 
30
31       self.read_body { |chunk| 
32         partial << chunk 
33         yield partial if block_given? 
34         break if size and size > 0 and partial.length >= size 
35       } 
36
37       return partial 
38     end 
39   end 
40 end
41
42 Net::HTTP.version_1_2
43
44 module ::Irc
45 module Utils
46
47 # class for making http requests easier (mainly for plugins to use)
48 # this class can check the bot proxy configuration to determine if a proxy
49 # needs to be used, which includes support for per-url proxy configuration.
50 class HttpUtil
51     BotConfig.register BotConfigBooleanValue.new('http.use_proxy',
52       :default => false, :desc => "should a proxy be used for HTTP requests?")
53     BotConfig.register BotConfigStringValue.new('http.proxy_uri', :default => false,
54       :desc => "Proxy server to use for HTTP requests (URI, e.g http://proxy.host:port)")
55     BotConfig.register BotConfigStringValue.new('http.proxy_user',
56       :default => nil,
57       :desc => "User for authenticating with the http proxy (if required)")
58     BotConfig.register BotConfigStringValue.new('http.proxy_pass',
59       :default => nil,
60       :desc => "Password for authenticating with the http proxy (if required)")
61     BotConfig.register BotConfigArrayValue.new('http.proxy_include',
62       :default => [],
63       :desc => "List of regexps to check against a URI's hostname/ip to see if we should use the proxy to access this URI. All URIs are proxied by default if the proxy is set, so this is only required to re-include URIs that might have been excluded by the exclude list. e.g. exclude /.*\.foo\.com/, include bar\.foo\.com")
64     BotConfig.register BotConfigArrayValue.new('http.proxy_exclude',
65       :default => [],
66       :desc => "List of regexps to check against a URI's hostname/ip to see if we should use avoid the proxy to access this URI and access it directly")
67     BotConfig.register BotConfigIntegerValue.new('http.max_redir',
68       :default => 5,
69       :desc => "Maximum number of redirections to be used when getting a document")
70     BotConfig.register BotConfigIntegerValue.new('http.expire_time',
71       :default => 60,
72       :desc => "After how many minutes since last use a cached document is considered to be expired")
73     BotConfig.register BotConfigIntegerValue.new('http.max_cache_time',
74       :default => 60*24,
75       :desc => "After how many minutes since first use a cached document is considered to be expired")
76     BotConfig.register BotConfigIntegerValue.new('http.no_expire_cache',
77       :default => false,
78       :desc => "Set this to true if you want the bot to never expire the cached pages")
79     BotConfig.register BotConfigIntegerValue.new('http.info_bytes',
80       :default => 8192,
81       :desc => "How many bytes to download from a web page to find some information. Set to 0 to let the bot download the whole page.")
82
83   class CachedObject
84     attr_accessor :response, :last_used, :first_used, :count, :expires, :date
85
86     def self.maybe_new(resp)
87       debug "maybe new #{resp}"
88       return nil unless Net::HTTPOK === resp ||
89       Net::HTTPMovedPermanently === resp ||
90       Net::HTTPFound === resp ||
91       Net::HTTPPartialContent === resp
92
93       cc = resp['cache-control']
94       return nil if cc && (cc =~ /no-cache/i)
95
96       date = Time.now
97       if d = resp['date']
98         date = Time.httpdate(d)
99       end
100
101       return nil if resp['expires'] && (Time.httpdate(resp['expires']) < date)
102
103       debug "creating cache obj"
104
105       self.new(resp)
106     end
107
108     def use
109       now = Time.now
110       @first_used = now if @count == 0
111       @last_used = now
112       @count += 1
113     end
114
115     def expired?
116       debug "checking expired?"
117       if cc = self.response['cache-control'] && cc =~ /must-revalidate/
118         return true
119       end
120       return self.expires < Time.now
121     end
122
123     def setup_headers(hdr)
124       hdr['if-modified-since'] = self.date.rfc2822
125
126       debug "ims == #{hdr['if-modified-since']}"
127
128       if etag = self.response['etag']
129         hdr['if-none-match'] = etag
130         debug "etag: #{etag}"
131       end
132     end
133
134     def revalidate(resp = self.response)
135       @count = 0
136       self.use
137       self.date = resp.key?('date') ? Time.httpdate(resp['date']) : Time.now
138
139       cc = resp['cache-control']
140       if cc && (cc =~ /max-age=(\d+)/)
141         self.expires = self.date + $1.to_i
142       elsif resp.key?('expires')
143         self.expires = Time.httpdate(resp['expires'])
144       elsif lm = resp['last-modified']
145         delta = self.date - Time.httpdate(lm)
146         delta = 10 if delta <= 0
147         delta /= 5
148         self.expires = self.date + delta
149       else
150         self.expires = self.date + 300
151       end
152       # self.expires = Time.now + 10 # DEBUG
153       debug "expires on #{self.expires}"
154
155       return true
156     end
157
158     private
159     def initialize(resp)
160       @response = resp
161       begin
162         self.revalidate
163         self.response.body
164       rescue Exception => e
165         error e.message
166         error e.backtrace.join("\n")
167         raise e
168       end
169     end
170   end
171
172   def initialize(bot)
173     @bot = bot
174     @cache = Hash.new
175     @headers = {
176       'Accept-Charset' => 'utf-8;q=1.0, *;q=0.8',
177       'User-Agent' =>
178         "rbot http util #{$version} (http://linuxbrit.co.uk/rbot/)"
179     } 
180     debug "starting http cache cleanup timer"
181     @timer = @bot.timer.add(300) {
182       self.remove_stale_cache unless @bot.config['http.no_expire_cache']
183     }
184   end 
185
186   def cleanup
187     debug 'stopping http cache cleanup timer'
188     @bot.timer.remove(@timer)
189   end
190
191   # if http_proxy_include or http_proxy_exclude are set, then examine the
192   # uri to see if this is a proxied uri
193   # the in/excludes are a list of regexps, and each regexp is checked against
194   # the server name, and its IP addresses
195   def proxy_required(uri)
196     use_proxy = true
197     if @bot.config["http.proxy_exclude"].empty? && @bot.config["http.proxy_include"].empty?
198       return use_proxy
199     end
200
201     list = [uri.host]
202     begin
203       list.concat Resolv.getaddresses(uri.host)
204     rescue StandardError => err
205       warning "couldn't resolve host uri.host"
206     end
207
208     unless @bot.config["http.proxy_exclude"].empty?
209       re = @bot.config["http.proxy_exclude"].collect{|r| Regexp.new(r)}
210       re.each do |r|
211         list.each do |item|
212           if r.match(item)
213             use_proxy = false
214             break
215           end
216         end
217       end
218     end
219     unless @bot.config["http.proxy_include"].empty?
220       re = @bot.config["http.proxy_include"].collect{|r| Regexp.new(r)}
221       re.each do |r|
222         list.each do |item|
223           if r.match(item)
224             use_proxy = true
225             break
226           end
227         end
228       end
229     end
230     debug "using proxy for uri #{uri}?: #{use_proxy}"
231     return use_proxy
232   end
233
234   # uri:: Uri to create a proxy for
235   #
236   # return a net/http Proxy object, which is configured correctly for
237   # proxying based on the bot's proxy configuration.
238   # This will include per-url proxy configuration based on the bot config
239   # +http_proxy_include/exclude+ options.
240   
241   def get_proxy(uri, options = {})
242     opts = {
243       :read_timeout => 10,
244       :open_timeout => 5
245     }.merge(options)
246
247     proxy = nil
248     proxy_host = nil
249     proxy_port = nil
250     proxy_user = nil
251     proxy_pass = nil
252
253     if @bot.config["http.use_proxy"]
254       if (ENV['http_proxy'])
255         proxy = URI.parse ENV['http_proxy'] rescue nil
256       end
257       if (@bot.config["http.proxy_uri"])
258         proxy = URI.parse @bot.config["http.proxy_uri"] rescue nil
259       end
260       if proxy
261         debug "proxy is set to #{proxy.host} port #{proxy.port}"
262         if proxy_required(uri)
263           proxy_host = proxy.host
264           proxy_port = proxy.port
265           proxy_user = @bot.config["http.proxy_user"]
266           proxy_pass = @bot.config["http.proxy_pass"]
267         end
268       end
269     end
270
271     h = Net::HTTP.new(uri.host, uri.port, proxy_host, proxy_port, proxy_user, proxy_port)
272     h.use_ssl = true if uri.scheme == "https"
273
274     h.read_timeout = opts[:read_timeout]
275     h.open_timeout = opts[:open_timeout]
276     return h
277   end
278
279   def handle_response(uri, resp, opts, &block)
280     if Net::HTTPRedirection === resp && opts[:max_redir] >= 0
281       if resp.key?('location')
282         raise 'Too many redirections' if opts[:max_redir] <= 0
283         yield resp if opts[:yield] == :all && block_given?
284         loc = resp['location']
285         new_loc = URI.join(uri.to_s, loc) rescue URI.parse(loc)
286         new_opts = opts.dup
287         new_opts[:max_redir] -= 1
288         case opts[:method].to_s.downcase.intern
289         when :post, :"net::http::post"
290           new_opts[:method] = :get
291         end
292         debug "following the redirect to #{new_loc}"
293         return get_response(new_loc, new_opts, &block)
294       else
295         warning ":| redirect w/o location?"
296       end
297     end
298     if block_given?
299       yield(resp)
300     else
301       resp.body
302     end
303
304     class << resp.body
305       def http_headers
306         if defined?(@http_headers)
307           @http_headers
308         else
309           nil
310         end
311       end
312
313       def http_headers=(rsp)
314         @http_headers=rsp
315       end
316     end
317
318     resp.body.http_headers = resp.to_hash
319
320     return resp
321   end
322
323   # uri::         uri to query (Uri object or String)
324   # opts::        options. Currently used:
325   # :method::     request method [:get (default), :post or :head]
326   # :open_timeout::     open timeout for the proxy
327   # :read_timeout::     read timeout for the proxy
328   # :cache::            should we cache results?
329   # :yield::      if :final [default], call &block for the response object
330   #               if :all, call &block for all intermediate redirects, too
331   # :max_redir::  how many redirects to follow before raising the exception
332   #               if -1, don't follow redirects, just return them
333   # :range::      make a ranged request (usually GET). accepts a string
334   #               for HTTP/1.1 "Range:" header (i.e. "bytes=0-1000")
335   # :body::       request body (usually for POST requests)
336   #
337   # Generic http transaction method
338   #
339   # It will return a HTTP::Response object or raise an exception
340   #
341   # If a block is given, it will yield the response (see :yield option)
342
343   def get_response(uri_or_s, options = {}, &block)
344     uri = uri_or_s.kind_of?(URI) ? uri_or_s : URI.parse(uri_or_s.to_s)
345     opts = {
346       :max_redir => @bot.config['http.max_redir'],
347       :yield => :final,
348       :cache => true,
349       :method => :GET
350     }.merge(options)
351
352     resp = nil
353     cached = nil
354
355     req_class = case opts[:method].to_s.downcase.intern
356                 when :head, :"net::http::head"
357                   opts[:max_redir] = -1
358                   Net::HTTP::Head
359                 when :get, :"net::http::get"
360                   Net::HTTP::Get
361                 when :post, :"net::http::post"
362                   opts[:cache] = false
363                   opts[:body] or raise 'post request w/o a body?'
364                   warning "refusing to cache POST request" if options[:cache]
365                   Net::HTTP::Post
366                 else
367                   warning "unsupported method #{opts[:method]}, doing GET"
368                   Net::HTTP::Get
369                 end
370
371     if req_class != Net::HTTP::Get && opts[:range]
372       warning "can't request ranges for #{req_class}"
373       opts.delete(:range)
374     end
375
376     cache_key = "#{opts[:range]}|#{req_class}|#{uri.to_s}"
377
378     if req_class != Net::HTTP::Get && req_class != Net::HTTP::Head
379       if opts[:cache]
380         warning "can't cache #{req_class.inspect} requests, working w/o cache"
381         opts[:cache] = false
382       end
383     end
384
385     debug "get_response(#{uri}, #{opts.inspect})"
386
387     if opts[:cache] && cached = @cache[cache_key]
388       debug "got cached"
389       if !cached.expired?
390         debug "using cached"
391         cached.use
392         return handle_response(uri, cached.response, opts, &block)
393       end
394     end
395     
396     headers = @headers.dup.merge(opts[:headers] || {})
397     headers['Range'] = opts[:range] if opts[:range]
398
399     cached.setup_headers(headers) if cached && (req_class == Net::HTTP::Get)
400     req = req_class.new(uri.request_uri, headers)
401     req.basic_auth(uri.user, uri.password) if uri.user && uri.password
402     req.body = opts[:body] if req_class == Net::HTTP::Post
403     debug "prepared request: #{req.to_hash.inspect}"
404
405     get_proxy(uri, opts).start do |http|
406       http.request(req) do |resp|
407         if Net::HTTPNotModified === resp
408           debug "not modified"
409           begin
410             cached.revalidate(resp)
411           rescue Exception => e
412             error e.message
413             error e.backtrace.join("\n")
414           end
415           debug "reusing cached"
416           resp = cached.response
417         elsif Net::HTTPServerError === resp || Net::HTTPClientError === resp
418           debug "http error, deleting cached obj" if cached
419           @cache.delete(cache_key)
420         elsif opts[:cache] && cached = CachedObject.maybe_new(resp) rescue nil
421           debug "storing to cache"
422           @cache[cache_key] = cached
423         end
424         return handle_response(uri, resp, opts, &block)
425       end
426     end
427   end
428
429   # uri::         uri to query (Uri object)
430   #
431   # simple get request, returns (if possible) response body following redirs
432   # and caching if requested
433   def get(uri, opts = {}, &block)
434     begin
435       resp = get_response(uri, opts, &block)
436       raise "http error: #{resp}" unless Net::HTTPOK === resp ||
437         Net::HTTPPartialContent === resp
438       return resp.body
439     rescue Exception => e
440       error e.message
441       error e.backtrace.join("\n")
442     end
443     return nil
444   end
445
446   def head(uri, options = {}, &block)
447     opts = {:method => :head}.merge(options)
448     begin
449       resp = get_response(uri, opts, &block)
450       raise "http error #{resp}" if Net::HTTPClientError === resp ||
451         Net::HTTPServerError == resp
452       return resp
453     rescue Exception => e
454       error e.message
455       error e.backtrace.join("\n")
456     end
457     return nil
458   end
459
460   def post(uri, data, options = {}, &block)
461     opts = {:method => :post, :body => data, :cache => false}.merge(options)
462     begin
463       resp = get_response(uri, opts, &block)
464       raise 'http error' unless Net::HTTPOK === resp
465       return resp
466     rescue Exception => e
467       error e.message
468       error e.backtrace.join("\n")
469     end
470     return nil
471   end
472
473   def get_partial(uri, nbytes = @bot.config['http.info_bytes'], options = {}, &block)
474     opts = {:range => "bytes=0-#{nbytes}"}.merge(options)
475     return get(uri, opts, &block)
476   end
477
478   def remove_stale_cache
479     debug "Removing stale cache"
480     now = Time.new
481     max_last = @bot.config['http.expire_time'] * 60
482     max_first = @bot.config['http.max_cache_time'] * 60
483     debug "#{@cache.size} pages before"
484     begin
485       @cache.reject! { |k, val|
486         (now - val.last_used > max_last) || (now - val.first_used > max_first)
487       }
488     rescue => e
489       error "Failed to remove stale cache: #{e.inspect}"
490     end
491     debug "#{@cache.size} pages after"
492   end
493
494 end
495 end
496 end
497
498 class HttpUtilPlugin < CoreBotModule
499   def initialize(*a)
500     super(*a)
501     debug 'initializing httputil'
502     @bot.httputil = Irc::Utils::HttpUtil.new(@bot)
503   end
504
505   def cleanup
506     debug 'shutting down httputil'
507     @bot.httputil.cleanup
508     @bot.httputil = nil
509   end
510 end
511
512 HttpUtilPlugin.new