### # Copyright (c) 2011, Valentin Lorentz # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright notice, # this list of conditions, and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright notice, # this list of conditions, and the following disclaimer in the # documentation and/or other materials provided with the distribution. # * Neither the name of the author of this software nor the name of # contributors to this software may be used to endorse or promote products # derived from this software without specific prior written consent. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. ### """ An embedded and centralized HTTP server for Supybot's plugins. """ import os import cgi import socket from threading import Thread import supybot.log as log import supybot.conf as conf import supybot.world as world import supybot.utils.minisix as minisix from supybot.i18n import PluginInternationalization _ = PluginInternationalization() if minisix.PY2: from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler else: from http.server import HTTPServer, BaseHTTPRequestHandler configGroup = conf.supybot.servers.http class RequestNotHandled(Exception): pass DEFAULT_TEMPLATES = { 'index.html': """\
""" + _('Here is a list of the plugins that have a Web interface:') +\ """
%(list)s """, 'generic/error.html': """\%(error)s
""", 'default.css': """\ body { background-color: #F0F0F0; } /************************************ * Classes that plugins should use. * ************************************/ /* Error pages */ body.error { text-align: center; } body.error p { background-color: #FFE0E0; border: 1px #FFA0A0 solid; } /* Pages that only contain a list. */ .purelisting { text-align: center; } .purelisting ul { margin: 0; padding: 0; } .purelisting ul li { margin: 0; padding: 0; list-style-type: none; } /* Pages that only contain a table. */ .puretable { text-align: center; } .puretable table { width: 100%; border-collapse: collapse; text-align: center; } .puretable table th { /*color: #039;*/ padding: 10px 8px; border-bottom: 2px solid #6678b1; } .puretable table td { padding: 9px 8px 0px 8px; border-bottom: 1px solid #ccc; } """, 'robots.txt': """""", } def set_default_templates(defaults): for filename, content in defaults.items(): path = conf.supybot.directories.data.web.dirize(filename) if os.path.isfile(path + '.example'): os.unlink(path + '.example') if not os.path.isdir(os.path.dirname(path)): os.makedirs(os.path.dirname(path)) with open(path + '.example', 'a') as fd: fd.write(content) set_default_templates(DEFAULT_TEMPLATES) def get_template(filename): path = conf.supybot.directories.data.web.dirize(filename) if os.path.isfile(path): with open(path, 'r') as fd: return fd.read() else: assert os.path.isfile(path + '.example'), path + '.example' with open(path + '.example', 'r') as fd: return fd.read() class RealSupyHTTPServer(HTTPServer): # TODO: make this configurable timeout = 0.5 running = False def __init__(self, address, protocol, callback): self.protocol = protocol if protocol == 4: self.address_family = socket.AF_INET elif protocol == 6: self.address_family = socket.AF_INET6 else: raise AssertionError(protocol) HTTPServer.__init__(self, address, callback) self.callbacks = {} def server_bind(self): if self.protocol == 6: v = conf.supybot.servers.http.singleStack() self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, v) HTTPServer.server_bind(self) def hook(self, subdir, callback): if subdir in self.callbacks: log.warning(('The HTTP subdirectory `%s` was already hooked but ' 'has been claimed by another plugin (or maybe you ' 'reloaded the plugin and it didn\'t properly unhook. ' 'Forced unhook.') % subdir) self.callbacks[subdir] = callback callback.doHook(self, subdir) def unhook(self, subdir): callback = self.callbacks.pop(subdir, None) if callback: callback.doUnhook(self) return callback def __str__(self): return 'server at %s %i' % self.server_address[0:2] class TestSupyHTTPServer(RealSupyHTTPServer): def __init__(self, *args, **kwargs): self.callbacks = {} def serve_forever(self, *args, **kwargs): pass def shutdown(self, *args, **kwargs): pass if world.testing: SupyHTTPServer = TestSupyHTTPServer else: SupyHTTPServer = RealSupyHTTPServer class SupyHTTPRequestHandler(BaseHTTPRequestHandler): def do_X(self, callbackMethod, *args, **kwargs): if self.path == '/': callback = SupyIndex() elif self.path in ('/robots.txt',): callback = Static('text/plain; charset=utf-8') elif self.path in ('/default.css',): callback = Static('text/css') elif self.path == '/favicon.ico': callback = Favicon() else: subdir = self.path.split('/')[1] try: callback = self.server.callbacks[subdir] except KeyError: callback = Supy404() # Some shortcuts for name in ('send_response', 'send_header', 'end_headers', 'rfile', 'wfile', 'headers'): setattr(callback, name, getattr(self, name)) # We call doX, because this is more supybotic than do_X. path = self.path if not callback.fullpath: path = '/' + path.split('/', 2)[-1] getattr(callback, callbackMethod)(self, path, *args, **kwargs) def do_GET(self): self.do_X('doGet') def do_POST(self): if 'Content-Type' not in self.headers: self.headers['Content-Type'] = 'application/x-www-form-urlencoded' if self.headers['Content-Type'] == 'application/x-www-form-urlencoded': form = cgi.FieldStorage( fp=self.rfile, headers=self.headers, environ={'REQUEST_METHOD':'POST', 'CONTENT_TYPE':self.headers['Content-Type'], }) else: content_length = int(self.headers.get('Content-Length', '0')) form = self.rfile.read(content_length) self.do_X('doPost', form=form) def do_HEAD(self): self.do_X('doHead') def address_string(self): s = BaseHTTPRequestHandler.address_string(self) # Strip IPv4-mapped IPv6 addresses such as ::ffff:127.0.0.1 prefix = '::ffff:' if s.startswith(prefix): s = s[len(prefix):] return s def log_message(self, format, *args): log.info('HTTP request: %s - %s' % (self.address_string(), format % args)) class SupyHTTPServerCallback(log.Firewalled): """This is a base class that should be overriden by any plugin that want to have a Web interface.""" __firewalled__ = {'doGet': None, 'doPost': None, 'doHead': None, 'doPut': None, 'doDelete': None, } fullpath = False name = "Unnamed plugin" defaultResponse = _(""" This is a default response of the Supybot HTTP server. If you see this message, it probably means you are developing a plugin, and you have neither overriden this message or defined an handler for this query.""") if minisix.PY3: def write(self, b): if isinstance(b, str): b = b.encode() self.wfile.write(b) else: def write(self, s): self.wfile.write(s) def doGetOrHead(self, handler, path, write_content): response = self.defaultResponse.encode() handler.send_response(405) self.send_header('Content-Type', 'text/plain; charset=utf-8; charset=utf-8') self.send_header('Content-Length', len(response)) self.end_headers() if write_content: self.wfile.write(response) def doGet(self, handler, path): self.doGetOrHead(handler, path, write_content=True) def doHead(self, handler, path): self.doGetOrHead(handler, path, write_content=False) doPost = doGet def doHook(self, handler, subdir): """Method called when hooking this callback.""" pass def doUnhook(self, handler): """Method called when unhooking this callback.""" pass class Supy404(SupyHTTPServerCallback): """A 404 Not Found error.""" name = "Error 404" fullpath = True response = _(""" I am a pretty clever IRC bot, but I suck at serving Web pages, particulary if I don't know what to serve. What I'm saying is you just triggered a 404 Not Found, and I am not trained to help you in such a case.""") def doGetOrHead(self, handler, path, write_content): response = self.response if minisix.PY3: response = response.encode() handler.send_response(404) self.send_header('Content-Type', 'text/plain; charset=utf-8; charset=utf-8') self.send_header('Content-Length', len(self.response)) self.end_headers() if write_content: self.wfile.write(response) class SupyIndex(SupyHTTPServerCallback): """Displays the index of available plugins.""" name = "index" fullpath = True defaultResponse = _("Request not handled.") def doGetOrHead(self, handler, path, write_content): plugins = [x for x in handler.server.callbacks.items()] if plugins == []: plugins = _('No plugins available.') else: plugins = '