diff options
-rw-r--r-- | lib/rbot/core/webservice.rb | 375 |
1 files changed, 319 insertions, 56 deletions
diff --git a/lib/rbot/core/webservice.rb b/lib/rbot/core/webservice.rb index 26465285..42365a6a 100644 --- a/lib/rbot/core/webservice.rb +++ b/lib/rbot/core/webservice.rb @@ -18,6 +18,254 @@ require 'openssl' require 'cgi' require 'json' +module ::Irc +class Bot + # A WebMessage is a web request and response object combined with helper methods. + # + class WebMessage + attr_reader :bot, :method, :bot, :req, :res, :post, :client, :path, :source + def initialize(bot, req, res) + @bot = bot + @req = req + @res = res + + @method = req.request_method + if req.body and not req.body.empty? + @post = CGI::parse(req.body) + end + @client = req.peeraddr[3] + + # login a botuser with http authentication + WEBrick::HTTPAuth.basic_auth(req, res, 'RBotAuth') { |username, password| + if username + botuser = @bot.auth.get_botuser(Auth::BotUser.sanitize_username(username)) + if botuser and botuser.password == password + @source = botuser + true + end + false + else + true # no need to request auth at this point + end + } + + @path = req.path + debug '@path = ' + @path.inspect + end + + # The target of a RemoteMessage + def target + @bot + end + + # Remote messages are always 'private' + def private? + true + end + + # Sends a plaintext response + def send_plaintext(body, status=200) + @res.status = status + @res['Content-Type'] = 'text/plain' + @res.body = body + end + end + + # works similar to a message mapper but for url paths + class WebDispatcher + class WebTemplate + attr_reader :botmodule, :pattern, :options + def initialize(botmodule, pattern, options={}) + @botmodule = botmodule + @pattern = pattern + @options = options + set_auth_path(@options) + end + + def recognize(m) + message_route = m.path[1..-1].split('/') + template_route = @pattern[1..-1].split('/') + params = {} + + debug 'web mapping path %s <-> %s' % [message_route.inspect, template_route.inspect] + + message_route.each do |part| + tmpl = template_route.shift + return false if not tmpl + + if tmpl[0] == ':' + # push part as url path parameter + params[tmpl[1..-1].to_sym] = part + elsif tmpl == part + next + else + return false + end + end + + debug 'web mapping params is %s' % [params.inspect] + + params + end + + def set_auth_path(hash) + if hash.has_key?(:full_auth_path) + warning "Web route #{@pattern.inspect} in #{@botmodule} sets :full_auth_path, please don't do this" + else + pre = @botmodule + words = @pattern[1..-1].split('/').reject{ |x| + x == pre || x =~ /^:/ || x =~ /\[|\]/ + } + if words.empty? + post = nil + else + post = words.first + end + if hash.has_key?(:auth_path) + extra = hash[:auth_path] + if extra.sub!(/^:/, "") + pre += "::" + post + post = nil + end + if extra.sub!(/:$/, "") + if words.length > 1 + post = [post,words[1]].compact.join("::") + end + end + pre = nil if extra.sub!(/^!/, "") + post = nil if extra.sub!(/!$/, "") + extra = nil if extra.empty? + else + extra = nil + end + hash[:full_auth_path] = [pre,extra,post].compact.join("::") + debug "Web route #{@pattern} in #{botmodule} will use authPath #{hash[:full_auth_path]}" + end + end + end + + def initialize(bot) + @bot = bot + @templates = [] + end + + def map(botmodule, pattern, options={}) + @templates << WebTemplate.new(botmodule.to_s, pattern, options) + debug 'template route: ' + @templates[-1].inspect + return @templates.length - 1 + end + + # The unmap method for the RemoteDispatcher nils the template at the given index, + # therefore effectively removing the mapping + # + def unmap(botmodule, index) + tmpl = @templates[index] + raise "Botmodule #{botmodule.name} tried to unmap #{tmpl.inspect} that was handled by #{tmpl.botmodule}" unless tmpl.botmodule == botmodule.name + debug "Unmapping #{tmpl.inspect}" + @templates[handle] = nil + @templates.clear unless @templates.compact.size > 0 + end + + # Handle a web service request, find matching mapping and dispatch. + # + # In case authentication fails, sends a 401 Not Authorized response. + # + def handle(m) + if @templates.empty? + m.send_plaintext('no routes!', 404) + return false if @templates.empty? + end + failures = [] + @templates.each do |tmpl| + # Skip this element if it was unmapped + next unless tmpl + botmodule = @bot.plugins[tmpl.botmodule] + params = tmpl.recognize(m) + if params + action = tmpl.options[:action] + unless botmodule.respond_to?(action) + failures << NoActionFailure.new(tmpl, m) + next + end + # check http method: + unless not tmpl.options.has_key? :method or tmpl.options[:method] == m.method + debug 'request method missmatch' + next + end + auth = tmpl.options[:full_auth_path] + debug "checking auth for #{auth.inspect}" + # We check for private permission + if m.bot.auth.permit?(m.source || Auth::defaultbotuser, auth, '?') + debug "template match found and auth'd: #{action.inspect} #{params.inspect}" + response = botmodule.send(action, m, params) + if m.res.sent_size == 0 + m.send_plaintext(response.to_json) + end + return true + end + debug "auth failed for #{auth}" + # if it's just an auth failure but otherwise the match is good, + # don't try any more handlers + m.send_plaintext('Authentication Required!', 401) + return false + end + end + failures.each {|r| + debug "#{r.template.inspect} => #{r}" + } + debug "no handler found" + m.send_plaintext('No Handler Found!', 404) + return false + end + end + + # Static web dispatcher instance used internally. + def web_dispatcher + if defined? @web_dispatcher + @web_dispatcher + else + @web_dispatcher = WebDispatcher.new(self) + end + end + + module Plugins + # Mixin for plugins that want to provide a web interface of some sort. + # + # Plugins include the module and can then use web_map + # to register a url to handle. + # + module WebBotModule + # The remote_map acts just like the BotModule#map method, except that + # the map is registered to the @bot's remote_dispatcher. Also, the remote map handle + # is handled for the cleanup management + # + def web_map(*args) + # stores the handles/indexes for cleanup: + @web_maps = Array.new unless defined? @web_maps + @web_maps << @bot.web_dispatcher.map(self, *args) + end + + # Unregister the remote maps. + # + def web_cleanup + return unless defined? @web_maps + @web_maps.each { |h| + @bot.web_dispatcher.unmap(self, h) + } + @web_maps.clear + end + + # Redefine the default cleanup method. + # + def cleanup + super + web_cleanup + end + end + end +end # Bot +end # Irc + class ::WebServiceUser < Irc::User def initialize(str, botuser, opts={}) super(str, opts) @@ -28,74 +276,46 @@ class ::WebServiceUser < Irc::User attr_accessor :response end -class PingServlet < WEBrick::HTTPServlet::AbstractServlet - def initialize(server, bot) - super server - @bot = bot - end - - def do_GET(req, res) - res['Content-Type'] = 'text/plain' - res.body = "pong\r\n" - end -end - class DispatchServlet < WEBrick::HTTPServlet::AbstractServlet def initialize(server, bot) super server @bot = bot end - def dispatch_command(command, botuser, ip) - netmask = '%s!%s@%s' % [botuser.username, botuser.username, ip] - - user = WebServiceUser.new(netmask, botuser) - message = Irc::PrivMessage.new(@bot, nil, user, @bot.myself, command) - - @bot.plugins.irc_delegate('privmsg', message) - - { :reply => user.response } - end - - # Handle a dispatch request. - def do_POST(req, res) - post = CGI::parse(req.body) - ip = req.peeraddr[3] - - username = post['username'].first - password = post['password'].first - command = post['command'].first - - botuser = @bot.auth.get_botuser(username) - raise 'Permission Denied' if not botuser or botuser.password != password - + def dispatch(req, res) + res['Server'] = 'RBot Web Service (http://ruby-rbot.org/)' begin - ret = dispatch_command(command, botuser, ip) + m = WebMessage.new(@bot, req, res) + @bot.web_dispatcher.handle m rescue - debug '[webservice] error: ' + $!.to_s - debug $@.join("\n") - end - - res.status = 200 - if req['Accept'] == 'application/json' - res['Content-Type'] = 'application/json' - res.body = JSON.dump ret - else + res.status = 500 res['Content-Type'] = 'text/plain' - res.body = ret[:reply].join("\n") + "\n" + res.body = "Error: %s\n" % [$!.to_s] + error 'web dispatch error: ' + $!.to_s + error $@.join("\n") end end + + def do_GET(req, res) + dispatch(req, res) + end + + def do_POST(req, res) + dispatch(req, res) + end end class WebServiceModule < CoreBotModule + include WebBotModule + Config.register Config::BooleanValue.new('webservice.autostart', :default => false, :requires_rescan => true, :desc => 'Whether the web service should be started automatically') Config.register Config::IntegerValue.new('webservice.port', - :default => 7260, # that's 'rbot' + :default => 7268, :requires_rescan => true, :desc => 'Port on which the web service will listen') @@ -119,6 +339,10 @@ class WebServiceModule < CoreBotModule :requires_rescan => true, :desc => 'Certificate file to use for SSL') + Config.register Config::BooleanValue.new('webservice.allow_dispatch', + :default => true, + :desc => 'Dispatch normal bot commands, just as a user would through the web service, requires auth for certain commands just like a irc user.') + def initialize super @port = @bot.config['webservice.port'] @@ -160,8 +384,7 @@ class WebServiceModule < CoreBotModule }) @server = WEBrick::HTTPServer.new(opts) debug 'webservice started: ' + opts.inspect - @server.mount('/dispatch', DispatchServlet, @bot) - @server.mount('/ping', PingServlet, @bot) + @server.mount('/', DispatchServlet, @bot) Thread.new { @server.start } end @@ -176,22 +399,51 @@ class WebServiceModule < CoreBotModule end def handle_start(m, params) - s = '' if @server - s << 'web service already running' + m.reply 'web service already running' else begin start_service - s << 'web service started' + m.reply 'web service started' rescue - s << 'unable to start web service, error: ' + $!.to_s + m.reply 'unable to start web service, error: ' + $!.to_s end end - m.reply s end - def register_servlet(plugin, servlet) - @server.mount('/plugin/%s' % plugin.name, servlet, plugin, @bot) + def handle_stop(m, params) + if @server + stop_service + m.reply 'web service stopped' + else + m.reply 'web service not running' + end + end + + def handle_ping(m, params) + m.send_plaintext("pong\n") + end + + def handle_dispatch(m, params) + if not @bot.config['webservice.allow_dispatch'] + m.send_plaintext('dispatch forbidden by configuration', 403) + return + end + + command = m.post['command'][0] + if not m.source + botuser = Auth::defaultbotuser + else + botuser = m.source.botuser + end + netmask = '%s!%s@%s' % [botuser.username, botuser.username, m.client] + + user = WebServiceUser.new(netmask, botuser) + message = Irc::PrivMessage.new(@bot, nil, user, @bot.myself, command) + + res = @bot.plugins.irc_delegate('privmsg', message) + + { :reply => user.response } end end @@ -206,5 +458,16 @@ webservice.map 'webservice stop', :action => 'handle_stop', :auth_path => ':manage:' +webservice.web_map '/ping', + :action => :handle_ping, + :auth_path => 'public' + +# executes arbitary bot commands +webservice.web_map '/dispatch', + :action => :handle_dispatch, + :method => 'POST', + :auth_path => 'public' + webservice.default_auth('*', false) +webservice.default_auth('public', true) |