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={})
@bot = bot
end
- def do_POST(req, res)
- # NOTE: still wip.
- uri = req.path_info
- post = CGI::parse(req.body)
- ip = req.peeraddr[3]
-
- if post['command']
- command = post['command'].first
- else
- command = uri.gsub('/', ' ').strip
- end
-
- username = post['username'].first
- password = post['password'].first
-
- botuser = @bot.auth.get_botuser(username)
- netmask = '%s!%s@%s' % [botuser.username, botuser.username, ip]
-
- if not botuser or botuser.password != password
- raise 'Permission Denied'
+ 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
- ws_user = WebServiceUser.new(netmask, botuser)
- message = Irc::PrivMessage.new(@bot, nil, ws_user, @bot.myself, command)
-
- @bot.plugins.irc_delegate('privmsg', message)
+ def do_GET(req, res)
+ dispatch(req, res)
+ end
- res.status = 200
- res['Content-Type'] = 'text/plain'
- res.body = ws_user.response.join("\n") + "\n"
+ def do_POST(req, res)
+ 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')
: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,
: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
# serial number which makes this feature pretty much useless.
end
end
+ # Logging to file in ~/.rbot
+ logfile = File.open(@bot.path('webservice.log'), 'a+')
+ opts.merge!({
+ :Logger => WEBrick::Log.new(logfile),
+ :AccessLog => [[logfile, WEBrick::AccessLog::COMBINED_LOG_FORMAT]]
+ })
@server = WEBrick::HTTPServer.new(opts)
debug 'webservice started: ' + opts.inspect
- @server.mount('/dispatch', DispatchServlet, @bot)
+ @server.mount('/', DispatchServlet, @bot)
Thread.new { @server.start }
end
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.botuser
+ 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
: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)