X-Git-Url: https://git.netwichtig.de/gitweb/?a=blobdiff_plain;ds=sidebyside;f=lib%2Frbot%2Fcore%2Fwebservice.rb;h=112ec85e7672f859b0daefe69ec8d1bce2183e8d;hb=28502d92c420aefa3832e57561044efa06b9ab8b;hp=21acf87d0d3623d36cd3b5776fb9094386dae2a4;hpb=111c284f29dd728f6535ed05d1f2820fff02b78d;p=user%2Fhenk%2Fcode%2Fruby%2Frbot.git diff --git a/lib/rbot/core/webservice.rb b/lib/rbot/core/webservice.rb index 21acf87d..112ec85e 100644 --- a/lib/rbot/core/webservice.rb +++ b/lib/rbot/core/webservice.rb @@ -17,6 +17,345 @@ require 'webrick/https' require 'openssl' require 'cgi' require 'json' +require 'erb' +require 'ostruct' + +module ::Irc +class Bot + # A WebMessage is a web request and response object combined with helper methods. + # + class WebMessage + # Bot instance + # + attr_reader :bot + # HTTP method (POST, GET, etc.) + # + attr_reader :method + # Request object, a instance of WEBrick::HTTPRequest ({http://www.ruby-doc.org/stdlib-2.0/libdoc/webrick/rdoc/WEBrick/HTTPRequest.html docs}) + # + attr_reader :req + # Response object, a instance of WEBrick::HTTPResponse ({http://www.ruby-doc.org/stdlib-2.0/libdoc/webrick/rdoc/WEBrick/HTTPResponse.html docs}) + # + attr_reader :res + # Parsed post request parameters. + # + attr_reader :post + # Parsed url parameters. + # + attr_reader :args + # Client IP. + # + attr_reader :client + # URL Path. + # + attr_reader :path + # The bot user issuing the command. + # + attr_reader :source + def initialize(bot, req, res) + @bot = bot + @req = req + @res = res + + @method = req.request_method + @post = {} + if req.body and not req.body.empty? + @post = parse_query(req.body) + end + @args = {} + if req.query_string and not req.query_string.empty? + @args = parse_query(req.query_string) + 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 + else + false + end + else + true # no need to request auth at this point + end + } + + @path = req.path + + @load_path = [File.join(Config::datadir, 'web')] + @load_path += @bot.plugins.core_module_dirs + @load_path += @bot.plugins.plugin_dirs + end + + def parse_query(query) + params = CGI::parse(query) + params.each_pair do |key, val| + params[key] = val.last + end + params + end + + # The target of a RemoteMessage + def target + @bot + end + + # Remote messages are always 'private' + def private? + true + end + + # Sends a response with the specified body, status and content type. + def send_response(body, status, type) + @res.status = status + @res['Content-Type'] = type + @res.body = body + end + + # Sends a plaintext response + def send_plaintext(body, status=200) + send_response(body, status, 'text/plain') + end + + # Sends a json response + def send_json(body, status=200) + send_response(body, status, 'application/json') + end + + # Sends a html response + def send_html(body, status=200) + send_response(body, status, 'text/html') + end + + # Expands a relative filename to absolute using a list of load paths. + def get_load_path(filename) + @load_path.each do |path| + file = File.join(path, filename) + return file if File.exists?(file) + end + end + + # Renders a erb template and responds it + def render(template, args={}) + file = get_load_path template + if not file + raise 'template not found: ' + template + end + + tmpl = ERB.new(IO.read(file)) + ns = OpenStruct.new(args) + body = tmpl.result(ns.instance_eval { binding }) + send_html(body, 200) + 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[index] = 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 and m.res.body.empty? + m.send_json(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 + + # And just because I like consistency: + # + module WebCoreBotModule + include WebBotModule + end + + module WebPlugin + include WebBotModule + end + end +end # Bot +end # Irc class ::WebServiceUser < Irc::User def initialize(str, botuser, opts={}) @@ -28,69 +367,51 @@ 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) + def dispatch(req, res) + res['Server'] = 'RBot Web Service (http://ruby-rbot.org/)' + begin + m = WebMessage.new(@bot, req, res) + @bot.web_dispatcher.handle m + rescue WEBrick::HTTPStatus::Unauthorized + res.status = 401 + res['Content-Type'] = 'text/plain' + res.body = 'Authentication Required!' + error 'authentication error (wrong password)' + rescue + res.status = 500 + res['Content-Type'] = 'text/plain' + res.body = "Error: %s\n" % [$!.to_s] + error 'web dispatch error: ' + $!.to_s + error $@.join("\n") + end + end - { :reply => user.response } + def do_GET(req, res) + dispatch(req, res) 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 - - ret = dispatch_command(command, botuser, ip) - - res.status = 200 - if req['Accept'] == 'application/json' - res['Content-Type'] = 'application/json' - res.body = JSON.dump ret - else - res['Content-Type'] = 'text/plain' - res.body = ret[:reply].join("\n") + "\n" - end + dispatch(req, res) end end class WebServiceModule < CoreBotModule + include WebCoreBotModule + 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') @@ -99,6 +420,10 @@ class WebServiceModule < CoreBotModule :requires_rescan => true, :desc => 'Host the web service will bind on') + Config.register Config::StringValue.new('webservice.url', + :default => 'http://127.0.0.1:7268', + :desc => 'The public URL of the web service.') + Config.register Config::BooleanValue.new('webservice.ssl', :default => false, :requires_rescan => true, @@ -114,11 +439,16 @@ 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'] @host = @bot.config['webservice.host'] @server = nil + @bot.webservice = self begin start_service if @bot.config['webservice.autostart'] rescue => e @@ -154,8 +484,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 @@ -170,18 +499,57 @@ 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 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'] + if not m.source + botuser = Auth::defaultbotuser + else + botuser = m.source + end + netmask = '%s!%s@%s' % [botuser.username, botuser.username, m.client] + + debug 'dispatch command: ' + command + + user = WebServiceUser.new(netmask, botuser) + message = Irc::PrivMessage.new(@bot, nil, user, @bot.myself, command) + + res = @bot.plugins.irc_delegate('privmsg', message) + + if m.req['Accept'] == 'application/json' + { :reply => user.response } + else + m.send_plaintext(user.response.join("\n") + "\n") + end end end @@ -196,5 +564,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)