diff options
| author | 2019-12-22 05:33:18 +0000 | |
|---|---|---|
| committer | 2019-12-22 05:33:18 +0000 | |
| commit | a91e61b84c2b485edd3786aa0d31645b130d39ab (patch) | |
| tree | 66e4fb20a0ef857a53db73bec385c67b9bc505b3 /http2irc.py | |
| parent | Support web server config changes (diff) | |
| signature | ||
Add support for transformation/translation modules that do arbitrary request processing to generate the message
Fixes #3
Diffstat (limited to 'http2irc.py')
| -rw-r--r-- | http2irc.py | 61 |
1 files changed, 54 insertions, 7 deletions
diff --git a/http2irc.py b/http2irc.py index 1ead513..7a09642 100644 --- a/http2irc.py +++ b/http2irc.py @@ -4,6 +4,8 @@ import asyncio import base64 import collections import concurrent.futures +import importlib.util +import inspect import logging import os.path import signal @@ -114,9 +116,11 @@ class Config(dict): raise InvalidConfig(f'Invalid map key {key!r}') if not isinstance(map_, collections.abc.Mapping): raise InvalidConfig(f'Invalid map for {key!r}') - if any(x not in ('webpath', 'ircchannel', 'auth') for x in map_): + if any(x not in ('webpath', 'ircchannel', 'auth', 'module') for x in map_): raise InvalidConfig(f'Unknown key(s) found in map {key!r}') #TODO: Check values + if 'module' in map_ and not os.path.isfile(map_['module']): + raise InvalidConfig(f'Module {map_["module"]!r} in map {key!r} is not a file') # Default values finalObj = {'logging': {'level': 'INFO', 'format': '{asctime} {levelname} {message}'}, 'irc': {'host': 'irc.hackint.org', 'port': 6697, 'ssl': 'yes', 'nick': 'h2ibot', 'real': 'I am an http2irc bot.', 'certfile': None, 'certkeyfile': None}, 'web': {'host': '127.0.0.1', 'port': 8080}, 'maps': {}} @@ -129,6 +133,33 @@ class Config(dict): map_['ircchannel'] = f'#{key}' if 'auth' not in map_: map_['auth'] = False + if 'module' not in map_: + map_['module'] = None + + # Load modules + modulePaths = {map_['module'] for map_ in obj['maps'].values() if 'module' in map_ and map_['module'] is not None} + modules = {} # path: str -> module: module + for i, path in enumerate(modulePaths): + try: + # Build a name that is virtually guaranteed to be unique across a process. + # Although importlib does not seem to perform any caching as of CPython 3.8, this is not guaranteed by spec. + spec = importlib.util.spec_from_file_location(f'http2irc-module-{id(self)}-{i}', path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + except Exception as e: # This is ugly, but exec_module can raise virtually any exception + raise InvalidConfig(f'Loading module {path!r} failed: {e!s}') + if not hasattr(module, 'process'): + raise InvalidConfig(f'Module {path!r} does not have a process function') + if not inspect.iscoroutinefunction(module.process): + raise InvalidConfig(f'Module {path!r} process attribute is not a coroutine function') + if len(inspect.signature(module.process).parameters) != 1: + raise InvalidConfig(f'Module {path!r} process function does not take exactly 1 parameter') + modules[path] = module + + # Replace module value in maps + for map_ in obj['maps'].values(): + if 'module' in map_ and map_['module'] is not None: + map_['module'] = modules[map_['module']] # Merge in what was read from the config file and set keys on self for key in ('logging', 'irc', 'web', 'maps'): @@ -370,7 +401,7 @@ class WebServer: self.messageQueue = messageQueue self.config = config - self._paths = {} # '/path' => ('#channel', auth) where auth is either False (no authentication) or the HTTP header value for basic auth + self._paths = {} # '/path' => ('#channel', auth, module) where auth is either False (no authentication) or the HTTP header value for basic auth self._app = aiohttp.web.Application() self._app.add_routes([aiohttp.web.post('/{path:.+}', self.post)]) @@ -379,7 +410,7 @@ class WebServer: self._configChanged = asyncio.Event() def update_config(self, config): - self._paths = {map_['webpath']: (map_['ircchannel'], f'Basic {base64.b64encode(map_["auth"].encode("utf-8")).decode("utf-8")}' if map_['auth'] else False) for map_ in config['maps'].values()} + self._paths = {map_['webpath']: (map_['ircchannel'], f'Basic {base64.b64encode(map_["auth"].encode("utf-8")).decode("utf-8")}' if map_['auth'] else False, map_['module']) for map_ in config['maps'].values()} needRebind = self.config['web'] != config['web'] self.config = config if needRebind: @@ -400,7 +431,7 @@ class WebServer: async def post(self, request): logging.info(f'Received request for {request.path!r}') try: - channel, auth = self._paths[request.path] + channel, auth, module = self._paths[request.path] except KeyError: logging.info(f'Bad request: no path {request.path!r}') raise aiohttp.web.HTTPNotFound() @@ -409,6 +440,24 @@ class WebServer: if not authHeader or authHeader != auth: logging.info(f'Bad request: authentication failed: {authHeader!r} != {auth}') raise aiohttp.web.HTTPForbidden() + if module is not None: + try: + message = await module.process(request) + except aiohttp.web.HTTPException as e: + raise e + except Exception as e: + logging.error(f'Bad request: exception in module process function: {e!s}') + raise aiohttp.web.HTTPBadRequest() + if '\r' in message or '\n' in message: + logging.error(f'Bad request: module process function returned message with linebreaks: {message!r}') + raise aiohttp.web.HTTPBadRequest() + else: + message = await self._default_process(request) + logging.debug(f'Putting message {message!r} for {channel} into message queue') + self.messageQueue.put_nowait((channel, message)) + raise aiohttp.web.HTTPOk() + + async def _default_process(self, request): try: message = await request.text() except Exception as e: @@ -423,9 +472,7 @@ class WebServer: if '\r' in message or '\n' in message: logging.info('Bad request: linebreaks in message') raise aiohttp.web.HTTPBadRequest() - logging.debug(f'Putting message {message!r} for {channel} into message queue') - self.messageQueue.put_nowait((channel, message)) - raise aiohttp.web.HTTPOk() + return message def configure_logging(config): |
