From 27e7d6a9acd3d39053b938612764abfb5530e81d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Thu, 10 Oct 2019 17:27:34 +0200 Subject: [PATCH] Aka: Add web UI (#1373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * utils.web: Import html escaping functions Signed-off-by: Johannes Löthberg * Aka: Add web interface for browsing Akas Fixes #1226. Signed-off-by: Johannes Löthberg * httpserver: Actually handle KeyError in unhook Signed-off-by: Johannes Löthberg * test: FakeHTTPConnection: Don't decode data in send BaseHTTPRequestHandler expects to get bytes, so we can't decode the sent data. Signed-off-by: Johannes Löthberg * test: HTTPPluginTestCase: Use BytesIO instead of StringIO BaseHTTPRequestHandler expects bytes, not strings. Signed-off-by: Johannes Löthberg * test: HTTPPluginTestCase: Rewind wfile to 0 before reading the response Otherwise the read pointer is at the end of the file. Signed-off-by: Johannes Löthberg * Aka: Add basic web UI tests Signed-off-by: Johannes Löthberg --- plugins/Aka/config.py | 4 ++ plugins/Aka/plugin.py | 147 ++++++++++++++++++++++++++++++++++++++++++ plugins/Aka/test.py | 49 ++++++++++++++ src/httpserver.py | 5 +- src/test.py | 8 +-- src/utils/web.py | 2 + 6 files changed, 208 insertions(+), 7 deletions(-) diff --git a/plugins/Aka/config.py b/plugins/Aka/config.py index 59e3d0274..4c5069f83 100644 --- a/plugins/Aka/config.py +++ b/plugins/Aka/config.py @@ -56,5 +56,9 @@ conf.registerGlobalValue(Aka, 'maximumWordsInName', command name. Setting this to an high value may slow down your bot on long commands."""))) +conf.registerGroup(Aka, 'web') +conf.registerGlobalValue(Aka.web, 'enable', + registry.Boolean(False, _("""Determines whether the Akas will be + browsable through the HTTP server."""))) # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/Aka/plugin.py b/plugins/Aka/plugin.py index 63e23c4c1..e41e88a3b 100644 --- a/plugins/Aka/plugin.py +++ b/plugins/Aka/plugin.py @@ -41,6 +41,7 @@ import supybot.plugins as plugins import supybot.utils.minisix as minisix import supybot.ircutils as ircutils import supybot.callbacks as callbacks +import supybot.httpserver as httpserver from supybot.i18n import PluginInternationalization _ = PluginInternationalization('Aka') @@ -193,6 +194,15 @@ if sqlite3: return (bool(r[0]), r[1], r[2]) else: raise AkaError(_('This Aka does not exist.')) + + def get_all(self, channel): + cursor = self.get_db(channel).cursor() + cursor.execute(""" + SELECT id, name, alias, locked, locked_by, locked_at + FROM aliases; + """) + return cursor.fetchall() + available_db.update({'sqlite3': SQLiteAkaDB}) elif sqlalchemy: Base = sqlalchemy.ext.declarative.declarative_base() @@ -324,6 +334,16 @@ elif sqlalchemy: except sqlalchemy.orm.exc.NoResultFound: raise AkaError(_('This Aka does not exist.')) + def get_all(self, channel): + akas = self.get_db(channel).query( + SQLAlchemyAlias.id, SQLAlchemyAlias.name, SQLAlchemyAlias.alias, + SQLAlchemyAlias.locked, SQLAlchemyAlias.locked_by, SQLAlchemyAlias.locked_at + ).all() + return map( + lambda aka: (aka.name, aka.alias, aka.locked, aka.locked_by, aka.locked_at), + akas + ) + available_db.update({'sqlalchemy': SqlAlchemyAkaDB}) @@ -376,6 +396,107 @@ elif 'sqlalchemy' in conf.supybot.databases() and 'sqlalchemy' in available_db: else: raise plugins.NoSuitableDatabase(['sqlite3', 'sqlalchemy']) + +class AkaHTTPCallback(httpserver.SupyHTTPServerCallback): + name = 'Aka web interface' + base_template = '''\ + + + + Aka + + + + +

Aka

+ + %s + +''' + index_template = base_template % '''\ +

To view the global Akas either click here or + enter 'global' in the form below.

+ +
+ + + +
''' + list_template = base_template % '''\ + + + + + + + %s +
NameAliasLocked
''' + + def doGet(self, handler, path, *args, **kwargs): + if path == '/': + self.send_response(200) + self.send_header('Content-type', 'text/html; charset=utf-8') + self.end_headers() + self.write(self.index_template) + + elif path.startswith('/list/'): + parts = path.split('/') + channel = parts[2] if len(parts) == 3 else 'global' + channel = utils.web.urlunquote(channel) + + self.send_response(200) + self.send_header('Content-type', 'text/html; charset=utf-8') + self.end_headers() + + akas = {} + for aka in self._plugin._db.get_all('global'): + akas[aka[1]] = aka + + if channel != 'global': + for aka in self._plugin._db.get_all(channel): + akas[aka[1]] = aka + + aka_rows = [] + for (name, aka) in sorted(akas.items()): + (id, name, alias, locked, locked_by, locked_at) = aka + locked_column = 'False' + if locked: + locked_column = format( + _('By %s at %s'), + locked_by, + locked_at.split('.')[0], + ) + aka_rows.append( + format( + """ + + %s + %s + %s + + """, + utils.web.html_escape(name), + utils.web.html_escape(alias), + utils.web.html_escape(locked_column), + ) + ) + self.write(format(self.list_template, ''.join(aka_rows))) + + def doPost(self, handler, path, form, *args, **kwargs): + if path == '/' and 'channel' in form: + self.send_response(303) + self.send_header( + 'Location', + format('list/%s', utils.web.urlquote(form['channel'].value)) + ) + self.end_headers() + else: + self.send_response(400) + self.send_header('Content-type', 'text/plain; charset=utf-8') + self.end_headers() + self.write('Missing field \'channel\'.') + + class Aka(callbacks.Plugin): """Aka is the improved version of the Alias plugin. It stores akas outside of the bot.conf, which doesn't have risk of corrupting the bot.conf file @@ -388,6 +509,32 @@ class Aka(callbacks.Plugin): # "sqlalchemy" is only for backward compatibility filename = conf.supybot.directories.data.dirize('Aka.sqlalchemy.db') self._db = AkaDB(filename) + self._http_running = False + conf.supybot.plugins.Aka.web.enable.addCallback(self._httpConfCallback) + if self.registryValue('web.enable'): + self._startHttp() + + def die(self): + if self._http_running: + self._stopHttp() + + def _httpConfCallback(self): + if self.registryValue('web.enable'): + if not self._http_running: + self._startHttp() + else: + if self._http_running: + self._stopHttp() + + def _startHttp(self): + callback = AkaHTTPCallback() + callback._plugin = self + httpserver.hook('aka', callback) + self._http_running = True + + def _stopHttp(self): + httpserver.unhook('aka') + self._http_running = False def isCommandMethod(self, name): args = name.split(' ') diff --git a/plugins/Aka/test.py b/plugins/Aka/test.py index 789949490..dc997b263 100644 --- a/plugins/Aka/test.py +++ b/plugins/Aka/test.py @@ -31,6 +31,7 @@ from supybot.test import * import supybot.conf as conf +import supybot.httpserver as httpserver import supybot.plugin as plugin import supybot.registry as registry from supybot.utils.minisix import u @@ -292,4 +293,52 @@ class AkaTestCase(PluginTestCase): # This should be case insensitive too. self.assertRegexp('aka search MaNY', 'many words') +class AkaWebUITestCase(ChannelHTTPPluginTestCase): + plugins = ('Aka',) + config = { + 'servers.http.keepAlive': True, + 'plugins.Aka.web.enable': False, + } + + def setUp(self): + super(ChannelHTTPPluginTestCase, self).setUp() + httpserver.startServer() + + def tearDown(self): + httpserver.stopServer() + super(ChannelHTTPPluginTestCase, self).tearDown() + + def testToggleWebEnable(self): + self.assertHTTPResponse('/aka/', 404) + self.assertNotError('config plugins.Aka.web.enable True') + self.assertHTTPResponse('/aka/', 200) + self.assertNotError('config plugins.Aka.web.enable False') + self.assertHTTPResponse('/aka/', 404) + + def testGlobalPage(self): + self.assertNotError('config plugins.Aka.web.enable True') + + self.assertNotError('aka add foo1 echo 1') + self.assertNotError('aka add --channel #foo foo2 echo 2') + self.assertNotError('aka add --channel #bar foo3 echo 3') + + (respCode, body) = self.request('/aka/list/global') + self.assertEqual(respCode, 200) + self.assertIn(b'foo1', body) + self.assertNotIn(b'foo2', body) + self.assertNotIn(b'foo3', body) + + def testChannelPage(self): + self.assertNotError('config plugins.Aka.web.enable True') + + self.assertNotError('aka add foo1 echo 1') + self.assertNotError('aka add --channel #foo foo2 echo 2') + self.assertNotError('aka add --channel #bar foo3 echo 3') + + (respCode, body) = self.request('/aka/list/%23foo') + self.assertEqual(respCode, 200) + self.assertIn(b'foo1', body) + self.assertIn(b'foo2', body) + self.assertNotIn(b'foo3', body) + # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/src/httpserver.py b/src/httpserver.py index 583c025e8..715c1c5d2 100644 --- a/src/httpserver.py +++ b/src/httpserver.py @@ -195,8 +195,9 @@ class RealSupyHTTPServer(HTTPServer): self.callbacks[subdir] = callback callback.doHook(self, subdir) def unhook(self, subdir): - callback = self.callbacks.pop(subdir) # May raise a KeyError. We don't care. - callback.doUnhook(self) + callback = self.callbacks.pop(subdir, None) + if callback: + callback.doUnhook(self) return callback def __str__(self): diff --git a/src/test.py b/src/test.py index 1127e71ac..90ff20b06 100644 --- a/src/test.py +++ b/src/test.py @@ -637,8 +637,6 @@ class FakeHTTPConnection(HTTPConnection): self.rfile = rfile self.wfile = wfile def send(self, data): - if minisix.PY3 and isinstance(data, bytes): - data = data.decode() self.wfile.write(data) #def putheader(self, name, value): # self._headers[name] = value @@ -653,14 +651,14 @@ class HTTPPluginTestCase(PluginTestCase): def request(self, url, method='GET', read=True, data={}): assert url.startswith('/') - wfile = minisix.io.StringIO() - rfile = minisix.io.StringIO() + wfile = minisix.io.BytesIO() + rfile = minisix.io.BytesIO() connection = FakeHTTPConnection(wfile, rfile) connection.putrequest(method, url) connection.endheaders() rfile.seek(0) - wfile.seek(0) handler = TestRequestHandler(rfile, wfile) + wfile.seek(0) if read: return (handler._response, wfile.read()) else: diff --git a/src/utils/web.py b/src/utils/web.py index 9589b161e..139bde9ce 100644 --- a/src/utils/web.py +++ b/src/utils/web.py @@ -48,6 +48,7 @@ if minisix.PY2: from urlparse import urlsplit, urlunsplit, urlparse from htmlentitydefs import entitydefs, name2codepoint from HTMLParser import HTMLParser + from cgi import escape as html_escape Request = urllib2.Request urlquote = urllib.quote urlquote_plus = urllib.quote_plus @@ -62,6 +63,7 @@ else: from urllib.parse import urlsplit, urlunsplit, urlparse from html.entities import entitydefs, name2codepoint from html.parser import HTMLParser + from html import escape as html_escape import urllib.request, urllib.parse, urllib.error Request = urllib.request.Request urlquote = urllib.parse.quote