]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/core/utils/httputil.rb
httputil: reinstate partial_body
[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     return resp
305   end
306
307   # uri::         uri to query (Uri object or String)
308   # opts::        options. Currently used:
309   # :method::     request method [:get (default), :post or :head]
310   # :open_timeout::     open timeout for the proxy
311   # :read_timeout::     read timeout for the proxy
312   # :cache::            should we cache results?
313   # :yield::      if :final [default], call &block for the response object
314   #               if :all, call &block for all intermediate redirects, too
315   # :max_redir::  how many redirects to follow before raising the exception
316   #               if -1, don't follow redirects, just return them
317   # :range::      make a ranged request (usually GET). accepts a string
318   #               for HTTP/1.1 "Range:" header (i.e. "bytes=0-1000")
319   # :body::       request body (usually for POST requests)
320   #
321   # Generic http transaction method
322   #
323   # It will return a HTTP::Response object or raise an exception
324   #
325   # If a block is given, it will yield the response (see :yield option)
326
327   def get_response(uri_or_s, options = {}, &block)
328     uri = uri_or_s.kind_of?(URI) ? uri_or_s : URI.parse(uri_or_s.to_s)
329     opts = {
330       :max_redir => @bot.config['http.max_redir'],
331       :yield => :final,
332       :cache => true,
333       :method => :GET
334     }.merge(options)
335
336     resp = nil
337     cached = nil
338
339     req_class = case opts[:method].to_s.downcase.intern
340                 when :head, :"net::http::head"
341                   opts[:max_redir] = -1
342                   Net::HTTP::Head
343                 when :get, :"net::http::get"
344                   Net::HTTP::Get
345                 when :post, :"net::http::post"
346                   opts[:cache] = false
347                   opts[:body] or raise 'post request w/o a body?'
348                   warning "refusing to cache POST request" if options[:cache]
349                   Net::HTTP::Post
350                 else
351                   warning "unsupported method #{opts[:method]}, doing GET"
352                   Net::HTTP::Get
353                 end
354
355     if req_class != Net::HTTP::Get && opts[:range]
356       warning "can't request ranges for #{req_class}"
357       opts.delete(:range)
358     end
359
360     cache_key = "#{opts[:range]}|#{req_class}|#{uri.to_s}"
361
362     if req_class != Net::HTTP::Get && req_class != Net::HTTP::Head
363       if opts[:cache]
364         warning "can't cache #{req_class.inspect} requests, working w/o cache"
365         opts[:cache] = false
366       end
367     end
368
369     debug "get_response(#{uri}, #{opts.inspect})"
370
371     if opts[:cache] && cached = @cache[cache_key]
372       debug "got cached"
373       if !cached.expired?
374         debug "using cached"
375         cached.use
376         return handle_response(uri, cached.response, opts, &block)
377       end
378     end
379     
380     headers = @headers.dup.merge(opts[:headers] || {})
381     headers['Range'] = opts[:range] if opts[:range]
382
383     cached.setup_headers(headers) if cached && (req_class == Net::HTTP::Get)
384     req = req_class.new(uri.request_uri, headers)
385     req.basic_auth(uri.user, uri.password) if uri.user && uri.password
386     req.body = opts[:body] if req_class == Net::HTTP::Post
387     debug "prepared request: #{req.to_hash.inspect}"
388
389     get_proxy(uri, opts).start do |http|
390       http.request(req) do |resp|
391         if Net::HTTPNotModified === resp
392           debug "not modified"
393           begin
394             cached.revalidate(resp)
395           rescue Exception => e
396             error e.message
397             error e.backtrace.join("\n")
398           end
399           debug "reusing cached"
400           resp = cached.response
401         elsif Net::HTTPServerError === resp || Net::HTTPClientError === resp
402           debug "http error, deleting cached obj" if cached
403           @cache.delete(cache_key)
404         elsif opts[:cache] && cached = CachedObject.maybe_new(resp) rescue nil
405           debug "storing to cache"
406           @cache[cache_key] = cached
407         end
408         return handle_response(uri, resp, opts, &block)
409       end
410     end
411   end
412
413   # uri::         uri to query (Uri object)
414   #
415   # simple get request, returns (if possible) response body following redirs
416   # and caching if requested
417   def get(uri, opts = {}, &block)
418     begin
419       resp = get_response(uri, opts, &block)
420       raise "http error: #{resp}" unless Net::HTTPOK === resp ||
421         Net::HTTPPartialContent === resp
422       return resp.body
423     rescue Exception => e
424       error e.message
425       error e.backtrace.join("\n")
426     end
427     return nil
428   end
429
430   def head(uri, options = {}, &block)
431     opts = {:method => :head}.merge(options)
432     begin
433       resp = get_response(uri, opts, &block)
434       raise "http error #{resp}" if Net::HTTPClientError === resp ||
435         Net::HTTPServerError == resp
436       return resp
437     rescue Exception => e
438       error e.message
439       error e.backtrace.join("\n")
440     end
441     return nil
442   end
443
444   def post(uri, data, options = {}, &block)
445     opts = {:method => :post, :body => data, :cache => false}.merge(options)
446     begin
447       resp = get_response(uri, opts, &block)
448       raise 'http error' unless Net::HTTPOK === resp
449       return resp
450     rescue Exception => e
451       error e.message
452       error e.backtrace.join("\n")
453     end
454     return nil
455   end
456
457   def get_partial(uri, nbytes = @bot.config['http.info_bytes'], options = {}, &block)
458     opts = {:range => "bytes=0-#{nbytes}"}.merge(options)
459     return get(uri, opts, &block)
460   end
461
462   def remove_stale_cache
463     debug "Removing stale cache"
464     now = Time.new
465     max_last = @bot.config['http.expire_time'] * 60
466     max_first = @bot.config['http.max_cache_time'] * 60
467     debug "#{@cache.size} pages before"
468     begin
469       @cache.reject! { |k, val|
470         (now - val.last_used > max_last) || (now - val.first_used > max_first)
471       }
472     rescue => e
473       error "Failed to remove stale cache: #{e.inspect}"
474     end
475     debug "#{@cache.size} pages after"
476   end
477
478 end
479 end
480 end
481
482 class HttpUtilPlugin < CoreBotModule
483   def initialize(*a)
484     super(*a)
485     debug 'initializing httputil'
486     @bot.httputil = Irc::Utils::HttpUtil.new(@bot)
487   end
488
489   def cleanup
490     debug 'shutting down httputil'
491     @bot.httputil.cleanup
492     @bot.httputil = nil
493   end
494 end
495
496 HttpUtilPlugin.new