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 command name. Setting this to an high value may slow down your bot
on long commands."""))) 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: # 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.utils.minisix as minisix
import supybot.ircutils as ircutils import supybot.ircutils as ircutils
import supybot.callbacks as callbacks import supybot.callbacks as callbacks
import supybot.httpserver as httpserver
from supybot.i18n import PluginInternationalization from supybot.i18n import PluginInternationalization
_ = PluginInternationalization('Aka') _ = PluginInternationalization('Aka')
@ -193,6 +194,15 @@ if sqlite3:
return (bool(r[0]), r[1], r[2]) return (bool(r[0]), r[1], r[2])
else: else:
raise AkaError(_('This Aka does not exist.')) 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}) available_db.update({'sqlite3': SQLiteAkaDB})
elif sqlalchemy: elif sqlalchemy:
Base = sqlalchemy.ext.declarative.declarative_base() Base = sqlalchemy.ext.declarative.declarative_base()
@ -324,6 +334,16 @@ elif sqlalchemy:
except sqlalchemy.orm.exc.NoResultFound: except sqlalchemy.orm.exc.NoResultFound:
raise AkaError(_('This Aka does not exist.')) 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}) available_db.update({'sqlalchemy': SqlAlchemyAkaDB})
@ -376,6 +396,107 @@ elif 'sqlalchemy' in conf.supybot.databases() and 'sqlalchemy' in available_db:
else: else:
raise plugins.NoSuitableDatabase(['sqlite3', 'sqlalchemy']) 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): class Aka(callbacks.Plugin):
"""Aka is the improved version of the Alias plugin. It stores akas outside """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 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 # "sqlalchemy" is only for backward compatibility
filename = conf.supybot.directories.data.dirize('Aka.sqlalchemy.db') filename = conf.supybot.directories.data.dirize('Aka.sqlalchemy.db')
self._db = AkaDB(filename) 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): def isCommandMethod(self, name):
args = name.split(' ') args = name.split(' ')

View File

@ -31,6 +31,7 @@
from supybot.test import * from supybot.test import *
import supybot.conf as conf import supybot.conf as conf
import supybot.httpserver as httpserver
import supybot.plugin as plugin import supybot.plugin as plugin
import supybot.registry as registry import supybot.registry as registry
from supybot.utils.minisix import u from supybot.utils.minisix import u
@ -292,4 +293,52 @@ class AkaTestCase(PluginTestCase):
# This should be case insensitive too. # This should be case insensitive too.
self.assertRegexp('aka search MaNY', 'many words') 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: # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:

View File

@ -195,8 +195,9 @@ class RealSupyHTTPServer(HTTPServer):
self.callbacks[subdir] = callback self.callbacks[subdir] = callback
callback.doHook(self, subdir) callback.doHook(self, subdir)
def unhook(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)
callback.doUnhook(self) if callback:
callback.doUnhook(self)
return callback return callback
def __str__(self): def __str__(self):

View File

@ -637,8 +637,6 @@ class FakeHTTPConnection(HTTPConnection):
self.rfile = rfile self.rfile = rfile
self.wfile = wfile self.wfile = wfile
def send(self, data): def send(self, data):
if minisix.PY3 and isinstance(data, bytes):
data = data.decode()
self.wfile.write(data) self.wfile.write(data)
#def putheader(self, name, value): #def putheader(self, name, value):
# self._headers[name] = value # self._headers[name] = value
@ -653,14 +651,14 @@ class HTTPPluginTestCase(PluginTestCase):
def request(self, url, method='GET', read=True, data={}): def request(self, url, method='GET', read=True, data={}):
assert url.startswith('/') assert url.startswith('/')
wfile = minisix.io.StringIO() wfile = minisix.io.BytesIO()
rfile = minisix.io.StringIO() rfile = minisix.io.BytesIO()
connection = FakeHTTPConnection(wfile, rfile) connection = FakeHTTPConnection(wfile, rfile)
connection.putrequest(method, url) connection.putrequest(method, url)
connection.endheaders() connection.endheaders()
rfile.seek(0) rfile.seek(0)
wfile.seek(0)
handler = TestRequestHandler(rfile, wfile) handler = TestRequestHandler(rfile, wfile)
wfile.seek(0)
if read: if read:
return (handler._response, wfile.read()) return (handler._response, wfile.read())
else: else:

View File

@ -48,6 +48,7 @@ if minisix.PY2:
from urlparse import urlsplit, urlunsplit, urlparse from urlparse import urlsplit, urlunsplit, urlparse
from htmlentitydefs import entitydefs, name2codepoint from htmlentitydefs import entitydefs, name2codepoint
from HTMLParser import HTMLParser from HTMLParser import HTMLParser
from cgi import escape as html_escape
Request = urllib2.Request Request = urllib2.Request
urlquote = urllib.quote urlquote = urllib.quote
urlquote_plus = urllib.quote_plus urlquote_plus = urllib.quote_plus
@ -62,6 +63,7 @@ else:
from urllib.parse import urlsplit, urlunsplit, urlparse from urllib.parse import urlsplit, urlunsplit, urlparse
from html.entities import entitydefs, name2codepoint from html.entities import entitydefs, name2codepoint
from html.parser import HTMLParser from html.parser import HTMLParser
from html import escape as html_escape
import urllib.request, urllib.parse, urllib.error import urllib.request, urllib.parse, urllib.error
Request = urllib.request.Request Request = urllib.request.Request
urlquote = urllib.parse.quote urlquote = urllib.parse.quote