Aka: Add web UI (#1373)

* utils.web: Import html escaping functions

Signed-off-by: Johannes Löthberg <johannes@kyriasis.com>

* Aka: Add web interface for browsing Akas

Fixes #1226.

Signed-off-by: Johannes Löthberg <johannes@kyriasis.com>

* httpserver: Actually handle KeyError in unhook

Signed-off-by: Johannes Löthberg <johannes@kyriasis.com>

* 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 <johannes@kyriasis.com>

* test: HTTPPluginTestCase: Use BytesIO instead of StringIO

BaseHTTPRequestHandler expects bytes, not strings.

Signed-off-by: Johannes Löthberg <johannes@kyriasis.com>

* 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 <johannes@kyriasis.com>

* Aka: Add basic web UI tests

Signed-off-by: Johannes Löthberg <johannes@kyriasis.com>
This commit is contained in:
Johannes Löthberg 2019-10-10 17:27:34 +02:00 committed by Valentin Lorentz
parent 68539da7ab
commit 27e7d6a9ac
6 changed files with 208 additions and 7 deletions

View File

@ -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:

View File

@ -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 = '''\
<!DOCTYPE html>
<html>
<head>
<title>Aka</title>
<link rel="stylesheet" href="/default.css">
</head>
<body>
<h1>Aka</h1>
%s
</body>
</html>'''
index_template = base_template % '''\
<p>To view the global Akas either click <a href="list/global">here</a> or
enter 'global' in the form below.</p>
<form action="" method="post">
<label for="channel">Channel name:</label>
<input type="text" placeholder="#channel" name="channel" id="channel">
<input type="submit" name="submit" value="view">
</form>'''
list_template = base_template % '''\
<table>
<thead>
<th>Name</th>
<th>Alias</th>
<th>Locked</th>
</thead>
%s
</table>'''
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(
"""
<tr>
<td style="white-space: nowrap;"><code>%s</code></td>
<td><code>%s<code></td>
<td style="white-space: nowrap;">%s</td>
</tr>
""",
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(' ')

View File

@ -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:

View File

@ -195,7 +195,8 @@ 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 = self.callbacks.pop(subdir, None)
if callback:
callback.doUnhook(self)
return callback

View File

@ -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:

View File

@ -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