Better flood control
[rbot] / lib / rbot / httputil.rb
1 module Irc
2 module Utils
3
4 require 'resolv'
5 require 'net/http'
6 require 'net/https'
7 Net::HTTP.version_1_2
8
9 # class for making http requests easier (mainly for plugins to use)
10 # this class can check the bot proxy configuration to determine if a proxy
11 # needs to be used, which includes support for per-url proxy configuration.
12 class HttpUtil
13     BotConfig.register BotConfigBooleanValue.new('http.use_proxy',
14       :default => false, :desc => "should a proxy be used for HTTP requests?")
15     BotConfig.register BotConfigStringValue.new('http.proxy_uri', :default => false,
16       :desc => "Proxy server to use for HTTP requests (URI, e.g http://proxy.host:port)")
17     BotConfig.register BotConfigStringValue.new('http.proxy_user',
18       :default => nil,
19       :desc => "User for authenticating with the http proxy (if required)")
20     BotConfig.register BotConfigStringValue.new('http.proxy_pass',
21       :default => nil,
22       :desc => "Password for authenticating with the http proxy (if required)")
23     BotConfig.register BotConfigArrayValue.new('http.proxy_include',
24       :default => [],
25       :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")
26     BotConfig.register BotConfigArrayValue.new('http.proxy_exclude',
27       :default => [],
28       :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")
29     BotConfig.register BotConfigIntegerValue.new('http.max_redir',
30       :default => 5,
31       :desc => "Maximum number of redirections to be used when getting a document")
32     BotConfig.register BotConfigIntegerValue.new('http.expire_time',
33       :default => 60,
34       :desc => "After how many minutes since last use a cached document is considered to be expired")
35     BotConfig.register BotConfigIntegerValue.new('http.max_cache_time',
36       :default => 60*24,
37       :desc => "After how many minutes since first use a cached document is considered to be expired")
38     BotConfig.register BotConfigIntegerValue.new('http.no_expire_cache',
39       :default => false,
40       :desc => "Set this to true if you want the bot to never expire the cached pages")
41
42   def initialize(bot)
43     @bot = bot
44     @cache = Hash.new
45     @headers = {
46       'User-Agent' => "rbot http util #{$version} (http://linuxbrit.co.uk/rbot/)",
47     }
48     @last_response = nil
49   end
50   attr_reader :last_response
51   attr_reader :headers
52
53   # if http_proxy_include or http_proxy_exclude are set, then examine the
54   # uri to see if this is a proxied uri
55   # the in/excludes are a list of regexps, and each regexp is checked against
56   # the server name, and its IP addresses
57   def proxy_required(uri)
58     use_proxy = true
59     if @bot.config["http.proxy_exclude"].empty? && @bot.config["http.proxy_include"].empty?
60       return use_proxy
61     end
62
63     list = [uri.host]
64     begin
65       list.concat Resolv.getaddresses(uri.host)
66     rescue StandardError => err
67       warning "couldn't resolve host uri.host"
68     end
69
70     unless @bot.config["http.proxy_exclude"].empty?
71       re = @bot.config["http.proxy_exclude"].collect{|r| Regexp.new(r)}
72       re.each do |r|
73         list.each do |item|
74           if r.match(item)
75             use_proxy = false
76             break
77           end
78         end
79       end
80     end
81     unless @bot.config["http.proxy_include"].empty?
82       re = @bot.config["http.proxy_include"].collect{|r| Regexp.new(r)}
83       re.each do |r|
84         list.each do |item|
85           if r.match(item)
86             use_proxy = true
87             break
88           end
89         end
90       end
91     end
92     debug "using proxy for uri #{uri}?: #{use_proxy}"
93     return use_proxy
94   end
95
96   # uri:: Uri to create a proxy for
97   #
98   # return a net/http Proxy object, which is configured correctly for
99   # proxying based on the bot's proxy configuration.
100   # This will include per-url proxy configuration based on the bot config
101   # +http_proxy_include/exclude+ options.
102   def get_proxy(uri)
103     proxy = nil
104     proxy_host = nil
105     proxy_port = nil
106     proxy_user = nil
107     proxy_pass = nil
108
109     if @bot.config["http.use_proxy"]
110       if (ENV['http_proxy'])
111         proxy = URI.parse ENV['http_proxy'] rescue nil
112       end
113       if (@bot.config["http.proxy_uri"])
114         proxy = URI.parse @bot.config["http.proxy_uri"] rescue nil
115       end
116       if proxy
117         debug "proxy is set to #{proxy.host} port #{proxy.port}"
118         if proxy_required(uri)
119           proxy_host = proxy.host
120           proxy_port = proxy.port
121           proxy_user = @bot.config["http.proxy_user"]
122           proxy_pass = @bot.config["http.proxy_pass"]
123         end
124       end
125     end
126
127     h = Net::HTTP.new(uri.host, uri.port, proxy_host, proxy_port, proxy_user, proxy_port)
128     h.use_ssl = true if uri.scheme == "https"
129     return h
130   end
131
132   # uri::         uri to query (Uri object)
133   # readtimeout:: timeout for reading the response
134   # opentimeout:: timeout for opening the connection
135   #
136   # simple get request, returns (if possible) response body following redirs
137   # and caching if requested
138   # if a block is given, it yields the urls it gets redirected to
139   # TODO we really need something to implement proper caching
140   def get(uri_or_str, readtimeout=10, opentimeout=5, max_redir=@bot.config["http.max_redir"], cache=false)
141     if uri_or_str.kind_of?(URI)
142       uri = uri_or_str
143     else
144       uri = URI.parse(uri_or_str.to_s)
145     end
146
147     proxy = get_proxy(uri)
148     proxy.open_timeout = opentimeout
149     proxy.read_timeout = readtimeout
150
151     begin
152       proxy.start() {|http|
153         yield uri.request_uri() if block_given?
154         resp = http.get(uri.request_uri(), @headers)
155         case resp
156         when Net::HTTPSuccess
157           if cache && !(resp.key?('cache-control') && resp['cache-control']=='must-revalidate')
158             k = uri.to_s
159             @cache[k] = Hash.new
160             @cache[k][:body] = resp.body
161             @cache[k][:last_mod] = Time.httpdate(resp['last-modified']) if resp.key?('last-modified')
162             if resp.key?('date')
163               @cache[k][:first_use] = Time.httpdate(resp['date'])
164               @cache[k][:last_use] = Time.httpdate(resp['date'])
165             else
166               now = Time.new
167               @cache[k][:first_use] = now
168               @cache[k][:last_use] = now
169             end
170             @cache[k][:count] = 1
171           end
172           return resp.body
173         when Net::HTTPRedirection
174           debug "Redirecting #{uri} to #{resp['location']}"
175           yield resp['location'] if block_given?
176           if max_redir > 0
177             return get( URI.parse(resp['location']), readtimeout, opentimeout, max_redir-1, cache)
178           else
179             warning "Max redirection reached, not going to #{resp['location']}"
180           end
181         else
182           debug "HttpUtil.get return code #{resp.code} #{resp.body}"
183         end
184         @last_response = resp
185         return nil
186       }
187     rescue StandardError, Timeout::Error => e
188       error "HttpUtil.get exception: #{e.inspect}, while trying to get #{uri}"
189       debug e.backtrace.join("\n")
190     end
191     @last_response = nil
192     return nil
193   end
194
195   # just like the above, but only gets the head
196   def head(uri_or_str, readtimeout=10, opentimeout=5, max_redir=@bot.config["http.max_redir"])
197     if uri_or_str.kind_of?(URI)
198       uri = uri_or_str
199     else
200       uri = URI.parse(uri_or_str.to_s)
201     end
202
203     proxy = get_proxy(uri)
204     proxy.open_timeout = opentimeout
205     proxy.read_timeout = readtimeout
206
207     begin
208       proxy.start() {|http|
209         yield uri.request_uri() if block_given?
210         resp = http.request_head(uri.request_uri(), @headers)
211         case resp
212         when Net::HTTPSuccess
213           return resp
214         when Net::HTTPRedirection
215           debug "Redirecting #{uri} to #{resp['location']}"
216           yield resp['location'] if block_given?
217           if max_redir > 0
218             return head( URI.parse(resp['location']), readtimeout, opentimeout, max_redir-1)
219           else
220             warning "Max redirection reached, not going to #{resp['location']}"
221           end
222         else
223           debug "HttpUtil.head return code #{resp.code}"
224         end
225         @last_response = resp
226         return nil
227       }
228     rescue StandardError, Timeout::Error => e
229       error "HttpUtil.head exception: #{e.inspect}, while trying to get #{uri}"
230       debug e.backtrace.join("\n")
231     end
232     @last_response = nil
233     return nil
234   end
235
236   # gets a page from the cache if it's still (assumed to be) valid
237   # TODO remove stale cached pages, except when called with noexpire=true
238   def get_cached(uri_or_str, readtimeout=10, opentimeout=5,
239                  max_redir=@bot.config['http.max_redir'],
240                  noexpire=@bot.config['http.no_expire_cache'])
241     if uri_or_str.kind_of?(URI)
242       uri = uri_or_str
243     else
244       uri = URI.parse(uri_or_str.to_s)
245     end
246
247     k = uri.to_s
248     if !@cache.key?(k)
249       remove_stale_cache unless noexpire
250       return get(uri, readtimeout, opentimeout, max_redir, true)
251     end
252     now = Time.new
253     begin
254       # See if the last-modified header can be used
255       # Assumption: the page was not modified if both the header
256       # and the cached copy have the last-modified value, and it's the same time
257       # If only one of the cached copy and the header have the value, or if the
258       # value is different, we assume that the cached copyis invalid and therefore
259       # get a new one.
260       # On our first try, we tested for last-modified in the webpage first,
261       # and then on the local cache. however, this is stupid (in general),
262       # so we only test for the remote page if the local copy had the header
263       # in the first place.
264       if @cache[k].key?(:last_mod)
265         h = head(uri, readtimeout, opentimeout, max_redir)
266         if h.key?('last-modified')
267           if Time.httpdate(h['last-modified']) == @cache[k][:last_mod]
268             if h.key?('date')
269               @cache[k][:last_use] = Time.httpdate(h['date'])
270             else
271               @cache[k][:last_use] = now
272             end
273             @cache[k][:count] += 1
274             return @cache[k][:body]
275           end
276           remove_stale_cache unless noexpire
277           return get(uri, readtimeout, opentimeout, max_redir, true)
278         end
279         remove_stale_cache unless noexpire
280         return get(uri, readtimeout, opentimeout, max_redir, true)
281       end
282     rescue => e
283       warning "Error #{e.inspect} getting the page #{uri}, using cache"
284       debug e.backtrace.join("\n")
285       return @cache[k][:body]
286     end
287     # If we still haven't returned, we are dealing with a non-redirected document
288     # that doesn't have the last-modified attribute
289     debug "Could not use last-modified attribute for URL #{uri}, guessing cache validity"
290     if noexpire or !expired?(@cache[k], now)
291       @cache[k][:count] += 1
292       @cache[k][:last_use] = now
293       debug "Using cache"
294       return @cache[k][:body]
295     end
296     debug "Cache expired, getting anew"
297     @cache.delete(k)
298     remove_stale_cache unless noexpire
299     return get(uri, readtimeout, opentimeout, max_redir, true)
300   end
301
302   def expired?(hash, time)
303     (time - hash[:last_use] > @bot.config['http.expire_time']*60) or
304     (time - hash[:first_use] > @bot.config['http.max_cache_time']*60)
305   end
306
307   def remove_stale_cache
308     now = Time.new
309     @cache.reject! { |k, val|
310       !val.key?(:last_modified) && expired?(val, now)
311     }
312   end
313
314 end
315 end
316 end