Merge branch 'feature/bottle' into 'staging'

bottle.py instead of custom web framework

See merge request s3lph/matemat!62
This commit is contained in:
s3lph 2020-02-03 22:46:16 +00:00
commit 10cf96d0fc
53 changed files with 652 additions and 3729 deletions

View file

@ -1,5 +1,5 @@
--- ---
image: s3lph/matemat-ci:20181107-02 image: s3lph/matemat-ci:20200203-01
stages: stages:
- test - test
@ -17,9 +17,9 @@ test:
stage: test stage: test
script: script:
- pip3 install -e . - pip3 install -e .
- sudo -u matemat python3.6 -m coverage run --rcfile=setup.cfg -m unittest discover matemat - sudo -u matemat python3.7 -m coverage run --rcfile=setup.cfg -m unittest discover matemat
- sudo -u matemat python3.6 -m coverage combine - sudo -u matemat python3.7 -m coverage combine
- sudo -u matemat python3.6 -m coverage report --rcfile=setup.cfg - sudo -u matemat python3.7 -m coverage report --rcfile=setup.cfg
codestyle: codestyle:
stage: test stage: test

2
doc

@ -1 +1 @@
Subproject commit 0fcf4244f90d93cbc7be8e670aeaa30288d24ae3 Subproject commit 8d6d6b6fece9d0b2dedc11ad41d4b96554a8ea36

View file

@ -1,27 +1,91 @@
from typing import Any, Dict, Iterable, Union from typing import Any, Dict, Iterable, Union
import sys import sys
import os.path
import bottle
from matemat.webserver import MatematWebserver from matemat.db import MatematDatabase
from matemat.webserver import parse_config_file from matemat.webserver import cron
from matemat.webserver.logger import Logger
from matemat.webserver.config import get_config, parse_config_file
from matemat.webserver.template import init as template_init
# Those imports are actually needed, as they implicitly register pagelets. # Those imports are actually needed, as they implicitly register pagelets.
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from matemat.webserver.pagelets import * from matemat.webserver.pagelets import *
def _init(config: Dict[str, Any]):
logger = Logger.instance()
# Set default values for missing config items
if 'InstanceName' not in config:
config['InstanceName'] = 'Matemat'
logger.warning('Property \'InstanceName\' not set, using \'Matemat\'')
if 'UploadDir' not in config:
config['UploadDir'] = './static/upload/'
logger.warning('Property \'UploadDir\' not set, using \'./static/upload/\'')
if 'DatabaseFile' not in config:
config['DatabaseFile'] = './matemat.db'
logger.warning('Property \'DatabaseFile\' not set, using \'./matemat.db\'')
if 'SmtpSendReceipts' not in config:
config['SmtpSendReceipts'] = '0'
logger.warning('Property \'SmtpSendReceipts\' not set, using \'0\'')
if config['SmtpSendReceipts'] == '1':
if 'SmtpFrom' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpFrom\' missing.')
raise KeyError()
if 'SmtpSubj' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpSubj\' missing.')
raise KeyError()
if 'SmtpHost' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpHost\' missing.')
raise KeyError()
if 'SmtpPort' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpPort\' missing.')
raise KeyError()
if 'SmtpUser' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpUser\' missing.')
raise KeyError()
if 'SmtpPass' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpPass\' missing.')
raise KeyError()
if 'SmtpEnforceTLS' not in config:
config['SmtpEnforceTLS'] = '1'
logger.warning('Property \'SmtpEnforceTLS\' not set, using \'1\'')
with MatematDatabase(config['DatabaseFile']):
# Connect to the database to create it and perform any schema migrations
pass
# Initialize Jinaj2 template system
template_init(config)
@bottle.route('/static/<filename:path>')
def serve_static_files(filename: str):
config = get_config()
staticroot = os.path.abspath(config['staticroot'])
return bottle.static_file(filename, root=staticroot)
def main(): def main():
# Use config file name from command line, if present # Use config file name from command line, if present, and parse it
configfile: Union[str, Iterable[str]] = '/etc/matemat.conf' configfile: Union[str, Iterable[str]] = '/etc/matemat.conf'
if len(sys.argv) > 1: if len(sys.argv) > 1:
configfile = sys.argv[1:] configfile = sys.argv[1:]
parse_config_file(configfile)
# Parse the config file config = get_config()
config: Dict[str, Any] = parse_config_file(configfile)
_init(config)
host: str = config['listen']
port: int = int(str(config['port']))
# noinspection PyUnresolvedReferences
from matemat.webserver.pagelets.receipt_smtp_cron import receipt_smtp_cron
# Start the web server # Start the web server
MatematWebserver(**config).start() bottle.run(host=host, port=port)
# Stop cron
cron.shutdown()
if __name__ == '__main__': if __name__ == '__main__':

View file

@ -6,7 +6,4 @@ API that can be used by 'pagelets' - single pages of a web service. If a reques
server will attempt to serve the request with a static resource in a previously configured webroot directory. server will attempt to serve the request with a static resource in a previously configured webroot directory.
""" """
from .requestargs import RequestArgument, RequestArguments from .logger import Logger
from .responses import PageletResponse, RedirectResponse, TemplateResponse
from .httpd import MatematWebserver, HttpHandler, pagelet, pagelet_init, pagelet_cron
from .config import parse_config_file

View file

@ -7,6 +7,9 @@ import logging
from configparser import ConfigParser from configparser import ConfigParser
config: Dict[str, Any] = dict()
def parse_logging(symbolic_level: str, symbolic_target: str) -> Tuple[int, logging.Handler]: def parse_logging(symbolic_level: str, symbolic_target: str) -> Tuple[int, logging.Handler]:
""" """
Parse the LogLevel and LogTarget from configuration. Parse the LogLevel and LogTarget from configuration.
@ -45,32 +48,32 @@ def parse_logging(symbolic_level: str, symbolic_target: str) -> Tuple[int, loggi
return level, target return level, target
def parse_config_file(paths: Union[str, Iterable[str]]) -> Dict[str, Any]: def parse_config_file(paths: Union[str, Iterable[str]]) -> None:
""" """
Parse the configuration file at the given path. Parse the configuration file at the given path.
:param paths: The config file(s) to parse. :param paths: The config file(s) to parse.
:return: A dictionary containing the parsed configuration. :return: A dictionary containing the parsed configuration.
""" """
global config
# Set up default values # Set up default values
config: Dict[str, Any] = {
# Address to listen on # Address to listen on
'listen': '::', config['listen'] = '::'
# TCP port to listen on # TCP port to listen on
'port': 80, config['port'] = 80
# Root directory of statically served content # Root directory of statically served content
'staticroot': '/var/matemat/static', config['staticroot'] = '/var/matemat/static'
# Root directory of Jinja2 templates # Root directory of Jinja2 templates
'templateroot': '/var/matemat/templates', config['templateroot'] = '/var/matemat/templates'
# Log level # Log level
'log_level': logging.INFO, config['log_level'] = logging.INFO
# Log target: An IO stream (stderr, stdout, ...) or a filename # Log target: An IO stream (stderr, stdout, ...) or a filename
'log_handler': logging.StreamHandler(), config['log_handler'] = logging.StreamHandler()
# Variables passed to pagelets # Variables passed to pagelets
'pagelet_variables': dict(), config['pagelet_variables'] = dict()
# Statically configured headers # Statically configured headers
'headers': dict() config['headers'] = dict()
}
# Initialize the config parser # Initialize the config parser
parser: ConfigParser = ConfigParser() parser: ConfigParser = ConfigParser()
@ -108,4 +111,12 @@ def parse_config_file(paths: Union[str, Iterable[str]]) -> Dict[str, Any]:
for k, v in parser['HttpHeaders'].items(): for k, v in parser['HttpHeaders'].items():
config['headers'][k] = v config['headers'][k] = v
def get_config() -> Dict[str, Any]:
global config
return config return config
def get_app_config() -> Dict[str, Any]:
global config
return config['pagelet_variables']

92
matemat/webserver/cron.py Normal file
View file

@ -0,0 +1,92 @@
from typing import Callable
from datetime import timedelta
from threading import Event, Timer, Thread
from matemat.webserver import Logger
_CRON_STATIC_EVENT: Event = Event()
def shutdown(*_args, **_kwargs):
_CRON_STATIC_EVENT.set()
class _GlobalEventTimer(Thread):
"""
A timer similar to threading.Timer, except that waits on an externally supplied threading.Event instance,
therefore allowing all timers waiting on the same event to be cancelled at once.
"""
def __init__(self, interval: float, event: Event, fun, *args, **kwargs):
"""
Create a new _GlobalEventTimer.
:param interval: The delay after which to run the function.
:param event: The external threading.Event to wait on.
:param fun: The function to call.
:param args: The positional arguments to pass to the function.
:param kwargs: The keyword arguments to pass to the function.
"""
Thread.__init__(self)
self.interval = interval
self.fun = fun
self.args = args if args is not None else []
self.kwargs = kwargs if kwargs is not None else {}
self.event = event
def run(self):
self.event.wait(self.interval)
if not self.event.is_set():
self.fun(*self.args, **self.kwargs)
# Do NOT call event.set(), as done in threading.Timer, as that would cancel all other timers
def cron(weeks: int = 0,
days: int = 0,
hours: int = 0,
seconds: int = 0,
minutes: int = 0,
milliseconds: int = 0,
microseconds: int = 0):
"""
Annotate a function to act as a cron function. The function will be called in a regular interval, defined
by the arguments passed to the decorator, which are passed to a timedelta object.
:param weeks: Number of weeks in the interval.
:param days: Number of days in the interval.
:param hours: Number of hours in the interval.
:param seconds: Number of seconds in the interval.
:param minutes: Number of minutes in the interval.
:param milliseconds: Number of milliseconds in the interval.
:param microseconds: Number of microseconds in the interval.
"""
def cron_wrapper(fun: Callable[[], None]):
# Create the timedelta object
delta: timedelta = timedelta(weeks=weeks,
days=days,
hours=hours,
seconds=seconds,
minutes=minutes,
milliseconds=milliseconds,
microseconds=microseconds)
# This function is called once in the specified interval
def cron():
logger = Logger.instance()
# Reschedule the job
t: Timer = _GlobalEventTimer(delta.total_seconds(), _CRON_STATIC_EVENT, cron)
t.start()
# Actually run the task
logger.info('Executing cron job "%s"', fun.__name__)
try:
fun()
logger.info('Completed cron job "%s"', fun.__name__)
except BaseException as e:
logger.exception('Cron job "%s" failed:', fun.__name__, exc_info=e)
# Set a timer to run the cron job after the specified interval
timer: Timer = _GlobalEventTimer(delta.total_seconds(), _CRON_STATIC_EVENT, cron)
timer.start()
return cron_wrapper

View file

@ -1,636 +0,0 @@
from typing import Any, Callable, Dict, Set, Tuple, Type, Union
import logging
import os
import socket
import mimetypes
import magic
from socketserver import TCPServer
from http.server import HTTPServer, BaseHTTPRequestHandler
from http.cookies import SimpleCookie
from uuid import uuid4
from datetime import datetime, timedelta
from threading import Event, Timer, Thread
import jinja2
from matemat import __version__ as matemat_version
from matemat.exceptions import HttpException
from matemat.webserver import RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.webserver.util import parse_args
from matemat.util.currency_format import format_chf
#
# Python internal class hacks
#
# Enable IPv6 support (IPv6/IPv4 dual-stack support should be implicitly enabled)
TCPServer.address_family = socket.AF_INET6
# Dictionary to hold registered pagelet paths and their handler functions
_PAGELET_PATHS: Dict[str, Callable[[str, # HTTP method (GET, POST, ...)
str, # Request path
RequestArguments, # HTTP Request arguments
Dict[str, Any], # Session vars
Dict[str, str], # Response headers
Dict[str, str]], # Items from the [Pagelets] section in the config file
Union[ # Return type: either a response body, or a redirect
bytes, str, # Response body: will assign HTTP/1.0 200 OK
PageletResponse, # A generic response
]]] = dict()
# The pagelet initialization functions, to be executed upon startup
_PAGELET_INIT_FUNCTIONS: Set[Callable[[Dict[str, str], logging.Logger], None]] = set()
_PAGELET_CRON_STATIC_EVENT: Event = Event()
_PAGELET_CRON_RUNNER: Callable[[Callable[[Dict[str, str], jinja2.Environment, logging.Logger], None]], None] = None
# Inactivity timeout for client sessions
_SESSION_TIMEOUT: int = 3600
_MAX_POST: int = 1_000_000
def pagelet(path: str):
"""
Annotate a function to act as a pagelet (part of a website). The function will be called if a request is made to
the path specified as argument to the annotation.
The function must have the following signature:
(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str])
-> Union[bytes, str, Tuple[int, str]]
method: The HTTP method (GET, POST) that was used.
path: The path that was requested.
args: The arguments that were passed with the request (as GET or POST arguments).
session_vars: The session storage. May be read from and written to.
headers: The dictionary of HTTP response headers. Add headers you wish to send with the response.
config: The dictionary of variables read from the [Pagelets] section of the configuration file.
returns: One of the following:
- A HTTP Response body as str or bytes
- A PageletResponse class instance: An instance of (a subclass of)
matemat.webserver.PageletResponse, e.g. encapsulating a redirect or a Jinja2 template.
raises: HttpException: If a non-200 HTTP status code should be returned
:param path: The path to register the function for.
"""
def http_handler(fun: Callable[[str,
str,
RequestArguments,
Dict[str, Any],
Dict[str, str],
Dict[str, str]],
Union[
bytes, str,
PageletResponse
]]):
# Add the function to the dict of pagelets
_PAGELET_PATHS[path] = fun
# Don't change the function itself at all
return fun
# Return the inner function (Python requires a "real" function annotation to not have any arguments except
# the function itself)
return http_handler
def pagelet_init(fun: Callable[[Dict[str, str], logging.Logger], None]):
"""
Annotate a function to act as a pagelet initialization function. The function will be called when the webserver is
started. The function should set up everything required for pagelets to operate correctly, e.g. providing default
values for configuration items, or performing database schema migrations.
Any exception thrown by an initialization function will cause the web server to seize operation. Multiple
initialization functions can be used.
The function must have the following signature:
(config: Dict[str, str], logger: logging.Logger) -> None
config: The mutable dictionary of variables read from the [Pagelets] section of the configuration file.
logger: The server's logger instance.
returns: Nothing.
:param fun: The function to annotate
"""
_PAGELET_INIT_FUNCTIONS.add(fun)
class _GlobalEventTimer(Thread):
"""
A timer similar to threading.Timer, except that waits on an externally supplied threading.Event instance,
therefore allowing all timers waiting on the same event to be cancelled at once.
"""
def __init__(self, interval: float, event: Event, fun, *args, **kwargs):
"""
Create a new _GlobalEventTimer.
:param interval: The delay after which to run the function.
:param event: The external threading.Event to wait on.
:param fun: The function to call.
:param args: The positional arguments to pass to the function.
:param kwargs: The keyword arguments to pass to the function.
"""
Thread.__init__(self)
self.interval = interval
self.fun = fun
self.args = args if args is not None else []
self.kwargs = kwargs if kwargs is not None else {}
self.event = event
def run(self):
self.event.wait(self.interval)
if not self.event.is_set():
self.fun(*self.args, **self.kwargs)
# Do NOT call event.set(), as done in threading.Timer, as that would cancel all other timers
def pagelet_cron(weeks: int = 0,
days: int = 0,
hours: int = 0,
seconds: int = 0,
minutes: int = 0,
milliseconds: int = 0,
microseconds: int = 0):
"""
Annotate a function to act as a pagelet cron function. The function will be called in a regular interval, defined
by the arguments passed to the decorator, which are passed to a timedelta object.
The function must have the following signature:
(config: Dict[str, str], jinja_env: jinja2.Environment, logger: logging.Logger) -> None
config: The mutable dictionary of variables read from the [Pagelets] section of the configuration file.
jinja_env: The Jinja2 environment used by the web server.
logger: The server's logger instance.
returns: Nothing.
:param weeks: Number of weeks in the interval.
:param days: Number of days in the interval.
:param hours: Number of hours in the interval.
:param seconds: Number of seconds in the interval.
:param minutes: Number of minutes in the interval.
:param milliseconds: Number of milliseconds in the interval.
:param microseconds: Number of microseconds in the interval.
"""
def cron_wrapper(fun: Callable[[Dict[str, str], jinja2.Environment, logging.Logger], None]):
# Create the timedelta object
delta: timedelta = timedelta(weeks=weeks,
days=days,
hours=hours,
seconds=seconds,
minutes=minutes,
milliseconds=milliseconds,
microseconds=microseconds)
# This function is called once in the specified interval
def cron():
# Set a new timer
t: Timer = _GlobalEventTimer(delta.total_seconds(), _PAGELET_CRON_STATIC_EVENT, cron)
t.start()
# Have the cron job be picked up by the cron runner provided by the web server
if _PAGELET_CRON_RUNNER is not None:
_PAGELET_CRON_RUNNER(fun)
# Set a timer to run the cron job after the specified interval
timer: Timer = _GlobalEventTimer(delta.total_seconds(), _PAGELET_CRON_STATIC_EVENT, cron)
timer.start()
return cron_wrapper
class MatematHTTPServer(HTTPServer):
"""
A http.server.HTTPServer subclass that acts as a container for data that must be persistent between requests.
"""
def __init__(self,
server_address: Any,
handler: Type[BaseHTTPRequestHandler],
staticroot: str,
templateroot: str,
pagelet_variables: Dict[str, str],
static_headers: Dict[str, str],
log_level: int,
log_handler: logging.Handler,
bind_and_activate: bool = True) -> None:
super().__init__(server_address, handler, bind_and_activate)
# Resolve webroot directory
self.webroot = os.path.abspath(staticroot)
# Set up session vars dict
self.session_vars: Dict[str, Tuple[datetime, Dict[str, Any]]] = dict()
# Set up pagelet arguments dict
self.pagelet_variables = pagelet_variables
# Set up static HTTP Response headers
self.static_headers = static_headers
# Set up the Jinja2 environment
self.jinja_env: jinja2.Environment = jinja2.Environment(
loader=jinja2.FileSystemLoader(os.path.abspath(templateroot)),
autoescape=jinja2.select_autoescape(default=True),
)
self.jinja_env.filters['chf'] = format_chf
# Set up logger
self.logger: logging.Logger = logging.getLogger('matemat.webserver')
self.logger.setLevel(log_level)
# Set up the log handler's (obtained from config parsing) format string
log_handler.setFormatter(logging.Formatter('%(asctime)s %(name)s [%(levelname)s]: %(message)s'))
self.logger.addHandler(log_handler)
class MatematWebserver(object):
"""
Then main webserver class, internally uses Python's http.server.
The server will serve a pagelet, if one is defined for a request path, else it will attempt to serve a static
resource from the webroot.
Usage:
# Listen on all interfaces on port 80 (dual-stack IPv6/IPv4)
server = MatematWebserver('::', 80, webroot='/var/www/matemat')
# Start the server. This call blocks while the server is running.
server.start()
"""
def __init__(self,
listen: str,
port: int,
staticroot: str,
templateroot: str,
pagelet_variables: Dict[str, str],
headers: Dict[str, str],
log_level: int,
log_handler: logging.Handler) -> None:
"""
Instantiate a MatematWebserver.
:param listen: The IPv4 or IPv6 address to listen on.
:param port: The TCP port to listen on.
:param staticroot: Path to the static webroot directory.
:param templateroot: Path to the Jinja2 templates root directory.
:param pagelet_variables: Dictionary of variables to pass to pagelet functions.
:param headers: Dictionary of statically configured headers.
:param log_level: The log level, as defined in the builtin logging module.
:param log_handler: The logging handler.
"""
# IPv4 address detection heuristic
if ':' not in listen and '.' in listen:
# Rewrite IPv4 address to IPv6-mapped form
listen = f'::ffff:{listen}'
# Create the http server
self._httpd = MatematHTTPServer((listen, port),
HttpHandler,
staticroot,
templateroot,
pagelet_variables,
headers,
log_level,
log_handler)
def start(self) -> None:
"""
Call all pagelet initialization functions and start the web server. This call blocks while the server is
running. If any exception is raised in the initialization phase, the program is terminated with a non-zero
exit code.
"""
global _PAGELET_CRON_RUNNER
try:
try:
# Run all pagelet initialization functions
for fun in _PAGELET_INIT_FUNCTIONS:
fun(self._httpd.pagelet_variables, self._httpd.logger)
# Set pagelet cron runner to self
_PAGELET_CRON_RUNNER = self._cron_runner
except BaseException as e:
# If an error occurs, log it and terminate
self._httpd.logger.exception(e)
self._httpd.logger.critical('An initialization pagelet raised an error. Stopping.')
raise e
# If pagelet initialization went fine, start the HTTP server
self._httpd.serve_forever()
finally:
# Cancel all cron timers at once when the webserver is shutting down
_PAGELET_CRON_STATIC_EVENT.set()
def _cron_runner(self, fun: Callable[[Dict[str, str], jinja2.Environment, logging.Logger], None]):
self._httpd.logger.info('Executing cron job "%s"', fun.__name__)
try:
fun(self._httpd.pagelet_variables,
self._httpd.jinja_env,
self._httpd.logger)
self._httpd.logger.info('Completed cron job "%s"', fun.__name__)
except BaseException as e:
self._httpd.logger.exception('Cron job "%s" failed:', fun.__name__, exc_info=e)
class HttpHandler(BaseHTTPRequestHandler):
"""
HTTP Request handler.
This class parses HTTP requests, and calls the appropriate pagelets, or fetches a static resource from the webroot
directory.
"""
def __init__(self, request: bytes, client_address: Tuple[str, int], server: HTTPServer) -> None:
super().__init__(request, client_address, server)
self.server: MatematHTTPServer
@property
def server_version(self) -> str:
return f'matemat/{matemat_version}'
def _start_session(self) -> Tuple[str, datetime]:
"""
Start a new session, or resume the session identified by the session cookie sent in the HTTP request.
:return: A tuple consisting of the session ID (a UUID string), and the session timeout date.
"""
# Reference date for session timeout
now = datetime.utcnow()
# Parse cookies sent by the client
cookiestring = '\n'.join(self.headers.get_all('Cookie', failobj=[]))
cookie = SimpleCookie()
cookie.load(cookiestring)
# Read the client's session ID, if any
session_id = str(cookie['matemat_session_id'].value) if 'matemat_session_id' in cookie else None
# If there is no active session, create a new session ID
if session_id is None or session_id not in self.server.session_vars:
session_id = str(uuid4())
self.server.logger.debug('Started session %s', session_id)
# Check for session timeout
if session_id in self.server.session_vars and self.server.session_vars[session_id][0] < now:
self._end_session(session_id)
self.server.logger.debug('Session %s timed out', session_id)
raise TimeoutError('Session timed out.')
# Update or initialize the session timeout
if session_id not in self.server.session_vars:
self.server.session_vars[session_id] = (now + timedelta(seconds=_SESSION_TIMEOUT)), dict()
else:
self.server.session_vars[session_id] = \
(now + timedelta(seconds=_SESSION_TIMEOUT), self.server.session_vars[session_id][1])
# Return the session ID and timeout
return session_id, self.server.session_vars[session_id][0]
def _end_session(self, session_id: str) -> None:
"""
Destroy a session identified by the session ID.
:param session_id: ID of the session to destroy.
"""
if session_id in self.server.session_vars:
del self.server.session_vars[session_id]
def _parse_pagelet_result(self,
pagelet_res: Union[bytes, # Response body as bytes
str, # Response body as str
PageletResponse], # Encapsulated or unresolved response body
headers: Dict[str, str]) \
-> Tuple[int, bytes]:
"""
Process the return value of a pagelet function call.
:param pagelet_res: The pagelet return value.
:param headers: The dict of HTTP response headers, needed for setting the redirect header.
:return: The HTTP Response status code (an int) and body (a bytes).
:raises TypeError: If the pagelet result was not in the expected form.
"""
# The HTTP Response Status Code, defaults to 200 OK
hsc: int = 200
# The HTTP Response body, defaults to empty
data: bytes = bytes()
# If the response is a bytes object, it is used without further modification
if isinstance(pagelet_res, bytes):
data = pagelet_res
# If the response is a str object, it is encoded into a bytes object
elif isinstance(pagelet_res, str):
data = pagelet_res.encode('utf-8')
# If the response is a PageletResponse object, the status code is extracted. Generation of the body depends
# on the subtype
elif isinstance(pagelet_res, PageletResponse):
hsc = pagelet_res.status
# If the object is a RedirectResponse instance, no body is needed
if isinstance(pagelet_res, RedirectResponse):
headers['Location'] = pagelet_res.location
# If the object is a TemplateRespinse instance, pass the Jinja2 environment instance for rendering
elif isinstance(pagelet_res, TemplateResponse):
# noinspection PyProtectedMember
data = pagelet_res._render(self.server.jinja_env)
# else: Empty body
else:
raise TypeError(f'Return value of pagelet not understood: {pagelet_res}')
# Return the resulting status code and body
return hsc, data
def _handle(self, method: str, path: str, args: RequestArguments) -> None:
"""
Handle a HTTP request by either dispatching it to the appropriate pagelet or by serving a static resource.
:param method: The HTTP request method (GET, POST).
:param path: The request path without GET arguments.
:param args: Arguments sent with the request. This includes GET and POST arguments, where the POST arguments
take precedence.
"""
# Start or resume a session; redirect to / on session timeout
try:
session_id, timeout = self._start_session()
except TimeoutError:
self.send_response(302)
self.send_header('Set-Cookie', 'matemat_session_id=; expires=Thu, 01 Jan 1970 00:00:00 GMT')
self.send_header('Location', '/')
self.end_headers()
return
self.session_id: str = session_id
# Set all static headers. These can still be overwritten by a pagelet
headers: Dict[str, str] = dict()
for k, v in self.server.static_headers.items():
headers[k] = v
# Call a pagelet function, if one is registered for the requested path
if path in _PAGELET_PATHS:
# Call the pagelet function
pagelet_res = _PAGELET_PATHS[path](method,
path,
args,
self.session_vars,
headers,
self.server.pagelet_variables)
# Parse the pagelet's return value, vielding a HTTP status code and a response body
hsc, data = self._parse_pagelet_result(pagelet_res, headers)
# Send the HTTP status code
self.send_response(hsc)
# Format the session cookie timeout string and send the session cookie header
expires = timeout.strftime('%a, %d %b %Y %H:%M:%S GMT')
headers['Set-Cookie'] = f'matemat_session_id={session_id}; expires={expires}'
# Disable caching
headers['Cache-Control'] = 'no-cache'
# Compute the body length and add the appropriate header
headers['Content-Length'] = str(len(data))
# If the pagelet did not set its own Content-Type header, use libmagic to guess an appropriate one
if 'Content-Type' not in headers:
try:
filemagic: magic.FileMagic = magic.detect_from_content(data)
mimetype: str = filemagic.mime_type
charset: str = filemagic.encoding
except ValueError:
mimetype = 'application/octet-stream'
charset = 'binary'
# Only append the charset if it is not "binary"
if charset == 'binary':
headers['Content-Type'] = mimetype
else:
headers['Content-Type'] = f'{mimetype}; charset={charset}'
# Send all headers set by the pagelet
for name, value in headers.items():
self.send_header(name, value)
# End the header section and write the body
self.end_headers()
self.wfile.write(data)
else:
# No pagelet function for this path, try a static serve instead
# Only HTTP GET is allowed, else reply with a 'Method Not Allowed' header
if method != 'GET':
self.send_error(405)
self.end_headers()
return
# Create the absolute resource path, resolving '..'
filepath: str = os.path.abspath(os.path.join(self.server.webroot, path[1:]))
# Make sure the file is actually inside the webroot directory and that it exists
if os.path.commonpath([filepath, self.server.webroot]) == self.server.webroot and os.path.exists(filepath):
# Parse the If-Modified-Since header to check whether the browser can reuse cached content
datestr: str = self.headers.get('If-Modified-Since', 'Thu, 01 Jan 1970 00:00:00 GMT')
maxage: datetime = datetime.strptime(datestr, '%a, %d %b %Y %H:%M:%S %Z')
# Get file modification time
filestat: int = int(os.path.getmtime(filepath))
# Create UTC datetime object from mtime
fileage: datetime = datetime.utcfromtimestamp(filestat)
# If the file has not been replaced by a newer version than requested by the client, send a 304 response
if fileage <= maxage:
self.send_response(304, 'Not Modified')
self.send_header('Content-Length', '0')
self.end_headers()
return
# Open and read the file
with open(filepath, 'rb') as f:
data = f.read()
# File read successfully, send 'OK' header
self.send_response(200)
# Guess the MIME type by file extension, or use libmagic as fallback
# Use libmagic to guess the charset
try:
exttype: str = mimetypes.guess_type(filepath)[0]
filemagic: magic.FileMagic = magic.detect_from_filename(filepath)
mimetype: str = exttype if exttype is not None else filemagic.mime_type
charset: str = filemagic.encoding
except ValueError:
mimetype = 'application/octet-stream'
charset = 'binary'
# Send content type and length header. Only set the charset if it's not "binary"
if charset == 'binary':
headers['Content-Type'] = mimetype
else:
headers['Content-Type'] = f'{mimetype}; charset={charset}'
headers['Content-Length'] = str(len(data))
headers['Last-Modified'] = fileage.strftime('%a, %d %b %Y %H:%M:%S GMT')
headers['Cache-Control'] = 'max-age=1'
# Send all headers
for name, value in headers.items():
self.send_header(name, value)
self.end_headers()
# Send the requested resource as response body
self.wfile.write(data)
else:
# File does not exist or path points outside the webroot directory
self.send_response(404)
self.end_headers()
def _handle_request(self, method: str, path: str, args: RequestArguments):
try:
self._handle(method, path, args)
# Special handling for some errors
except HttpException as e:
self.send_error(e.status, e.title, e.message)
if 500 <= e.status < 600:
self.send_error(500, 'Internal Server Error')
self.server.logger.exception('', exc_info=e)
else:
self.server.logger.debug('', exc_info=e)
except PermissionError as e:
self.send_error(403, 'Forbidden')
self.server.logger.debug('', exc_info=e)
except ValueError as e:
self.send_error(400, 'Bad Request')
self.server.logger.debug('', exc_info=e)
except BaseException as e:
# Generic error handling
self.send_error(500, 'Internal Server Error')
self.server.logger.exception('', e.args, e)
# noinspection PyPep8Naming
def do_GET(self) -> None:
"""
Called by BasicHTTPRequestHandler for GET requests.
"""
# Parse the request and hand it to the handle function
try:
path, args = parse_args(self.path)
self._handle_request('GET', path, args)
except ValueError as e:
self.send_error(400, 'Bad Request')
self.server.logger.debug('', exc_info=e)
# noinspection PyPep8Naming
def do_POST(self) -> None:
"""
Called by BasicHTTPRequestHandler for POST requests.
"""
try:
# Read the POST body, if it exists, and its MIME type is application/x-www-form-urlencoded
clen: int = int(str(self.headers.get('Content-Length', failobj='0')))
if clen > _MAX_POST:
# Return a 413 error page if the request size exceeds boundaries
self.send_error(413, 'Payload Too Large')
self.server.logger.debug('', exc_info=HttpException(413, 'Payload Too Large'))
return
ctype: str = self.headers.get('Content-Type', failobj='application/octet-stream')
post: bytes = self.rfile.read(clen)
# Parse the request and hand it to the handle function
path, args = parse_args(self.path, postbody=post, enctype=ctype)
self._handle_request('POST', path, args)
except ValueError as e:
self.send_error(400, 'Bad Request')
self.server.logger.debug('', exc_info=e)
@property
def session_vars(self) -> Dict[str, Any]:
"""
Get the session variables for the current session.
:return: Dictionary of named session variables.
"""
return self.server.session_vars[self.session_id][1]
def log_request(self, code='-', size='-'):
self.server.logger.info('%s %s from %s', self.requestline, code, self.client_address)
def log_error(self, fmt: str, *args):
self.server.logger.warning(f'{self.requestline} from {self.client_address}: {fmt}', *args)
def log_message(self, fmt, *args):
self.server.logger.info(fmt, *args)

View file

@ -0,0 +1,25 @@
from typing import Any, Dict
import logging
from matemat.webserver.config import get_config
class Logger(logging.Logger):
logger: 'Logger' = None
def __init__(self):
super().__init__(name='matemat')
config: Dict[str, Any] = get_config()
log_handler: logging.Handler = config['log_handler']
self.setLevel(config['log_level'])
# Set up the log handler's (obtained from config parsing) format string
log_handler.setFormatter(logging.Formatter('%(asctime)s %(module)s [%(levelname)s]: %(message)s'))
self.addHandler(log_handler)
@classmethod
def instance(cls) -> 'Logger':
if cls.logger is None:
cls.logger = Logger()
return cls.logger

View file

@ -4,7 +4,6 @@ This package contains the pagelet functions served by the Matemat software.
A new pagelet function must be imported here to be automatically loaded when the server is started. A new pagelet function must be imported here to be automatically loaded when the server is started.
""" """
from .initialization import initialization
from .main import main_page from .main import main_page
from .login import login_page from .login import login_page
from .logout import logout from .logout import logout
@ -16,4 +15,3 @@ from .moduser import moduser
from .modproduct import modproduct from .modproduct import modproduct
from .userbootstrap import userbootstrap from .userbootstrap import userbootstrap
from .statistics import statistics from .statistics import statistics
from .receipt_smtp_cron import receipt_smtp_cron

View file

@ -1,69 +1,67 @@
from typing import Any, Dict, Union
import os import os
from shutil import copyfile
import magic
from io import BytesIO from io import BytesIO
from PIL import Image from shutil import copyfile
import magic
from PIL import Image
from bottle import get, post, abort, redirect, request, FormsDict
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.util.currency_format import parse_chf
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.db.primitives import User, ReceiptPreference from matemat.db.primitives import User, ReceiptPreference
from matemat.exceptions import AuthenticationError, DatabaseConsistencyError, HttpException from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
from matemat.util.currency_format import parse_chf
from matemat.webserver import session, template
from matemat.webserver.config import get_app_config
@pagelet('/admin') @get('/admin')
def admin(method: str, @post('/admin')
path: str, def admin():
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[str, bytes, PageletResponse]:
""" """
The admin panel, shows a user's own settings. Additionally, for administrators, settings to modify other users and The admin panel, shows a user's own settings. Additionally, for administrators, settings to modify other users and
products are shown. products are shown.
""" """
config = get_app_config()
session_id: str = session.start()
# If no user is logged in, redirect to the login page # If no user is logged in, redirect to the login page
if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars: if not session.has(session_id, 'authentication_level') or not session.has(session_id, 'authenticated_user'):
return RedirectResponse('/login') redirect('/login')
authlevel: int = session_vars['authentication_level'] authlevel: int = session.get(session_id, 'authentication_level')
uid: int = session_vars['authenticated_user'] uid: int = session.get(session_id, 'authenticated_user')
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1) # Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
if authlevel < 2: if authlevel < 2:
raise HttpException(403) abort(403)
# Connect to the database # Connect to the database
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user # Fetch the authenticated user
user = db.get_user(uid) user = db.get_user(uid)
# If the POST request contains a "change" parameter, delegate the change handling to the function below # If the POST request contains a "change" parameter, delegate the change handling to the function below
if method == 'POST' and 'change' in args: if request.method == 'POST' and 'change' in request.params:
handle_change(args, user, db, config) handle_change(request.params, request.files, user, db)
# If the POST request contains an "adminchange" parameter, delegate the change handling to the function below # If the POST request contains an "adminchange" parameter, delegate the change handling to the function below
elif method == 'POST' and 'adminchange' in args and user.is_admin: elif request.method == 'POST' and 'adminchange' in request.params and user.is_admin:
handle_admin_change(args, db, config) handle_admin_change(request.params, request.files, db)
# Fetch all existing users and products from the database # Fetch all existing users and products from the database
users = db.list_users() users = db.list_users()
products = db.list_products() products = db.list_products()
# Render the "Admin/Settings" page # Render the "Admin/Settings" page
return TemplateResponse('admin.html', return template.render('admin.html',
authuser=user, authlevel=authlevel, users=users, products=products, authuser=user, authlevel=authlevel, users=users, products=products,
receipt_preference_class=ReceiptPreference, receipt_preference_class=ReceiptPreference,
setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts']) setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts'])
def handle_change(args: RequestArguments, user: User, db: MatematDatabase, config: Dict[str, str]) -> None: def handle_change(args: FormsDict, files: FormsDict, user: User, db: MatematDatabase) -> None:
""" """
Write the changes requested by a user for its own account to the database. Write the changes requested by a user for its own account to the database.
:param args: The RequestArguments object passed to the pagelet. :param args: The FormsDict object passed to the pagelet.
:param user: The user to edit. :param user: The user to edit.
:param db: The database facade where changes are written to. :param db: The database facade where changes are written to.
:param config: The dictionary of config file entries from the [Pagelets] section.
""" """
config = get_app_config()
try: try:
# Read the type of change requested by the user, then switch over it # Read the type of change requested by the user, then switch over it
change = str(args.change) change = str(args.change)
@ -122,10 +120,10 @@ def handle_change(args: RequestArguments, user: User, db: MatematDatabase, confi
# The user requested an avatar change # The user requested an avatar change
elif change == 'avatar': elif change == 'avatar':
# The new avatar field must be present # The new avatar field must be present
if 'avatar' not in args: if 'avatar' not in files:
return return
# Read the raw image data from the request # Read the raw image data from the request
avatar = bytes(args.avatar) avatar = files.avatar.file.read()
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded # Only process the image, if its size is more than zero. Zero size means no new image was uploaded
if len(avatar) == 0: if len(avatar) == 0:
return return
@ -150,14 +148,14 @@ def handle_change(args: RequestArguments, user: User, db: MatematDatabase, confi
raise ValueError('an argument not a string') raise ValueError('an argument not a string')
def handle_admin_change(args: RequestArguments, db: MatematDatabase, config: Dict[str, str]): def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
""" """
Write the changes requested by an admin for users of products. Write the changes requested by an admin for users of products.
:param args: The RequestArguments object passed to the pagelet. :param args: The RequestArguments object passed to the pagelet.
:param db: The database facade where changes are written to. :param db: The database facade where changes are written to.
:param config: The dictionary of config file entries from the [Pagelets] section.
""" """
config = get_app_config()
try: try:
# Read the type of change requested by the admin, then switch over it # Read the type of change requested by the admin, then switch over it
change = str(args.adminchange) change = str(args.adminchange)
@ -202,11 +200,10 @@ def handle_admin_change(args: RequestArguments, db: MatematDatabase, config: Dic
# Create the user in the database # Create the user in the database
newproduct = db.create_product(name, price_member, price_non_member) newproduct = db.create_product(name, price_member, price_non_member)
# If a new product image was uploaded, process it # If a new product image was uploaded, process it
if 'image' in args and len(bytes(args.image)) > 0: image = files.image.file.read() if 'image' in files else None
# Read the raw image data from the request if image is None or len(image) == 0:
avatar = bytes(args.image)
# Detect the MIME type # Detect the MIME type
filemagic: magic.FileMagic = magic.detect_from_content(avatar) filemagic: magic.FileMagic = magic.detect_from_content(image)
if not filemagic.mime_type.startswith('image/'): if not filemagic.mime_type.startswith('image/'):
return return
# Create the absolute path of the upload directory # Create the absolute path of the upload directory
@ -214,7 +211,7 @@ def handle_admin_change(args: RequestArguments, db: MatematDatabase, config: Dic
os.makedirs(abspath, exist_ok=True) os.makedirs(abspath, exist_ok=True)
try: try:
# Parse the image data # Parse the image data
image: Image = Image.open(BytesIO(avatar)) image: Image = Image.open(BytesIO(image))
# Resize the image to 150x150 # Resize the image to 150x150
image.thumbnail((150, 150), Image.LANCZOS) image.thumbnail((150, 150), Image.LANCZOS)
# Write the image to the file # Write the image to the file
@ -250,10 +247,10 @@ def handle_admin_change(args: RequestArguments, db: MatematDatabase, config: Dic
elif change == 'defaultimg': elif change == 'defaultimg':
# Iterate the possible images to set # Iterate the possible images to set
for category in 'users', 'products': for category in 'users', 'products':
if category not in args: if category not in files:
continue continue
# Read the raw image data from the request # Read the raw image data from the request
default: bytes = bytes(args[category]) default: bytes = files[category].file.read()
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded # Only process the image, if its size is more than zero. Zero size means no new image was uploaded
if len(default) == 0: if len(default) == 0:
continue continue

View file

@ -1,33 +1,31 @@
from typing import Any, Dict, Union from bottle import get, post, redirect, request
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.webserver import session
from matemat.webserver.config import get_app_config
@pagelet('/buy') @get('/buy')
def buy(method: str, @post('/buy')
path: str, def buy():
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[str, bytes, PageletResponse]:
""" """
The purchasing mechanism. Called by the user clicking an item on the product list. The purchasing mechanism. Called by the user clicking an item on the product list.
""" """
config = get_app_config()
session_id: str = session.start()
# If no user is logged in, redirect to the main page, as a purchase must always be bound to a user # If no user is logged in, redirect to the main page, as a purchase must always be bound to a user
if 'authenticated_user' not in session_vars: if not session.has(session_id, 'authenticated_user'):
return RedirectResponse('/') redirect('/')
# Connect to the database # Connect to the database
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user from the database # Fetch the authenticated user from the database
uid: int = session_vars['authenticated_user'] uid: int = session.get(session_id, 'authenticated_user')
user = db.get_user(uid) user = db.get_user(uid)
# Read the product from the database, identified by the product ID passed as request argument # Read the product from the database, identified by the product ID passed as request argument
if 'pid' in args: if 'pid' in request.params:
pid = int(str(args.pid)) pid = int(str(request.params.pid))
product = db.get_product(pid) product = db.get_product(pid)
# Create a consumption entry for the (user, product) combination # Create a consumption entry for the (user, product) combination
db.increment_consumption(user, product) db.increment_consumption(user, product)
# Redirect to the main page (where this request should have come from) # Redirect to the main page (where this request should have come from)
return RedirectResponse('/') redirect('/')

View file

@ -1,32 +1,30 @@
from typing import Any, Dict, Union from bottle import get, post, redirect, request
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.webserver import session
from matemat.webserver.config import get_app_config
@pagelet('/deposit') @get('/deposit')
def deposit(method: str, @post('/deposit')
path: str, def deposit():
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[str, bytes, PageletResponse]:
""" """
The cash depositing mechanism. Called by the user submitting a deposit from the product list. The cash depositing mechanism. Called by the user submitting a deposit from the product list.
""" """
config = get_app_config()
session_id: str = session.start()
# If no user is logged in, redirect to the main page, as a deposit must always be bound to a user # If no user is logged in, redirect to the main page, as a deposit must always be bound to a user
if 'authenticated_user' not in session_vars: if not session.has(session_id, 'authenticated_user'):
return RedirectResponse('/') redirect('/')
# Connect to the database # Connect to the database
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user from the database # Fetch the authenticated user from the database
uid: int = session_vars['authenticated_user'] uid: int = session.get(session_id, 'authenticated_user')
user = db.get_user(uid) user = db.get_user(uid)
if 'n' in args: if 'n' in request.params:
# Read the amount of cash to deposit from the request arguments # Read the amount of cash to deposit from the request arguments
n = int(str(args.n)) n = int(str(request.params.n))
# Write the deposit to the database # Write the deposit to the database
db.deposit(user, n) db.deposit(user, n)
# Redirect to the main page (where this request should have come from) # Redirect to the main page (where this request should have come from)
return RedirectResponse('/') redirect('/')

View file

@ -1,53 +0,0 @@
from typing import Dict
import logging
from matemat.webserver import pagelet_init
from matemat.db import MatematDatabase
@pagelet_init
def initialization(config: Dict[str, str],
logger: logging.Logger) -> None:
"""
The pagelet initialization function. Makes sure everything is ready for operation. If anything fails here, the web
server won't resume operation.
"""
# Set default values for missing config items
if 'InstanceName' not in config:
config['InstanceName'] = 'Matemat'
logger.warning('Property \'InstanceName\' not set, using \'Matemat\'')
if 'UploadDir' not in config:
config['UploadDir'] = './static/upload/'
logger.warning('Property \'UploadDir\' not set, using \'./static/upload/\'')
if 'DatabaseFile' not in config:
config['DatabaseFile'] = './matemat.db'
logger.warning('Property \'DatabaseFile\' not set, using \'./matemat.db\'')
if 'SmtpSendReceipts' not in config:
config['SmtpSendReceipts'] = '0'
logger.warning('Property \'SmtpSendReceipts\' not set, using \'0\'')
if config['SmtpSendReceipts'] == '1':
if 'SmtpFrom' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpFrom\' missing.')
raise KeyError()
if 'SmtpSubj' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpSubj\' missing.')
raise KeyError()
if 'SmtpHost' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpHost\' missing.')
raise KeyError()
if 'SmtpPort' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpPort\' missing.')
raise KeyError()
if 'SmtpUser' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpUser\' missing.')
raise KeyError()
if 'SmtpPass' not in config:
logger.fatal('\'SmtpSendReceipts\' set to \'1\', but \'SmtpPass\' missing.')
raise KeyError()
if 'SmtpEnforceTLS' not in config:
config['SmtpEnforceTLS'] = '1'
logger.warning('Property \'SmtpEnforceTLS\' not set, using \'1\'')
with MatematDatabase(config['DatabaseFile']):
# Connect to the database to create it and perform any schema migrations
pass

View file

@ -1,45 +1,43 @@
from typing import Any, Dict, Union from bottle import get, post, redirect, abort, request
from matemat.exceptions import AuthenticationError, HttpException
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.db.primitives import User
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.db.primitives import User
from matemat.exceptions import AuthenticationError
from matemat.webserver import template, session
from matemat.webserver.config import get_app_config
@pagelet('/login') @get('/login')
def login_page(method: str, @post('/login')
path: str, def login_page():
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[bytes, str, PageletResponse]:
""" """
The password login mechanism. If called via GET, render the UI template; if called via POST, attempt to log in with The password login mechanism. If called via GET, render the UI template; if called via POST, attempt to log in with
the provided credentials (username and passsword). the provided credentials (username and passsword).
""" """
config = get_app_config()
session_id: str = session.start()
# If a user is already logged in, simply redirect to the main page, showing the product list # If a user is already logged in, simply redirect to the main page, showing the product list
if 'authenticated_user' in session_vars: if session.has(session_id, 'authenticated_user'):
return RedirectResponse('/') redirect('/')
# If requested via HTTP GET, render the login page showing the login UI # If requested via HTTP GET, render the login page showing the login UI
if method == 'GET': if request.method == 'GET':
return TemplateResponse('login.html', return template.render('login.html',
setupname=config['InstanceName']) setupname=config['InstanceName'])
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials # If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
elif method == 'POST': elif request.method == 'POST':
# Connect to the database # Connect to the database
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
try: try:
# Read the request arguments and attempt to log in with them # Read the request arguments and attempt to log in with them
user: User = db.login(str(args.username), str(args.password)) user: User = db.login(str(request.params.username), str(request.params.password))
except AuthenticationError: except AuthenticationError:
# Reload the touchkey login page on failure # Reload the touchkey login page on failure
return RedirectResponse('/login') redirect('/login')
# Set the user ID session variable # Set the user ID session variable
session_vars['authenticated_user'] = user.id session.put(session_id, 'authenticated_user', user.id)
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login) # Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session_vars['authentication_level'] = 2 session.put(session_id, 'authentication_level', 2)
# Redirect to the main page, showing the product list # Redirect to the main page, showing the product list
return RedirectResponse('/') redirect('/')
# If neither GET nor POST was used, show a 405 Method Not Allowed error page # If neither GET nor POST was used, show a 405 Method Not Allowed error page
raise HttpException(405) abort(405)

View file

@ -1,23 +1,19 @@
from typing import Any, Dict, Union from bottle import get, post, redirect
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse from matemat.webserver import session
@pagelet('/logout') @get('/logout')
def logout(method: str, @post('/logout')
path: str, def logout():
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[bytes, str, PageletResponse]:
""" """
The logout mechanism, clearing the authentication values in the session storage. The logout mechanism, clearing the authentication values in the session storage.
""" """
session_id: str = session.start()
# Remove the authenticated user ID from the session storage, if any # Remove the authenticated user ID from the session storage, if any
if 'authenticated_user' in session_vars: if session.has(session_id, 'authenticated_user'):
del session_vars['authenticated_user'] session.delete(session_id, 'authenticated_user')
# Reset the authlevel session variable (0 = none, 1 = touchkey, 2 = password login) # Reset the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session_vars['authentication_level'] = 0 session.put(session_id, 'authentication_level', 0)
# Redirect to the main page, showing the user list # Redirect to the main page, showing the user list
return RedirectResponse('/') redirect('/')

View file

@ -1,39 +1,36 @@
from typing import Any, Dict, Union from bottle import route, redirect
from matemat.webserver import pagelet, RequestArguments, PageletResponse, TemplateResponse, RedirectResponse
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.webserver import template, session
from matemat.webserver.config import get_app_config
@pagelet('/') @route('/')
def main_page(method: str, def main_page():
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[bytes, str, PageletResponse]:
""" """
The main page, showing either the user list (if no user is logged in) or the product list (if a user is logged in). The main page, showing either the user list (if no user is logged in) or the product list (if a user is logged in).
""" """
config = get_app_config()
session_id: str = session.start()
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
# Check whether a user is logged in # Check whether a user is logged in
if 'authenticated_user' in session_vars: if session.has(session_id, 'authenticated_user'):
# Fetch the user id and authentication level (touchkey vs password) from the session storage # Fetch the user id and authentication level (touchkey vs password) from the session storage
uid: int = session_vars['authenticated_user'] uid: int = session.get(session_id, 'authenticated_user')
authlevel: int = session_vars['authentication_level'] authlevel: int = session.get(session_id, 'authentication_level')
# Fetch the user object from the database (for name display, price calculation and admin check) # Fetch the user object from the database (for name display, price calculation and admin check)
user = db.get_user(uid) user = db.get_user(uid)
# Fetch the list of products to display # Fetch the list of products to display
products = db.list_products() products = db.list_products()
# Prepare a response with a jinja2 template # Prepare a response with a jinja2 template
return TemplateResponse('productlist.html', return template.render('productlist.html',
authuser=user, products=products, authlevel=authlevel, authuser=user, products=products, authlevel=authlevel,
setupname=config['InstanceName']) setupname=config['InstanceName'])
else: else:
# If there are no admin users registered, jump to the admin creation procedure # If there are no admin users registered, jump to the admin creation procedure
if not db.has_admin_users(): if not db.has_admin_users():
return RedirectResponse('/userbootstrap') redirect('/userbootstrap')
# If no user is logged in, fetch the list of users and render the userlist template # If no user is logged in, fetch the list of users and render the userlist template
users = db.list_users(with_touchkey=True) users = db.list_users(with_touchkey=True)
return TemplateResponse('userlist.html', return template.render('userlist.html',
users=users, setupname=config['InstanceName']) users=users, setupname=config['InstanceName'])

View file

@ -1,36 +1,35 @@
from typing import Any, Dict, Union
import os import os
from io import BytesIO
from typing import Dict
import magic import magic
from PIL import Image from PIL import Image
from io import BytesIO from bottle import get, post, redirect, abort, request, FormsDict
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.db.primitives import Product from matemat.db.primitives import Product
from matemat.exceptions import DatabaseConsistencyError, HttpException from matemat.exceptions import DatabaseConsistencyError
from matemat.util.currency_format import parse_chf from matemat.util.currency_format import parse_chf
from matemat.webserver import template, session
from matemat.webserver.config import get_app_config
@pagelet('/modproduct') @get('/modproduct')
def modproduct(method: str, @post('/modproduct')
path: str, def modproduct():
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[str, bytes, PageletResponse]:
""" """
The product modification page available from the admin panel. The product modification page available from the admin panel.
""" """
config = get_app_config()
session_id: str = session.start()
# If no user is logged in, redirect to the login page # If no user is logged in, redirect to the login page
if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars: if not session.has(session_id, 'authentication_level') or not session.has(session_id, 'authenticated_user'):
return RedirectResponse('/login') redirect('/login')
authlevel: int = session_vars['authentication_level'] authlevel: int = session.get(session_id, 'authentication_level')
auth_uid: int = session_vars['authenticated_user'] auth_uid: int = session.get(session_id, 'authenticated_user')
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1) # Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
if authlevel < 2: if authlevel < 2:
raise HttpException(403) abort(403)
# Connect to the database # Connect to the database
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
@ -38,38 +37,38 @@ def modproduct(method: str,
authuser = db.get_user(auth_uid) authuser = db.get_user(auth_uid)
if not authuser.is_admin: if not authuser.is_admin:
# Show a 403 Forbidden error page if the user is not an admin # Show a 403 Forbidden error page if the user is not an admin
raise HttpException(403) abort(403)
if 'productid' not in args: if 'productid' not in request.params:
# Show a 400 Bad Request error page if no product to edit was specified # Show a 400 Bad Request error page if no product to edit was specified
# (should never happen during normal operation) # (should never happen during normal operation)
raise HttpException(400, '"productid" argument missing') abort(400, '"productid" argument missing')
# Fetch the product to modify from the database # Fetch the product to modify from the database
modproduct_id = int(str(args.productid)) modproduct_id = int(str(request.params.productid))
product = db.get_product(modproduct_id) product = db.get_product(modproduct_id)
# If the request contains a "change" parameter, delegate the change handling to the function below # If the request contains a "change" parameter, delegate the change handling to the function below
if 'change' in args: if 'change' in request.params:
handle_change(args, product, db, config) handle_change(request.params, request.files, product, db)
# If the product was deleted, redirect back to the admin page, as there is nothing to edit any more # If the product was deleted, redirect back to the admin page, as there is nothing to edit any more
if str(args.change) == 'del': if str(request.params.change) == 'del':
return RedirectResponse('/admin') redirect('/admin')
# Render the "Modify Product" page # Render the "Modify Product" page
return TemplateResponse('modproduct.html', return template.render('modproduct.html',
authuser=authuser, product=product, authlevel=authlevel, authuser=authuser, product=product, authlevel=authlevel,
setupname=config['InstanceName']) setupname=config['InstanceName'])
def handle_change(args: RequestArguments, product: Product, db: MatematDatabase, config: Dict[str, str]) -> None: def handle_change(args: FormsDict, files: FormsDict, product: Product, db: MatematDatabase) -> None:
""" """
Write the changes requested by an admin to the database. Write the changes requested by an admin to the database.
:param args: The RequestArguments object passed to the pagelet. :param args: The FormsDict object passed to the pagelet.
:param product: The product to edit. :param product: The product to edit.
:param db: The database facade where changes are written to. :param db: The database facade where changes are written to.
:param config: The dictionary of config file entries from the [Pagelets] section.
""" """
config = get_app_config()
# Read the type of change requested by the admin, then switch over it # Read the type of change requested by the admin, then switch over it
change = str(args.change) change = str(args.change)
@ -101,9 +100,9 @@ def handle_change(args: RequestArguments, product: Product, db: MatematDatabase,
except DatabaseConsistencyError: except DatabaseConsistencyError:
return return
# If a new product image was uploaded, process it # If a new product image was uploaded, process it
if 'image' in args: if 'image' in files:
# Read the raw image data from the request # Read the raw image data from the request
avatar = bytes(args.image) avatar = files.image.file.read()
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded # Only process the image, if its size is more than zero. Zero size means no new image was uploaded
if len(avatar) == 0: if len(avatar) == 0:
return return

View file

@ -1,36 +1,35 @@
from typing import Any, Dict, Optional, Union
import os import os
from io import BytesIO
from typing import Dict, Optional
import magic import magic
from PIL import Image from PIL import Image
from io import BytesIO from bottle import get, post, redirect, abort, request, FormsDict
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.db.primitives import User, ReceiptPreference from matemat.db.primitives import User, ReceiptPreference
from matemat.exceptions import DatabaseConsistencyError, HttpException from matemat.exceptions import DatabaseConsistencyError
from matemat.util.currency_format import parse_chf from matemat.util.currency_format import parse_chf
from matemat.webserver import template, session
from matemat.webserver.config import get_app_config
@pagelet('/moduser') @get('/moduser')
def moduser(method: str, @post('/moduser')
path: str, def moduser():
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[str, bytes, PageletResponse]:
""" """
The user modification page available from the admin panel. The user modification page available from the admin panel.
""" """
config = get_app_config()
session_id: str = session.start()
# If no user is logged in, redirect to the login page # If no user is logged in, redirect to the login page
if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars: if not session.has(session_id, 'authentication_level') or not session.has(session_id, 'authenticated_user'):
return RedirectResponse('/login') redirect('/login')
authlevel: int = session_vars['authentication_level'] authlevel: int = session.get(session_id, 'authentication_level')
auth_uid: int = session_vars['authenticated_user'] auth_uid: int = session.get(session_id, 'authenticated_user')
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1) # Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
if authlevel < 2: if authlevel < 2:
raise HttpException(403) abort(403)
# Connect to the database # Connect to the database
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
@ -38,31 +37,31 @@ def moduser(method: str,
authuser = db.get_user(auth_uid) authuser = db.get_user(auth_uid)
if not authuser.is_admin: if not authuser.is_admin:
# Show a 403 Forbidden error page if the user is not an admin # Show a 403 Forbidden error page if the user is not an admin
raise HttpException(403) abort(403)
if 'userid' not in args: if 'userid' not in request.params:
# Show a 400 Bad Request error page if no users to edit was specified # Show a 400 Bad Request error page if no users to edit was specified
# (should never happen during normal operation) # (should never happen during normal operation)
raise HttpException(400, '"userid" argument missing') abort(400, '"userid" argument missing')
# Fetch the user to modify from the database # Fetch the user to modify from the database
moduser_id = int(str(args.userid)) moduser_id = int(str(request.params.userid))
user = db.get_user(moduser_id) user = db.get_user(moduser_id)
# If the request contains a "change" parameter, delegate the change handling to the function below # If the request contains a "change" parameter, delegate the change handling to the function below
if 'change' in args: if 'change' in request.params:
handle_change(args, user, authuser, db, config) handle_change(request.params, request.files, user, authuser, db)
# If the user was deleted, redirect back to the admin page, as there is nothing to edit any more # If the user was deleted, redirect back to the admin page, as there is nothing to edit any more
if str(args.change) == 'del': if str(request.params.change) == 'del':
return RedirectResponse('/admin') redirect('/admin')
# Render the "Modify User" page # Render the "Modify User" page
return TemplateResponse('moduser.html', return template.render('moduser.html',
authuser=authuser, user=user, authlevel=authlevel, authuser=authuser, user=user, authlevel=authlevel,
receipt_preference_class=ReceiptPreference, receipt_preference_class=ReceiptPreference,
setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts']) setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts'])
def handle_change(args: RequestArguments, user: User, authuser: User, db: MatematDatabase, config: Dict[str, str]) \ def handle_change(args: FormsDict, files: FormsDict, user: User, authuser: User, db: MatematDatabase) \
-> None: -> None:
""" """
Write the changes requested by an admin to the database. Write the changes requested by an admin to the database.
@ -71,8 +70,8 @@ def handle_change(args: RequestArguments, user: User, authuser: User, db: Matema
:param user: The user to edit. :param user: The user to edit.
:param authuser: The user performing the modification. :param authuser: The user performing the modification.
:param db: The database facade where changes are written to. :param db: The database facade where changes are written to.
:param config: The dictionary of config file entries from the [Pagelets] section.
""" """
config = get_app_config()
# Read the type of change requested by the admin, then switch over it # Read the type of change requested by the admin, then switch over it
change = str(args.change) change = str(args.change)
@ -124,9 +123,9 @@ def handle_change(args: RequestArguments, user: User, authuser: User, db: Matema
except DatabaseConsistencyError: except DatabaseConsistencyError:
return return
# If a new avatar was uploaded, process it # If a new avatar was uploaded, process it
if 'avatar' in args: if 'avatar' in files:
# Read the raw image data from the request # Read the raw image data from the request
avatar = bytes(args.avatar) avatar = files.avatar.file.read()
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded # Only process the image, if its size is more than zero. Zero size means no new image was uploaded
if len(avatar) == 0: if len(avatar) == 0:
return return

View file

@ -1,22 +1,25 @@
from typing import Dict, List, Tuple from typing import List, Tuple
import logging import logging
import smtplib as smtp import smtplib as smtp
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from jinja2 import Environment, Template
from matemat.webserver import pagelet_cron from matemat.webserver.cron import cron
from matemat.webserver import template
from matemat.webserver.config import get_app_config
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.db.primitives import User, Receipt from matemat.db.primitives import User, Receipt
from matemat.util.currency_format import format_chf from matemat.util.currency_format import format_chf
@pagelet_cron(hours=6) logger: logging.Logger = logging.Logger(__file__)
def receipt_smtp_cron(config: Dict[str, str],
jinja_env: Environment,
logger: logging.Logger) -> None: @cron(hours=6)
def receipt_smtp_cron() -> None:
config = get_app_config()
if config['SmtpSendReceipts'] != '1': if config['SmtpSendReceipts'] != '1':
# Sending receipts via mail is disabled # Sending receipts via mail is disabled
return return
@ -35,15 +38,13 @@ def receipt_smtp_cron(config: Dict[str, str],
receipts.append(receipt) receipts.append(receipt)
else: else:
logger.debug('No receipt due for user "%s".', user.name) logger.debug('No receipt due for user "%s".', user.name)
# Send all generated receipts via e-mailgi # Send all generated receipts via e-mail
if len(receipts) > 0: if len(receipts) > 0:
_send_receipt_mails(receipts, jinja_env, logger, config) _send_receipt_mails(receipts)
def _send_receipt_mails(receipts: List[Receipt], def _send_receipt_mails(receipts: List[Receipt]) -> None:
jinja_env: Environment, config = get_app_config()
logger: logging.Logger,
config: Dict[str, str]) -> None:
mails: List[Tuple[str, MIMEMultipart]] = [] mails: List[Tuple[str, MIMEMultipart]] = []
for receipt in receipts: for receipt in receipts:
if receipt.user.email is None: if receipt.user.email is None:
@ -63,8 +64,8 @@ def _send_receipt_mails(receipts: List[Receipt],
fbal = format_chf(receipt.transactions[0].old_balance).rjust(12) fbal = format_chf(receipt.transactions[0].old_balance).rjust(12)
tbal: str = format_chf(receipt.user.balance).rjust(12) tbal: str = format_chf(receipt.user.balance).rjust(12)
# Render the receipt # Render the receipt
template: Template = jinja_env.get_template('receipt.txt') rendered: str = template.render('receipt.txt',
rendered: str = template.render(fdate=fdate, tdate=tdate, user=username, fbal=fbal, tbal=tbal, date=fdate, tdate=tdate, user=username, fbal=fbal, tbal=tbal,
receipt_id=receipt.id, transactions=receipt.transactions, receipt_id=receipt.id, transactions=receipt.transactions,
instance_name=config['InstanceName']) instance_name=config['InstanceName'])
# Put the rendered receipt in the message body # Put the rendered receipt in the message body

View file

@ -1,33 +1,30 @@
from typing import Any, Dict, List, Tuple, Union
from math import pi, sin, cos
from datetime import datetime, timedelta from datetime import datetime, timedelta
from math import pi, sin, cos
from typing import Any, Dict, List, Tuple
from bottle import route, redirect, abort, request
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.db.primitives import User from matemat.db.primitives import User
from matemat.exceptions import HttpException from matemat.webserver import template, session
from matemat.webserver.config import get_app_config
@pagelet('/statistics') @route('/statistics')
def statistics(method: str, def statistics():
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[str, bytes, PageletResponse]:
""" """
The statistics page available from the admin panel. The statistics page available from the admin panel.
""" """
config = get_app_config()
session_id: str = session.start()
# If no user is logged in, redirect to the login page # If no user is logged in, redirect to the login page
if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars: if not session.has(session_id, 'authentication_level') or not session.has(session_id, 'authenticated_user'):
return RedirectResponse('/login') redirect('/login')
authlevel: int = session_vars['authentication_level'] authlevel: int = session.get(session_id, 'authentication_level')
auth_uid: int = session_vars['authenticated_user'] auth_uid: int = session.get(session_id, 'authenticated_user')
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1) # Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
if authlevel < 2: if authlevel < 2:
raise HttpException(403) abort(403)
# Connect to the database # Connect to the database
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
@ -35,15 +32,15 @@ def statistics(method: str,
authuser: User = db.get_user(auth_uid) authuser: User = db.get_user(auth_uid)
if not authuser.is_admin: if not authuser.is_admin:
# Show a 403 Forbidden error page if the user is not an admin # Show a 403 Forbidden error page if the user is not an admin
raise HttpException(403) abort(403)
todate: datetime = datetime.utcnow() todate: datetime = datetime.utcnow()
fromdate: datetime = (todate - timedelta(days=365)).replace(hour=0, minute=0, second=0, microsecond=0) fromdate: datetime = (todate - timedelta(days=365)).replace(hour=0, minute=0, second=0, microsecond=0)
if 'fromdate' in args: if 'fromdate' in request.params:
fdarg: str = str(args.fromdate) fdarg: str = str(request.params.fromdate)
fromdate = datetime.strptime(fdarg, '%Y-%m-%d').replace(tzinfo=None) fromdate = datetime.strptime(fdarg, '%Y-%m-%d').replace(tzinfo=None)
if 'todate' in args: if 'todate' in request.params:
tdarg: str = str(args.todate) tdarg: str = str(request.params.todate)
todate = datetime.strptime(tdarg, '%Y-%m-%d').replace(tzinfo=None) todate = datetime.strptime(tdarg, '%Y-%m-%d').replace(tzinfo=None)
todate = (todate + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) todate = (todate + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
@ -70,7 +67,7 @@ def statistics(method: str,
previous_angle = angle previous_angle = angle
# Render the statistics page # Render the statistics page
return TemplateResponse('statistics.html', return template.render('statistics.html',
fromdate=fromdate.strftime('%Y-%m-%d'), fromdate=fromdate.strftime('%Y-%m-%d'),
todate=(todate - timedelta(days=1)).strftime('%Y-%m-%d'), todate=(todate - timedelta(days=1)).strftime('%Y-%m-%d'),
product_slices=slices, product_slices=slices,

View file

@ -1,46 +1,44 @@
from typing import Any, Dict, Union from bottle import get, post, redirect, abort, request
from matemat.exceptions import AuthenticationError, HttpException
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.db.primitives import User
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.db.primitives import User
from matemat.exceptions import AuthenticationError
from matemat.webserver import template, session
from matemat.webserver.config import get_app_config
@pagelet('/touchkey') @get('/touchkey')
def touchkey_page(method: str, @post('/touchkey')
path: str, def touchkey_page():
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[bytes, str, PageletResponse]:
""" """
The touchkey login mechanism. If called via GET, render the UI template; if called via POST, attempt to log in with The touchkey login mechanism. If called via GET, render the UI template; if called via POST, attempt to log in with
the provided credentials (username and touchkey). the provided credentials (username and touchkey).
""" """
config = get_app_config()
session_id: str = session.start()
# If a user is already logged in, simply redirect to the main page, showing the product list # If a user is already logged in, simply redirect to the main page, showing the product list
if 'authenticated_user' in session_vars: if session.has(session_id, 'authenticated_user'):
return RedirectResponse('/') redirect('/')
# If requested via HTTP GET, render the login page showing the touchkey UI # If requested via HTTP GET, render the login page showing the touchkey UI
if method == 'GET': if request.method == 'GET':
return TemplateResponse('touchkey.html', return template.render('touchkey.html',
username=str(args.username), uid=int(str(args.uid)), username=str(request.params.username), uid=int(str(request.params.uid)),
setupname=config['InstanceName']) setupname=config['InstanceName'])
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials # If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
elif method == 'POST': elif request.method == 'POST':
# Connect to the database # Connect to the database
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
try: try:
# Read the request arguments and attempt to log in with them # Read the request arguments and attempt to log in with them
user: User = db.login(str(args.username), touchkey=str(args.touchkey)) user: User = db.login(str(request.params.username), touchkey=str(request.params.touchkey))
except AuthenticationError: except AuthenticationError:
# Reload the touchkey login page on failure # Reload the touchkey login page on failure
return RedirectResponse(f'/touchkey?uid={str(args.uid)}&username={str(args.username)}') redirect(f'/touchkey?uid={str(request.params.uid)}&username={str(request.params.username)}')
# Set the user ID session variable # Set the user ID session variable
session_vars['authenticated_user'] = user.id session.put(session_id, 'authenticated_user', user.id)
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login) # Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session_vars['authentication_level'] = 1 session.put(session_id, 'authentication_level', 1)
# Redirect to the main page, showing the product list # Redirect to the main page, showing the product list
return RedirectResponse('/') redirect('/')
# If neither GET nor POST was used, show a 405 Method Not Allowed error page # If neither GET nor POST was used, show a 405 Method Not Allowed error page
raise HttpException(405) abort(405)

View file

@ -1,43 +1,39 @@
from bottle import get, post, redirect, abort, request
from typing import Any, Dict, Union
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse from matemat.webserver import template
from matemat.exceptions import HttpException from matemat.webserver.config import get_app_config
@pagelet('/userbootstrap') @get('/userbootstrap')
def userbootstrap(method: str, @post('/userbootstrap')
path: str, def userbootstrap():
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[bytes, str, PageletResponse]:
""" """
The page for creating a first admin user. To be used when the system is set up the first time, or when there are no The page for creating a first admin user. To be used when the system is set up the first time, or when there are no
admin users left. admin users left.
""" """
config = get_app_config()
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
# Redirect to main if there are still administrators registered # Redirect to main if there are still administrators registered
if db.has_admin_users(): if db.has_admin_users():
return RedirectResponse('/') redirect('/')
# Process submission # Process submission
if method == 'POST': if request.method == 'POST':
# Make sure all required values are present # Make sure all required values are present
if 'username' not in args or 'password' not in args or 'password2' not in args: if 'username' not in request.params or 'password' not in request.params \
raise HttpException(400, 'Some arguments are missing') or 'password2' not in request.params:
username: str = str(args.username) abort(400, 'Some arguments are missing')
password: str = str(args.password) username: str = str(request.params.username)
password2: str = str(args.password2) password: str = str(request.params.password)
password2: str = str(request.params.password2)
# The 2 passwords must match # The 2 passwords must match
if password != password2: if password != password2:
return RedirectResponse('/userbootstrap') redirect('/userbootstrap')
# Create the admin user # Create the admin user
db.create_user(username, password, None, True, False) db.create_user(username, password, None, True, False)
# Redirect to the main page # Redirect to the main page
return RedirectResponse('/') redirect('/')
# Requested via GET; show the user creation UI # Requested via GET; show the user creation UI
else: else:
return TemplateResponse('userbootstrap.html', return template.render('userbootstrap.html',
setupname=config['InstanceName']) setupname=config['InstanceName'])

View file

@ -1,324 +0,0 @@
from typing import Dict, Iterator, List, Tuple, Union
class RequestArguments(object):
"""
Container for HTTP Request arguments.
Usage:
# Create empty instance
ra = RequestArguments()
# Add an entry for the key 'foo' with the value 'bar' and Content-Type 'text/plain'
ra['foo'].append('text/plain', 'bar')
# Retrieve the value for the key 'foo', as a string...
foo = str(ra.foo)
# ... or as raw bytes
foo = bytes(ra.foo)
"""
def __init__(self) -> None:
"""
Create an empty container instance.
"""
self.__container: Dict[str, RequestArgument] = dict()
def __getitem__(self, key: str) -> 'RequestArgument':
"""
Retrieve the argument for the given name, creating it on the fly, if it doesn't exist.
:param key: Name of the argument to retrieve.
:return: A RequestArgument instance.
:raises TypeError: If key is not a string.
"""
if not isinstance(key, str):
raise TypeError('key must be a str')
# Create empty argument, if it doesn't exist
if key not in self.__container:
self.__container[key] = RequestArgument(key)
# Return the argument for the name
return self.__container[key]
def __getattr__(self, key: str) -> 'RequestArgument':
"""
Syntactic sugar for accessing values with a name that can be used in Python attributes. The value will be
returned as an immutable view.
:param key: Name of the argument to retrieve.
:return: An immutable view of the RequestArgument instance.
"""
return _View.of(self.__container[key])
def __iter__(self) -> Iterator['RequestArgument']:
"""
Returns an iterator over the values in this instance. Values are represented as immutable views.
:return: An iterator that yields immutable views of the values.
"""
for ra in self.__container.values():
# Yield an immutable scalar view for each value
yield _View.of(ra)
def __contains__(self, key: str) -> bool:
"""
Checks whether an argument with a given name exists in the RequestArguments instance.
:param key: The name to check whether it exists.
:return: True, if present, False otherwise.
"""
return key in self.__container
def __len__(self) -> int:
"""
:return: The number of arguments in this instance.
"""
return len(self.__container)
class RequestArgument(object):
"""
Container class for HTTP request arguments that simplifies dealing with
- scalar and array arguments:
Automatically converts between single values and arrays where necessary: Arrays with one element can be
accessed as scalars, and scalars can be iterated, yielding themselves as a single item.
- UTF-8 strings and binary data (e.g. file uploads):
All data can be retrieved both as a str (if utf-8 decoding is possible) and a bytes object.
The objects returned from iteration or indexing are immutable views of (parts of) this object.
Usage example:
qsargs = urllib.parse.parse_qs(qs, strict_parsing=True, keep_blank_values=True, errors='strict')
args: RequestArguments
for k, vs in qsargs:
args[k].clear()
for v in vs:
# text/plain usually is a sensible choice for values decoded from urlencoded strings
# IF ALREADY IN STRING FORM (which parse_qs does)!
args[k].append('text/plain', v)
if 'username' in args and args.username.is_scalar:
username = str(args.username)
"""
def __init__(self,
name: str,
value: Union[Tuple[str, Union[bytes, str]], List[Tuple[str, Union[bytes, str]]]] = None) -> None:
"""
Create a new RequestArgument with a name and optionally an initial value.
:param name: The name for this argument, as provided via GET or POST.
:param value: The initial value, if any. Optional, initializes with empty array if omitted.
"""
# Assign name
self.__name: str = name
# Initialize value
self.__value: List[Tuple[str, Union[bytes, str]]] = []
# Default to empty array
if value is None:
self.__value = []
else:
if isinstance(value, list):
# Store the array
self.__value = value
else:
# Turn scalar into an array before storing
self.__value = [value]
@property
def is_array(self) -> bool:
"""
:return: True, if the value is a (possibly empty) array, False otherwise.
"""
return len(self.__value) != 1
@property
def is_scalar(self) -> bool:
"""
:return: True, if the value is a single scalar value, False otherwise.
"""
return len(self.__value) == 1
@property
def is_view(self) -> bool:
"""
:return: True, if this instance is an immutable view, False otherwise.
"""
return False
@property
def name(self) -> str:
"""
:return: The name of this argument.
"""
return self.__name
def get_str(self, index: int = 0) -> str:
"""
Attempts to return a value as a string. The index defaults to 0.
:param index: The index of the value to retrieve. Default: 0.
:return: An UTF-8 string representation of the requested value.
:raises UnicodeDecodeError: If the value cannot be decoded into an UTF-8 string.
:raises IndexError: If the index is out of bounds.
:raises TypeError: If the index is not an int.
:raises TypeError: If the requested value is neither a str nor a bytes object.
"""
if not isinstance(index, int):
# Index must be an int
raise TypeError('index must be an int')
# Type hint; access array element
v: Tuple[str, Union[bytes, str]] = self.__value[index]
if isinstance(v[1], str):
# The value already is a string, return
return v[1]
elif isinstance(v[1], bytes):
# The value is a bytes object, attempt to decode
return v[1].decode('utf-8')
raise TypeError('Value is neither a str nor bytes')
def __str__(self) -> str:
"""
Attempts to return the first value as a string.
:return: An UTF-8 string representation of the first value.
:raises UnicodeDecodeError: If the value cannot be decoded into an UTF-8 string.
"""
return self.get_str()
def get_bytes(self, index: int = 0) -> bytes:
"""
Attempts to return a value as a bytes object. The index defaults to 0.
:param index: The index of the value to retrieve. Default: 0.
:return: A bytes object representation of the requested value. Strings will be encoded as UTF-8.
:raises IndexError: If the index is out of bounds.
:raises TypeError: If the index is not an int.
:raises TypeError: If the requested value is neither a str nor a bytes object.
"""
if not isinstance(index, int):
# Index must be a int
raise TypeError('index must be an int')
# Type hint; access array element
v: Tuple[str, Union[bytes, str]] = self.__value[index]
if isinstance(v[1], bytes):
# The value already is a bytes object, return
return v[1]
elif isinstance(v[1], str):
# The value is a string, encode first
return v[1].encode('utf-8')
raise TypeError('Value is neither a str nor bytes')
def __bytes__(self) -> bytes:
"""
Attempts to return the first value as a bytes object.
:return: A bytes string representation of the first value.
"""
return self.get_bytes()
def get_content_type(self, index: int = 0) -> str:
"""
Attempts to retrieve a value's Content-Type. The index defaults to 0.
:param index: The index of the value to retrieve. Default: 0.
:return: The Content-Type of the requested value, as sent by the client. Not necessarily trustworthy.
:raises IndexError: If the index is out of bounds.
:raises TypeError: If the index is not an int.
"""
# instance is an array value
if not isinstance(index, int):
# Needs an index for array values
raise TypeError('index must be an int')
# Type hint; access array element
va: Tuple[str, Union[bytes, str]] = self.__value[index]
# Return the content type of the requested value
if not isinstance(va[0], str):
raise TypeError('Content-Type is not a str')
return va[0]
def append(self, ctype: str, value: Union[str, bytes]) -> None:
"""
Append a value to this instance. Turns an empty argument into a scalar and a scalar into an array.
:param ctype: The Content-Type, as provided in the request.
:param value: The scalar value to append, either a string or bytes object.
:raises TypeError: If called on an immutable view.
"""
if self.is_view:
# This is an immutable view, raise exception
raise TypeError('A RequestArgument view is immutable!')
self.__value.append((ctype, value))
def clear(self) -> None:
"""
Remove all values from this instance.
:raises TypeError: If called on an immutable view.
"""
if self.is_view:
# This is an immutable view, raise exception
raise TypeError('A RequestArgument view is immutable!')
self.__value.clear()
def __len__(self) -> int:
"""
:return: Number of values for this argument.
"""
return len(self.__value)
def __iter__(self) -> Iterator['RequestArgument']:
"""
Iterate the values of this argument. Each value is accessible as if it were a scalar RequestArgument in turn,
although they are immutable.
:return: An iterator that yields immutable views of the values.
"""
for v in self.__value:
# Yield an immutable scalar view for each (ctype, value) element in the array
yield _View(self.__name, v)
def __getitem__(self, index: Union[int, slice]) -> 'RequestArgument':
"""
Index the argument with either an int or a slice. The returned values are represented as immutable
RequestArgument views.
:param index: The index or slice.
:return: An immutable view of the indexed elements of this argument.
"""
# Pass the index or slice through to the array, packing the result in an immutable view
return _View(self.__name, self.__value[index])
class _View(RequestArgument):
"""
This class represents an immutable view of a (subset of a) RequestArgument object. Should not be instantiated
directly.
"""
def __init__(self, name: str, value: Union[Tuple[str, Union[bytes, str]], List[Tuple[str, Union[bytes, str]]]])\
-> None:
"""
Create a new immutable view of a (subset of a) RequestArgument.
:param name: The name for this argument, same as in the original RequestArgument.
:param value: The values to represent in this view, obtained by e.g. indexing or slicing.
"""
super().__init__(name, value)
@staticmethod
def of(argument: 'RequestArgument') ->'RequestArgument':
"""
Create an immutable, unsliced view of an RequestArgument instance.
:param argument: The RequestArgument instance to create a view of.
:return: An immutable view of the provided RequestArgument instance.
"""
return argument[:]
@property
def is_view(self) -> bool:
"""
:return: True, if this instance is an immutable view, False otherwise.
"""
return True

View file

@ -1,65 +0,0 @@
from jinja2 import Environment, Template
from matemat import __version__
class PageletResponse:
"""
Base class for pagelet return values that require more action than simply sending plain data.
An instance of this base class will result in an empty 200 OK response.
"""
def __init__(self, status: int = 200) -> None:
"""
Create an empty response.
:param status: The HTTP status code, defaults to 200 (OK).
"""
self.status: int = status
class RedirectResponse(PageletResponse):
"""
A pagelet response that causes the server to redirect to another location, using a 301 Permanently Moved (uncached)
response status, and a Location header.
"""
def __init__(self, location: str) -> None:
"""
Create a redirection response with the given redirection location.
:param location: The location to redirect to.
"""
super().__init__(status=301)
self.location: str = location
class TemplateResponse(PageletResponse):
"""
A pagelet response that causes the server to load a Jinja2 template and render it with the provided arguments, then
sending the result as response body, with a 200 OK response status.
"""
def __init__(self, name: str, **kwargs) -> None:
"""
Create a template response with the given template name and arguments.
:param name: Name of the template to load.
:param kwargs: Arguments for rendering the template, will be passed to jinja2.Template.render as is.
"""
super().__init__()
self.name: str = name
self.kwargs = kwargs
def _render(self, jinja_env: Environment) -> bytes:
"""
Load and render the template using the Jinja2 environment managed by the web server instance. This method
should not be called by a pagelet.
:param jinja_env: The Jinja2 environment.
:return: An UTF-8 encoded bytes object containing the template rendering result.
"""
template: Template = jinja_env.get_template(self.name)
return template.render(**self.kwargs, __version__=__version__).encode('utf-8')

View file

@ -0,0 +1,2 @@
from .sessions import start, end, put, get, has, delete

View file

@ -0,0 +1,73 @@
from typing import Any, Dict, Tuple, Optional
from bottle import request, response
from secrets import token_bytes
from uuid import uuid4
from datetime import datetime, timedelta
__key: Optional[str] = token_bytes(32)
__session_vars: Dict[str, Tuple[datetime, Dict[str, Any]]] = dict()
# Inactivity timeout for client sessions
_SESSION_TIMEOUT: int = 3600
_COOKIE_NAME: str = 'matemat_session_id'
def start() -> str:
"""
Start a new session, or resume the session identified by the session cookie sent in the HTTP request.
:return: The session ID.
"""
# Reference date for session timeout
now = datetime.utcnow()
# Read the client's session ID, if any
session_id = str(request.get_cookie(_COOKIE_NAME, secret=__key))
# If there is no active session, create a new session ID
if session_id is None:
session_id = str(uuid4())
# Check for session timeout
if session_id in __session_vars and __session_vars[session_id][0] < now:
end(session_id)
raise TimeoutError('Session timed out.')
# Update or initialize the session timeout
if session_id not in __session_vars:
__session_vars[session_id] = (now + timedelta(seconds=_SESSION_TIMEOUT)), dict()
else:
__session_vars[session_id] = \
(now + timedelta(seconds=_SESSION_TIMEOUT), __session_vars[session_id][1])
# Return the session ID and timeout
response.set_cookie(_COOKIE_NAME, session_id, secret=__key)
return session_id
def end(session_id: str) -> None:
"""
Destroy a session identified by the session ID.
:param session_id: ID of the session to destroy.
"""
if session_id in __session_vars:
del __session_vars[session_id]
def put(session_id: str, key: str, value: Any) -> None:
if session_id in __session_vars:
__session_vars[session_id][1][key] = value
def get(session_id: str, key: str) -> Any:
if session_id in __session_vars and key in __session_vars[session_id][1]:
return __session_vars[session_id][1][key]
return None
def delete(session_id: str, key: str) -> None:
if session_id in __session_vars and key in __session_vars[session_id][1]:
del __session_vars[session_id][1][key]
def has(session_id: str, key: str) -> bool:
return session_id in __session_vars and key in __session_vars[session_id][1]

View file

@ -0,0 +1,2 @@
from .template import init, render

View file

@ -0,0 +1,24 @@
from typing import Any, Dict
import os.path
import jinja2
from matemat import __version__
from matemat.util.currency_format import format_chf
__jinja_env: jinja2.Environment = None
def init(config: Dict[str, Any]) -> None:
global __jinja_env
__jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(os.path.abspath(config['templateroot'])),
autoescape=jinja2.select_autoescape(default=True),
)
__jinja_env.filters['chf'] = format_chf
def render(name: str, **kwargs):
global __jinja_env
template: jinja2.Template = __jinja_env.get_template(name)
return template.render(__version__=__version__, **kwargs).encode('utf-8')

View file

@ -1,222 +0,0 @@
from typing import Any, Callable, Dict, Tuple, Union
import unittest.mock
from io import BytesIO
from tempfile import TemporaryDirectory
from abc import ABC
from datetime import datetime
from http.server import HTTPServer
import logging
import jinja2
from matemat.webserver import pagelet, RequestArguments
class HttpResponse:
"""
A really basic HTTP response container and parser class, just good enough for unit testing a HTTP server, if even.
DO NOT USE THIS OUTSIDE UNIT TESTING!
Usage:
response = HttpResponse()
while response.parse_phase != 'done'
response.parse(<read from somewhere>)
print(response.statuscode)
"""
def __init__(self) -> None:
# The HTTP status code of the response
self.statuscode: int = 0
# HTTP headers set in the response
self.headers: Dict[str, str] = {
'Content-Length': 0
}
self.pagelet: str = None
# The response body
self.body: bytes = bytes()
# Parsing phase, one of 'begin', 'hdr', 'body' or 'done'
self.parse_phase = 'begin'
# Buffer for uncompleted lines
self.buffer: bytes = bytes()
def __finalize(self):
self.parse_phase = 'done'
self.pagelet = self.headers.get('X-Test-Pagelet', None)
def parse(self, fragment: bytes) -> None:
"""
Parse a new fragment of data. This function does nothing if the parsed HTTP response is already complete.
DO NOT USE THIS OUTSIDE UNIT TESTING!
:param fragment: The data fragment to parse.
"""
# response packet complete, nothing to do
if self.parse_phase == 'done':
return
# If in the body phase, simply decode and append to the body, while the body is not complete yet
elif self.parse_phase == 'body':
self.body += fragment
if len(self.body) >= int(self.headers['Content-Length']):
self.__finalize()
return
if b'\r\n' not in fragment:
# If the fragment does not contain a CR-LF, add it to the buffer, we only want to parse whole lines
self.buffer = self.buffer + fragment
else:
if not fragment.endswith(b'\r\n'):
# Special treatment for no trailing CR-LF: Add remainder to buffer
head, tail = fragment.rsplit(b'\r\n', 1)
data: bytes = (self.buffer + head)
self.buffer = tail
else:
data: bytes = (self.buffer + fragment)
self.buffer = bytes()
# Iterate the lines that are ready to be parsed
for line in data.split(b'\r\n'):
# The 'begin' phase indicates that the parser is waiting for the HTTP status line
if self.parse_phase == 'begin':
if line.startswith(b'HTTP/'):
# Parse the statuscode and advance to header parsing
_, statuscode, _ = line.decode('utf-8').split(' ', 2)
self.statuscode = int(statuscode)
self.parse_phase = 'hdr'
elif self.parse_phase == 'hdr':
# Parse a header line and add it to the header dict
if len(line) > 0:
k, v = line.decode('utf-8').split(':', 1)
self.headers[k.strip()] = v.strip()
else:
# Empty line separates header from body
self.parse_phase = 'body'
elif self.parse_phase == 'body':
# if there is a remainder in the data packet, it is (part of) the body, add to body string
self.body += line
if len(self.body) >= int(self.headers['Content-Length']):
self.__finalize()
class MockServer:
"""
A mock implementation of http.server.HTTPServer. Only used for matemat-specific storage.
"""
def __init__(self, webroot: str = '/var/matemat/webroot') -> None:
# Session timeout and variables for all sessions
self.session_vars: Dict[str, Tuple[datetime, Dict[str, Any]]] = dict()
# Webroot for statically served content
self.webroot: str = webroot
# Variables to pass to pagelets
self.pagelet_variables: Dict[str, str] = dict()
# Static response headers
self.static_headers: Dict[str, str] = {
'X-Static-Testing-Header': 'helloworld!'
}
# Jinja environment with a single, static template
self.jinja_env = jinja2.Environment(
loader=jinja2.DictLoader({'test.txt': 'Hello, {{ what }}!'})
)
# Set up logger
self.logger: logging.Logger = logging.getLogger('matemat unit test')
self.logger.setLevel(logging.DEBUG)
# Disable logging
self.logger.addHandler(logging.NullHandler())
# Initalize a log handler to stderr and set the log format
# sh: logging.StreamHandler = logging.StreamHandler()
# sh.setFormatter(logging.Formatter('%(asctime)s %(name)s [%(levelname)s]: %(message)s'))
# self.logger.addHandler(sh)
class MockSocket(bytes):
"""
A mock implementation of a socket.socket for http.server.BaseHTTPRequestHandler.
The bytes inheritance is due to a broken type annotation in BaseHTTPRequestHandler.
"""
def __init__(self) -> None:
super().__init__()
# The request string
self.__request = bytes()
# The parsed response
self.__packet = HttpResponse()
def set_request(self, request: bytes) -> None:
"""
Sets the HTTP request to send to the server.
:param request: The request
"""
self.__request: bytes = request
def makefile(self, mode: str, size: int) -> BytesIO:
"""
Required by http.server.HTTPServer.
:return: A dummy buffer IO object instead of a network socket file handle.
"""
return BytesIO(self.__request)
def sendall(self, b: bytes) -> None:
"""
Required by http.server.HTTPServer.
:param b: The data to send to the client. Will be parsed directly instead.
"""
self.__packet.parse(b)
def get_response(self) -> HttpResponse:
"""
Fetches the parsed HTTP response generated by the server.
:return: The response object.
"""
return self.__packet
def test_pagelet(path: str):
def with_testing_headers(fun: Callable[[str,
str,
RequestArguments,
Dict[str, Any],
Dict[str, str],
Dict[str, str]],
Union[bytes, str, Tuple[int, str]]]):
@pagelet(path)
def testing_wrapper(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]):
headers['X-Test-Pagelet'] = fun.__name__
result = fun(method, path, args, session_vars, headers, pagelet_variables)
return result
return testing_wrapper
return with_testing_headers
class AbstractHttpdTest(ABC, unittest.TestCase):
"""
An abstract test case that can be inherited by test case classes that want to test part of the webserver's core
functionality.
Usage (subclass test method):
self.client_sock.set_request(b'GET /just/testing/sessions HTTP/1.1\r\n\r\n')
handler = HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
"""
def setUp(self) -> None:
self.tempdir: TemporaryDirectory = TemporaryDirectory(prefix='matemat.', dir='/tmp/')
self.server: HTTPServer = MockServer(webroot=self.tempdir.name)
self.client_sock: MockSocket = MockSocket()
def tearDown(self):
self.tempdir.cleanup()

View file

@ -8,7 +8,7 @@ from io import StringIO
import logging import logging
import sys import sys
from matemat.webserver import parse_config_file from matemat.webserver.config import parse_config_file, get_config
_EMPTY_CONFIG = '' _EMPTY_CONFIG = ''
@ -112,7 +112,8 @@ class TestConfig(TestCase):
# Mock the open() function to return an empty config file example # Mock the open() function to return an empty config file example
with patch('builtins.open', return_value=StringIO(_EMPTY_CONFIG)): with patch('builtins.open', return_value=StringIO(_EMPTY_CONFIG)):
# The filename is only a placeholder, file content is determined by mocking open # The filename is only a placeholder, file content is determined by mocking open
config = parse_config_file('test') parse_config_file('test')
config = get_config()
# Make sure all mandatory values are present # Make sure all mandatory values are present
self.assertIn('listen', config) self.assertIn('listen', config)
self.assertIn('port', config) self.assertIn('port', config)
@ -141,7 +142,8 @@ class TestConfig(TestCase):
# Mock the open() function to return a full config file example # Mock the open() function to return a full config file example
with patch('builtins.open', return_value=StringIO(_FULL_CONFIG)): with patch('builtins.open', return_value=StringIO(_FULL_CONFIG)):
# The filename is only a placeholder, file content is determined by mocking open # The filename is only a placeholder, file content is determined by mocking open
config = parse_config_file('test') parse_config_file('test')
config = get_config()
# Make sure all mandatory values are present # Make sure all mandatory values are present
self.assertIn('listen', config) self.assertIn('listen', config)
self.assertIn('port', config) self.assertIn('port', config)
@ -186,7 +188,8 @@ class TestConfig(TestCase):
# second call # second call
with patch('builtins.open', return_value=IterOpenMock([_FULL_CONFIG, _PARTIAL_CONFIG])): with patch('builtins.open', return_value=IterOpenMock([_FULL_CONFIG, _PARTIAL_CONFIG])):
# These filenames are only placeholders, file content is determined by mocking open # These filenames are only placeholders, file content is determined by mocking open
config = parse_config_file(['full', 'partial']) parse_config_file(['full', 'partial'])
config = get_config()
# Make sure all mandatory values are present # Make sure all mandatory values are present
self.assertIn('listen', config) self.assertIn('listen', config)
self.assertIn('port', config) self.assertIn('port', config)
@ -216,7 +219,8 @@ class TestConfig(TestCase):
# Mock the open() function to return a config file example with disabled logging # Mock the open() function to return a config file example with disabled logging
with patch('builtins.open', return_value=StringIO(_LOG_NONE_CONFIG)): with patch('builtins.open', return_value=StringIO(_LOG_NONE_CONFIG)):
# The filename is only a placeholder, file content is determined by mocking open # The filename is only a placeholder, file content is determined by mocking open
config = parse_config_file('test') parse_config_file('test')
config = get_config()
# Make sure the returned log handler is a null handler # Make sure the returned log handler is a null handler
self.assertIsInstance(config['log_handler'], logging.NullHandler) self.assertIsInstance(config['log_handler'], logging.NullHandler)
@ -227,7 +231,8 @@ class TestConfig(TestCase):
# Mock the open() function to return a config file example with stdout logging # Mock the open() function to return a config file example with stdout logging
with patch('builtins.open', return_value=StringIO(_LOG_STDOUT_CONFIG)): with patch('builtins.open', return_value=StringIO(_LOG_STDOUT_CONFIG)):
# The filename is only a placeholder, file content is determined by mocking open # The filename is only a placeholder, file content is determined by mocking open
config = parse_config_file('test') parse_config_file('test')
config = get_config()
# Make sure the returned log handler is a stdout handler # Make sure the returned log handler is a stdout handler
self.assertIsInstance(config['log_handler'], logging.StreamHandler) self.assertIsInstance(config['log_handler'], logging.StreamHandler)
self.assertEqual(sys.stdout, config['log_handler'].stream) self.assertEqual(sys.stdout, config['log_handler'].stream)
@ -239,7 +244,8 @@ class TestConfig(TestCase):
# Mock the open() function to return a config file example with stdout logging # Mock the open() function to return a config file example with stdout logging
with patch('builtins.open', return_value=StringIO(_LOG_STDERR_CONFIG)): with patch('builtins.open', return_value=StringIO(_LOG_STDERR_CONFIG)):
# The filename is only a placeholder, file content is determined by mocking open # The filename is only a placeholder, file content is determined by mocking open
config = parse_config_file('test') parse_config_file('test')
config = get_config()
# Make sure the returned log handler is a stdout handler # Make sure the returned log handler is a stdout handler
self.assertIsInstance(config['log_handler'], logging.StreamHandler) self.assertIsInstance(config['log_handler'], logging.StreamHandler)
self.assertEqual(sys.stderr, config['log_handler'].stream) self.assertEqual(sys.stderr, config['log_handler'].stream)

View file

@ -1,108 +0,0 @@
from typing import Any, Dict, Union
from matemat.exceptions import HttpException
from matemat.webserver import HttpHandler, RequestArguments, PageletResponse
from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet
@test_pagelet('/just/testing/http_exception')
def test_pagelet_http_exception(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
raise HttpException(int(str(args.exc)), 'Test Exception')
@test_pagelet('/just/testing/value_error')
def test_pagelet_value_error(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
raise ValueError('test')
@test_pagelet('/just/testing/permission_error')
def test_pagelet_permission_error(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
raise PermissionError('test')
@test_pagelet('/just/testing/other_error')
def test_pagelet_other_error(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
raise TypeError('test')
class TestHttpd(AbstractHttpdTest):
def test_httpd_get_illegal_path(self):
self.client_sock.set_request(b'GET /foo?bar?baz HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(400, packet.statuscode)
def test_httpd_post_illegal_path(self):
self.client_sock.set_request(b'POST /foo?bar?baz HTTP/1.1\r\n'
b'Content-Length: 0\r\n'
b'Content-Type: application/x-www-form-urlencoded\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(400, packet.statuscode)
def test_httpd_post_illegal_header(self):
self.client_sock.set_request(b'POST /foo?bar=baz HTTP/1.1\r\n'
b'Content-Length: 0\r\n'
b'Content-Type: application/octet-stream\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(400, packet.statuscode)
def test_httpd_post_request_too_big(self):
self.client_sock.set_request(b'POST /foo?bar=baz HTTP/1.1\r\n'
b'Content-Length: 1000001\r\n'
b'Content-Type: application/octet-stream\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(413, packet.statuscode)
def test_httpd_exception_http_400(self):
self.client_sock.set_request(b'GET /just/testing/http_exception?exc=400 HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(400, packet.statuscode)
def test_httpd_exception_http_500(self):
self.client_sock.set_request(b'GET /just/testing/http_exception?exc=500 HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(500, packet.statuscode)
def test_httpd_exception_value_error(self):
self.client_sock.set_request(b'GET /just/testing/value_error HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(400, packet.statuscode)
def test_httpd_exception_permission_error(self):
self.client_sock.set_request(b'GET /just/testing/permission_error HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(403, packet.statuscode)
def test_httpd_exception_other_error(self):
self.client_sock.set_request(b'GET /just/testing/other_error HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(500, packet.statuscode)

View file

@ -1,65 +0,0 @@
from typing import Dict
import unittest
import logging
from threading import Lock, Thread, Timer
from time import sleep
import jinja2
from matemat.webserver import MatematWebserver, pagelet_cron
lock: Lock = Lock()
cron1called: int = 0
cron2called: int = 0
@pagelet_cron(seconds=4)
def cron1(config: Dict[str, str],
jinja_env: jinja2.Environment,
logger: logging.Logger) -> None:
global cron1called
with lock:
cron1called += 1
@pagelet_cron(seconds=3)
def cron2(config: Dict[str, str],
jinja_env: jinja2.Environment,
logger: logging.Logger) -> None:
global cron2called
with lock:
cron2called += 1
class TestPageletCron(unittest.TestCase):
def setUp(self):
self.srv = MatematWebserver('::1', 0, '/nonexistent', '/nonexistent', {}, {},
logging.NOTSET, logging.NullHandler())
self.srv_port = int(self.srv._httpd.socket.getsockname()[1])
self.timer = Timer(10.0, self.srv._httpd.shutdown)
self.timer.start()
def tearDown(self):
self.timer.cancel()
if self.srv is not None:
self.srv._httpd.socket.close()
def test_cron(self):
"""
Test that the cron functions are called properly.
"""
thread = Thread(target=self.srv.start)
thread.start()
sleep(12)
self.srv._httpd.shutdown()
with lock:
self.assertEqual(2, cron1called)
self.assertEqual(3, cron2called)
# Make sure the cron threads were stopped
sleep(5)
with lock:
self.assertEqual(2, cron1called)
self.assertEqual(3, cron2called)

View file

@ -1,68 +0,0 @@
from typing import Any, Dict
import unittest
import http.client
import logging
import threading
from matemat.webserver import MatematWebserver, RequestArguments, pagelet_init, pagelet
@pagelet('/just/testing/init')
def init_test_pagelet(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]):
return pagelet_variables['Unit-Test']
_INIT_FAIL = False
@pagelet_init
def init(config: Dict[str, str],
logger: logging.Logger):
if _INIT_FAIL:
raise ValueError('This error should be raised!')
config['Unit-Test'] = 'Pagelet Init Test'
class TestPageletInitialization(unittest.TestCase):
def setUp(self):
self.srv = MatematWebserver('::1', 0, '/nonexistent', '/nonexistent', {}, {},
logging.NOTSET, logging.NullHandler())
self.srv_port = int(self.srv._httpd.socket.getsockname()[1])
self.timer = threading.Timer(5.0, self.srv._httpd.shutdown)
self.timer.start()
def tearDown(self):
self.timer.cancel()
if self.srv is not None:
self.srv._httpd.socket.close()
global _INIT_FAIL
_INIT_FAIL = False
def test_pagelet_init_ok(self):
"""
Test successful pagelet initialization
"""
thread = threading.Thread(target=self.srv.start)
thread.start()
con = http.client.HTTPConnection(f'[::1]:{self.srv_port}')
con.request('GET', '/just/testing/init')
response = con.getresponse().read()
self.srv._httpd.shutdown()
self.assertEqual(b'Pagelet Init Test', response)
def test_pagelet_init_fail(self):
"""
Test unsuccessful pagelet initialization
"""
global _INIT_FAIL
_INIT_FAIL = True
with self.assertRaises(ValueError):
self.srv.start()

View file

@ -1,437 +0,0 @@
import unittest
from matemat.webserver.util import parse_args
class TestParseRequest(unittest.TestCase):
def test_parse_get_root(self):
"""
Test that the simple root path is parsed correctly ('/' path, no args).
"""
path, args = parse_args('/')
self.assertEqual('/', path)
self.assertEqual(0, len(args))
def test_parse_get_no_args(self):
"""
Test that a GET request without arguments is parsed correctly.
"""
path, args = parse_args('/index.html')
self.assertEqual('/index.html', path)
self.assertEqual(0, len(args))
def test_parse_get_root_getargs(self):
"""
Test that a GET request for '/' with scalar arguments is parsed correctly.
"""
path, args = parse_args('/?foo=42&bar=1337&baz=Hello,%20World!')
self.assertEqual('/', path)
self.assertEqual(3, len(args))
self.assertIn('foo', args)
self.assertIn('bar', args)
self.assertIn('baz', args)
self.assertTrue(args['foo'].is_scalar)
self.assertTrue(args['bar'].is_scalar)
self.assertTrue(args['baz'].is_scalar)
self.assertEqual('text/plain', args['foo'].get_content_type())
self.assertEqual('text/plain', args['bar'].get_content_type())
self.assertEqual('text/plain', args['baz'].get_content_type())
self.assertEqual('42', args['foo'].get_str())
self.assertEqual('1337', args['bar'].get_str())
self.assertEqual('Hello, World!', args['baz'].get_str())
def test_parse_get_getargs(self):
"""
Test that a GET request for an arbitrary path with scalar arguments is parsed correctly.
"""
path, args = parse_args('/abc/def?foo=42&bar=1337&baz=Hello,%20World!')
self.assertEqual('/abc/def', path)
self.assertEqual(3, len(args))
self.assertIn('foo', args)
self.assertIn('bar', args)
self.assertIn('baz', args)
self.assertTrue(args['foo'].is_scalar)
self.assertTrue(args['bar'].is_scalar)
self.assertTrue(args['baz'].is_scalar)
self.assertEqual('text/plain', args['foo'].get_content_type())
self.assertEqual('text/plain', args['bar'].get_content_type())
self.assertEqual('text/plain', args['baz'].get_content_type())
self.assertEqual('42', args['foo'].get_str())
self.assertEqual('1337', args['bar'].get_str())
self.assertEqual('Hello, World!', args['baz'].get_str())
def test_parse_get_getarray(self):
"""
Test that a GET request with mixed scalar and array arguments is parsed correctly.
"""
path, args = parse_args('/abc/def?foo=42&foo=1337&baz=Hello,%20World!')
self.assertEqual('/abc/def', path)
self.assertEqual(2, len(args))
self.assertIn('foo', args)
self.assertIn('baz', args)
self.assertTrue(args['foo'].is_array)
self.assertTrue(args['baz'].is_scalar)
self.assertEqual(2, len(args['foo']))
self.assertEqual('42', args['foo'].get_str(0))
self.assertEqual('1337', args['foo'].get_str(1))
def test_parse_get_zero_arg(self):
"""
Test that a GET request with an empty argument is parsed correctly.
"""
path, args = parse_args('/abc/def?foo=&bar=42')
self.assertEqual(2, len(args))
self.assertIn('foo', args)
self.assertIn('bar', args)
self.assertTrue(args['foo'].is_scalar)
self.assertTrue(args['bar'].is_scalar)
self.assertEqual(1, len(args['foo']))
self.assertEqual('', args['foo'].get_str())
self.assertEqual('42', args['bar'].get_str())
def test_parse_get_urlencoded_encoding_fail(self):
"""
Test that a GET request with non-decodable escape sequences fails.
"""
with self.assertRaises(ValueError):
parse_args('/?foo=42&bar=%80&baz=Hello,%20World!')
def test_parse_post_urlencoded(self):
"""
Test that a urlencoded POST request with scalar arguments is parsed correctly.
"""
path, args = parse_args('/',
postbody=b'foo=42&bar=1337&baz=Hello,%20World!',
enctype='application/x-www-form-urlencoded')
self.assertEqual('/', path)
self.assertEqual(3, len(args))
self.assertIn('foo', args)
self.assertIn('bar', args)
self.assertIn('baz', args)
self.assertTrue(args['foo'].is_scalar)
self.assertTrue(args['bar'].is_scalar)
self.assertTrue(args['baz'].is_scalar)
self.assertEqual('text/plain', args['foo'].get_content_type())
self.assertEqual('text/plain', args['bar'].get_content_type())
self.assertEqual('text/plain', args['baz'].get_content_type())
self.assertEqual('42', args['foo'].get_str())
self.assertEqual('1337', args['bar'].get_str())
self.assertEqual('Hello, World!', args['baz'].get_str())
def test_parse_post_urlencoded_array(self):
"""
Test that a urlencoded POST request with mixed scalar and array arguments is parsed correctly.
"""
path, args = parse_args('/',
postbody=b'foo=42&foo=1337&baz=Hello,%20World!',
enctype='application/x-www-form-urlencoded')
self.assertEqual('/', path)
self.assertEqual(2, len(args))
self.assertIn('foo', args)
self.assertIn('baz', args)
self.assertTrue(args['foo'].is_array)
self.assertTrue(args['baz'].is_scalar)
self.assertEqual(2, len(args['foo']))
self.assertEqual('42', args['foo'].get_str(0))
self.assertEqual('1337', args['foo'].get_str(1))
def test_parse_post_urlencoded_zero_arg(self):
"""
Test that a urlencoded POST request with an empty argument is parsed correctly.
"""
path, args = parse_args('/abc/def', postbody=b'foo=&bar=42', enctype='application/x-www-form-urlencoded')
self.assertEqual(2, len(args))
self.assertIn('foo', args)
self.assertIn('bar', args)
self.assertTrue(args['foo'].is_scalar)
self.assertTrue(args['bar'].is_scalar)
self.assertEqual(1, len(args['foo']))
self.assertEqual('', args['foo'].get_str())
self.assertEqual('42', args['bar'].get_str())
def test_parse_post_urlencoded_encoding_fail(self):
"""
Test that a urlencoded POST request with a non-decodable escape sequence fails.
"""
with self.assertRaises(ValueError):
parse_args('/',
postbody=b'foo=42&bar=%80&baz=Hello,%20World!',
enctype='application/x-www-form-urlencoded')
def test_parse_post_multipart_no_args(self):
"""
Test that a multipart POST request with no arguments is parsed correctly.
"""
path, args = parse_args('/',
postbody=b'--testBoundary1337--\r\n',
enctype='multipart/form-data; boundary=testBoundary1337')
self.assertEqual('/', path)
self.assertEqual(0, len(args))
def test_parse_post_multipart(self):
"""
Test that a multipart POST request with scalar arguments is parsed correctly.
"""
path, args = parse_args('/',
postbody=b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'42\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="bar"; filename="bar.bin"\r\n'
b'Content-Type: application/octet-stream\r\n\r\n'
b'1337\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="baz"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'Hello, World!\r\n'
b'--testBoundary1337--\r\n',
enctype='multipart/form-data; boundary=testBoundary1337')
self.assertEqual('/', path)
self.assertEqual(3, len(args))
self.assertIn('foo', args)
self.assertIn('bar', args)
self.assertIn('baz', args)
self.assertTrue(args['foo'].is_scalar)
self.assertTrue(args['bar'].is_scalar)
self.assertTrue(args['baz'].is_scalar)
self.assertEqual('text/plain', args['foo'].get_content_type())
self.assertEqual('application/octet-stream', args['bar'].get_content_type())
self.assertEqual('text/plain', args['baz'].get_content_type())
self.assertEqual('42', args['foo'].get_str())
self.assertEqual(b'1337', args['bar'].get_bytes())
self.assertEqual('Hello, World!', args['baz'].get_str())
def test_parse_post_multipart_names(self):
"""
Test that multipart names work both with and without quotation marks
"""
path, args = parse_args('/',
postbody=b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'42\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name=bar\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'Hello, World!\r\n'
b'--testBoundary1337--\r\n',
enctype='multipart/form-data; boundary=testBoundary1337')
self.assertEqual('/', path)
self.assertEqual(2, len(args))
self.assertIn('foo', args)
self.assertIn('bar', args)
self.assertTrue(args['foo'].is_scalar)
self.assertTrue(args['bar'].is_scalar)
self.assertEqual('text/plain', args['foo'].get_content_type())
self.assertEqual('text/plain', args['bar'].get_content_type())
self.assertEqual('42', args['foo'].get_str())
self.assertEqual('Hello, World!', args['bar'].get_str())
def test_parse_post_multipart_zero_arg(self):
"""
Test that a multipart POST request with an empty argument is parsed correctly.
"""
path, args = parse_args('/abc/def',
postbody=b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="bar"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'42\r\n'
b'--testBoundary1337--\r\n',
enctype='multipart/form-data; boundary=testBoundary1337')
self.assertEqual(2, len(args))
self.assertIn('foo', args)
self.assertIn('bar', args)
self.assertTrue(args['foo'].is_scalar)
self.assertTrue(args['bar'].is_scalar)
self.assertEqual(1, len(args['foo']))
self.assertEqual('', args['foo'].get_str())
self.assertEqual('42', args['bar'].get_str())
def test_parse_post_multipart_no_contenttype(self):
"""
Test that the Content-Type is set to 'application/octet-stream' if it is absent from the multipart header.
"""
path, args = parse_args('/',
postbody=b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'42\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="bar"; filename="bar.bin"\r\n'
b'Content-Type: application/octet-stream\r\n\r\n'
b'1337\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="baz"\r\n\r\n'
b'Hello, World!\r\n'
b'--testBoundary1337--\r\n',
enctype='multipart/form-data; boundary=testBoundary1337')
self.assertEqual('/', path)
self.assertEqual(3, len(args))
self.assertIn('foo', args)
self.assertIn('bar', args)
self.assertIn('baz', args)
self.assertTrue(args['foo'].is_scalar)
self.assertTrue(args['bar'].is_scalar)
self.assertTrue(args['baz'].is_scalar)
self.assertEqual('application/octet-stream', args['baz'].get_content_type())
self.assertEqual('Hello, World!', args['baz'].get_str())
def test_parse_post_multipart_broken_boundaries(self):
"""
Test that multiple cases with broken multipart boundaries fail.
"""
with self.assertRaises(ValueError):
# Boundary not defined in Content-Type
parse_args('/',
postbody=b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'42\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="bar"; filename="bar.bin"\r\n'
b'Content-Type: application/octet-stream\r\n\r\n'
b'1337\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="baz"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'Hello, World!\r\n'
b'--testBoundary1337--\r\n',
enctype='multipart/form-data')
with self.assertRaises(ValueError):
# Corrupted "--" head at first boundary
parse_args('/',
postbody=b'-+testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'42\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="bar"; filename="bar.bin"\r\n'
b'Content-Type: application/octet-stream\r\n\r\n'
b'1337\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="baz"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'Hello, World!\r\n'
b'--testBoundary1337--\r\n',
enctype='multipart/form-data; boundary=testBoundary1337')
with self.assertRaises(ValueError):
# Missing "--" tail at end boundary
parse_args('/',
postbody=b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'42\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="bar"; filename="bar.bin"\r\n'
b'Content-Type: application/octet-stream\r\n\r\n'
b'1337\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="baz"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'Hello, World!\r\n'
b'--testBoundary1337\r\n',
enctype='multipart/form-data; boundary=testBoundary1337')
with self.assertRaises(ValueError):
# Missing Content-Disposition header in one part
parse_args('/',
postbody=b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'42\r\n'
b'--testBoundary1337\r\n'
b'Content-Type: application/octet-stream\r\n\r\n'
b'1337\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="baz"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'Hello, World!\r\n'
b'--testBoundary1337--\r\n',
enctype='multipart/form-data; boundary=testBoundary1337')
with self.assertRaises(ValueError):
# Missing form-data name argument
parse_args('/',
postbody=b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'42\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; filename="bar.bin"\r\n'
b'Content-Type: application/octet-stream\r\n\r\n'
b'1337\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="baz"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'Hello, World!\r\n'
b'--testBoundary1337--\r\n',
enctype='multipart/form-data; boundary=testBoundary1337')
with self.assertRaises(ValueError):
# Unknown Content-Disposition
parse_args('/',
postbody=b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'42\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: attachment; name="bar"; filename="bar.bin"\r\n'
b'Content-Type: application/octet-stream\r\n\r\n'
b'1337\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="baz"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'Hello, World!\r\n'
b'--testBoundary1337--\r\n',
enctype='multipart/form-data; boundary=testBoundary1337')
def test_get_post_precedence_urlencoded(self):
"""
Test the precedence of urlencoded POST arguments over GET arguments.
"""
path, args = parse_args('/foo?foo=thisshouldnotbethere&bar=isurvived',
postbody=b'foo=42&foo=1337&baz=Hello,%20World!',
enctype='application/x-www-form-urlencoded')
self.assertIn('foo', args)
self.assertIn('bar', args)
self.assertIn('baz', args)
self.assertEqual(2, len(args['foo']))
self.assertEqual(1, len(args['bar']))
self.assertEqual(1, len(args['baz']))
self.assertEqual('42', args['foo'].get_str(0))
self.assertEqual('1337', args['foo'].get_str(1))
self.assertEqual('isurvived', args['bar'].get_str())
self.assertEqual('Hello, World!', args['baz'].get_str())
def test_get_post_precedence_multipart(self):
"""
Test the precedence of multipart POST arguments over GET arguments.
"""
path, args = parse_args('/foo?foo=thisshouldnotbethere&bar=isurvived',
postbody=b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'42\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="foo"; filename="bar.bin"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'1337\r\n'
b'--testBoundary1337\r\n'
b'Content-Disposition: form-data; name="baz"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'Hello, World!\r\n'
b'--testBoundary1337--\r\n',
enctype='multipart/form-data; boundary=testBoundary1337')
self.assertIn('foo', args)
self.assertIn('bar', args)
self.assertIn('baz', args)
self.assertEqual(2, len(args['foo']))
self.assertEqual(1, len(args['bar']))
self.assertEqual(1, len(args['baz']))
self.assertEqual('42', args['foo'].get_str(0))
self.assertEqual('1337', args['foo'].get_str(1))
self.assertEqual('isurvived', args['bar'].get_str())
self.assertEqual('Hello, World!', args['baz'].get_str())

View file

@ -1,225 +0,0 @@
from typing import Any, Dict, List
from matemat.webserver import HttpHandler, RequestArguments
from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet
import codecs
@test_pagelet('/just/testing/post')
def post_test_pagelet(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]):
"""
Test pagelet that simply prints the parsed arguments as response body.
"""
headers['Content-Type'] = 'text/plain'
dump: str = ''
for ra in args:
for a in ra:
if a.get_content_type().startswith('text/'):
dump += f'{a.name}: {a.get_str()}\n'
else:
dump += f'{a.name}: {codecs.encode(a.get_bytes(), "hex").decode("utf-8")}\n'
return dump
class TestPost(AbstractHttpdTest):
"""
Test cases for the content serving of the web server.
"""
def test_post_urlenc_get_only_args(self):
"""
Test a POST request that only contains GET arguments.
"""
# Send POST request
self.client_sock.set_request(b'POST /just/testing/post?foo=bar&test=1 HTTP/1.1\r\n'
b'Content-Length: 0\r\n'
b'Content-Type: application/x-www-form-urlencoded\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Parse response body
lines: List[bytes] = packet.body.split(b'\n')[:-1]
kv: Dict[str, str] = dict()
for l in lines:
k, v = l.decode('utf-8').split(':', 1)
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
# Make sure the arguments were properly parsed
self.assertEqual('bar', kv['foo'])
self.assertEqual('1', kv['test'])
def test_post_urlenc_post_only_args(self):
"""
Test a POST request that only contains POST arguments (urlencoded).
"""
# Send POST request
self.client_sock.set_request(b'POST /just/testing/post HTTP/1.1\r\n'
b'Content-Type: application/x-www-form-urlencoded\r\n'
b'Content-Length: 14\r\n\r\n'
b'foo=bar&test=1\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Parse response body
lines: List[bytes] = packet.body.split(b'\n')[:-1]
kv: Dict[str, str] = dict()
for l in lines:
k, v = l.decode('utf-8').split(':', 1)
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
# Make sure the arguments were properly parsed
self.assertEqual('bar', kv['foo'])
self.assertEqual('1', kv['test'])
def test_post_urlenc_mixed_args(self):
"""
Test that mixed POST and GET args are properly parsed, and that POST takes precedence over GET.
"""
# Send POST request
self.client_sock.set_request(b'POST /just/testing/post?gettest=1&foo=baz HTTP/1.1\r\n'
b'Content-Type: application/x-www-form-urlencoded\r\n'
b'Content-Length: 18\r\n\r\n'
b'foo=bar&posttest=2\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Parse response body
lines: List[bytes] = packet.body.split(b'\n')[:-1]
kv: Dict[str, str] = dict()
for l in lines:
k, v = l.decode('utf-8').split(':', 1)
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
# Make sure the arguments were properly parsed
self.assertEqual('bar', kv['foo'])
self.assertEqual('1', kv['gettest'])
self.assertEqual('2', kv['posttest'])
def test_post_urlenc_get_array(self):
"""
Test a POST request that contains GET array arguments.
"""
# Send POST request
self.client_sock.set_request(b'POST /just/testing/post?foo=bar&test=1&foo=baz HTTP/1.1\r\n'
b'Content-Length: 0\r\n'
b'Content-Type: application/x-www-form-urlencoded\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Parse response body
lines: List[bytes] = packet.body.split(b'\n')[:-1]
kv: Dict[str, str] = dict()
for l in lines:
k, v = l.decode('utf-8').split(':', 1)
k = k.strip()
v = v.strip()
if k in kv:
kv[k] += f',{v}'
else:
kv[k] = v
# Make sure the arguments were properly parsed
self.assertEqual('bar,baz', kv['foo'])
self.assertEqual('1', kv['test'])
def test_post_urlenc_post_array(self):
"""
Test a POST request that contains POST array arguments.
"""
# Send POST request
self.client_sock.set_request(b'POST /just/testing/post HTTP/1.1\r\n'
b'Content-Length: 22\r\n'
b'Content-Type: application/x-www-form-urlencoded\r\n\r\n'
b'foo=bar&test=1&foo=baz\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Parse response body
lines: List[bytes] = packet.body.split(b'\n')[:-1]
kv: Dict[str, str] = dict()
for l in lines:
k, v = l.decode('utf-8').split(':', 1)
k = k.strip()
v = v.strip()
if k in kv:
kv[k] += f',{v}'
else:
kv[k] = v
# Make sure the arguments were properly parsed
self.assertEqual('bar,baz', kv['foo'])
self.assertEqual('1', kv['test'])
def test_post_urlenc_mixed_array(self):
"""
Test a POST request that contains both GET and POST array arguments.
"""
# Send POST request
self.client_sock.set_request(b'POST /just/testing/post?foo=getbar&gettest=1&gettest=42&foo=getbaz HTTP/1.1\r\n'
b'Content-Length: 45\r\n'
b'Content-Type: application/x-www-form-urlencoded\r\n\r\n'
b'foo=postbar&posttest=1&posttest=2&foo=postbaz\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Parse response body
lines: List[bytes] = packet.body.split(b'\n')[:-1]
kv: Dict[str, str] = dict()
for l in lines:
k, v = l.decode('utf-8').split(':', 1)
k = k.strip()
v = v.strip()
if k in kv:
kv[k] += f',{v}'
else:
kv[k] = v
# Make sure the arguments were properly parsed
self.assertEqual('postbar,postbaz', kv['foo'])
self.assertEqual('1,42', kv['gettest'])
self.assertEqual('1,2', kv['posttest'])
def test_post_no_body(self):
"""
Test a POST request that contains no headers or body.
"""
# Send POST request
self.client_sock.set_request(b'POST /just/testing/post?foo=bar HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure a 400 Bad Request is returned
self.assertEqual(400, packet.statuscode)
def test_post_multipart_post_only(self):
"""
Test a POST request with a miutipart/form-data body.
"""
# Send POST request
formdata = (b'------testboundary\r\n'
b'Content-Disposition: form-data; name="foo"\r\n'
b'Content-Type: text/plain\r\n\r\n'
b'Hello, World!\r\n'
b'------testboundary\r\n'
b'Content-Disposition: form-data; name="bar"; filename="foo.bar"\r\n'
b'Content-Type: application/octet-stream\r\n\r\n'
b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x80\x0b\x0c\x73\x0e\x0f\r\n'
b'------testboundary--\r\n')
self.client_sock.set_request(f'POST /just/testing/post HTTP/1.1\r\n'
f'Content-Type: multipart/form-data; boundary=----testboundary\r\n'
f'Content-Length: {len(formdata)}\r\n\r\n'.encode('utf-8') + formdata)
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
lines: List[bytes] = packet.body.split(b'\n')[:-1]
kv: Dict[str, Any] = dict()
for l in lines:
k, v = l.split(b':', 1)
kv[k.decode('utf-8').strip()] = v.strip()
self.assertIn('foo', kv)
self.assertIn('bar', kv)
self.assertEqual(kv['foo'], b'Hello, World!')
self.assertEqual(kv['bar'], b'00010203040506070809800b0c730e0f')

View file

@ -1,529 +0,0 @@
from typing import Dict, List, Set, Tuple
import unittest
import urllib.parse
from matemat.webserver import RequestArgument, RequestArguments
# noinspection PyProtectedMember
from matemat.webserver.requestargs import _View
class TestRequestArguments(unittest.TestCase):
"""
Test cases for the RequestArgument class.
"""
def test_create_default(self):
"""
Test creation of an empty RequestArgument
"""
ra = RequestArgument('foo')
# Name must be set to 1st argument
self.assertEqual('foo', ra.name)
# Must be a 0-length array
self.assertEqual(0, len(ra))
self.assertFalse(ra.is_scalar)
self.assertTrue(ra.is_array)
# Must not be a view
self.assertFalse(ra.is_view)
def test_create_str_scalar(self):
"""
Test creation of a scalar RequestArgument with string value.
"""
ra = RequestArgument('foo', ('text/plain', 'bar'))
# Name must be set to 1st argument
self.assertEqual('foo', ra.name)
# Must be a scalar, length 1
self.assertEqual(1, len(ra))
self.assertTrue(ra.is_scalar)
self.assertFalse(ra.is_array)
# Scalar value must be representable both as str and bytes
self.assertEqual('bar', ra.get_str())
self.assertEqual(b'bar', ra.get_bytes())
# Content-Type must be set correctly
self.assertEqual('text/plain', ra.get_content_type())
# Using 0 indices must yield the same results
self.assertEqual('bar', ra.get_str(0))
self.assertEqual(b'bar', ra.get_bytes(0))
self.assertEqual('text/plain', ra.get_content_type(0))
# Using other indices must result in an error
with self.assertRaises(IndexError):
ra.get_str(1)
with self.assertRaises(IndexError):
ra.get_bytes(1)
with self.assertRaises(IndexError):
ra.get_content_type(1)
# Must not be a view
self.assertFalse(ra.is_view)
def test_create_str_scalar_array(self):
"""
Test creation of a scalar RequestArgument with string value, passing an array instead of a single tuple.
"""
ra = RequestArgument('foo', [('text/plain', 'bar')])
# Name must be set to 1st argument
self.assertEqual('foo', ra.name)
# Must be a scalar, length 1
self.assertEqual(1, len(ra))
self.assertTrue(ra.is_scalar)
self.assertFalse(ra.is_array)
# Scalar value must be representable both as str and bytes
self.assertEqual('bar', ra.get_str())
self.assertEqual(b'bar', ra.get_bytes())
# Content-Type must be set correctly
self.assertEqual('text/plain', ra.get_content_type())
# Using 0 indices must yield the same results
self.assertEqual('bar', ra.get_str(0))
self.assertEqual(b'bar', ra.get_bytes(0))
self.assertEqual('text/plain', ra.get_content_type(0))
# Using other indices must result in an error
with self.assertRaises(IndexError):
ra.get_str(1)
with self.assertRaises(IndexError):
ra.get_bytes(1)
with self.assertRaises(IndexError):
ra.get_content_type(1)
# Must not be a view
self.assertFalse(ra.is_view)
def test_create_bytes_scalar(self):
"""
Test creation of a scalar RequestArgument with bytes value.
"""
ra = RequestArgument('foo', ('application/octet-stream', b'\x00\x80\xff\xfe'))
# Name must be set to 1st argument
self.assertEqual('foo', ra.name)
# Must be a scalar, length 1
self.assertEqual(1, len(ra))
self.assertTrue(ra.is_scalar)
self.assertFalse(ra.is_array)
# Conversion to UTF-8 string must fail; bytes representation must work
with self.assertRaises(UnicodeDecodeError):
ra.get_str()
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes())
# Content-Type must be set correctly
self.assertEqual('application/octet-stream', ra.get_content_type())
# Using 0 indices must yield the same results
with self.assertRaises(UnicodeDecodeError):
ra.get_str(0)
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes(0))
self.assertEqual('application/octet-stream', ra.get_content_type(0))
# Using other indices must result in an error
with self.assertRaises(IndexError):
ra.get_str(1)
with self.assertRaises(IndexError):
ra.get_bytes(1)
with self.assertRaises(IndexError):
ra.get_content_type(1)
# Must not be a view
self.assertFalse(ra.is_view)
def test_create_array(self):
"""
Test creation of an array RequestArgument with mixed str and bytes initial value.
"""
ra = RequestArgument('foo', [
('text/plain', 'bar'),
('application/octet-stream', b'\x00\x80\xff\xfe')
])
# Name must be set to 1st argument
self.assertEqual('foo', ra.name)
# Must be an array, length 2
self.assertEqual(2, len(ra))
self.assertFalse(ra.is_scalar)
self.assertTrue(ra.is_array)
# Retrieving values without an index must yield the first element
self.assertEqual('bar', ra.get_str())
self.assertEqual(b'bar', ra.get_bytes())
self.assertEqual('text/plain', ra.get_content_type())
# The first value must be representable both as str and bytes, and have ctype text/plain
self.assertEqual('bar', ra.get_str(0))
self.assertEqual(b'bar', ra.get_bytes(0))
self.assertEqual('text/plain', ra.get_content_type(0))
# Conversion of the second value to UTF-8 string must fail; bytes representation must work
with self.assertRaises(UnicodeDecodeError):
ra.get_str(1)
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes(1))
# The second value's ctype must be correct
self.assertEqual('application/octet-stream', ra.get_content_type(1))
# Must not be a view
self.assertFalse(ra.is_view)
def test_append_empty_str(self):
"""
Test appending a str value to an empty RequestArgument.
"""
# Initialize the empty RequestArgument
ra = RequestArgument('foo')
self.assertEqual(0, len(ra))
self.assertFalse(ra.is_scalar)
# Append a string value
ra.append('text/plain', 'bar')
# New length must be 1, empty array must be converted to scalar
self.assertEqual(1, len(ra))
self.assertTrue(ra.is_scalar)
# Retrieval of the new value must work both in str and bytes representation
self.assertEqual('bar', ra.get_str())
self.assertEqual(b'bar', ra.get_bytes())
# Content type of the new value must be correct
self.assertEqual('text/plain', ra.get_content_type())
# Must not be a view
self.assertFalse(ra.is_view)
def test_append_empty_bytes(self):
"""
Test appending a bytes value to an empty RequestArgument.
"""
# Initialize the empty RequestArgument
ra = RequestArgument('foo')
self.assertEqual(0, len(ra))
self.assertFalse(ra.is_scalar)
# Append a bytes value
ra.append('application/octet-stream', b'\x00\x80\xff\xfe')
# New length must be 1, empty array must be converted to scalar
self.assertEqual(1, len(ra))
self.assertTrue(ra.is_scalar)
# Conversion of the new value to UTF-8 string must fail; bytes representation must work
with self.assertRaises(UnicodeDecodeError):
ra.get_str()
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes())
# Content type of the new value must be correct
self.assertEqual('application/octet-stream', ra.get_content_type())
# Must not be a view
self.assertFalse(ra.is_view)
def test_append_multiple(self):
"""
Test appending multiple values to an empty RequestArgument.
"""
# Initialize the empty RequestArgument
ra = RequestArgument('foo')
self.assertEqual(0, len(ra))
self.assertFalse(ra.is_scalar)
# Append a first value
ra.append('text/plain', 'bar')
# New length must be 1, empty array must be converted to scalar
self.assertEqual(1, len(ra))
self.assertTrue(ra.is_scalar)
self.assertEqual(b'bar', ra.get_bytes())
# Append a second value
ra.append('application/octet-stream', b'\x00\x80\xff\xfe')
# New length must be 2, scalar must be converted to array
self.assertEqual(2, len(ra))
self.assertFalse(ra.is_scalar)
self.assertEqual(b'bar', ra.get_bytes(0))
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes(1))
# Append a third value
ra.append('text/plain', 'Hello, World!')
# New length must be 3, array must remain array
self.assertEqual(3, len(ra))
self.assertFalse(ra.is_scalar)
self.assertEqual(b'bar', ra.get_bytes(0))
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes(1))
self.assertEqual(b'Hello, World!', ra.get_bytes(2))
def test_clear_empty(self):
"""
Test clearing an empty RequestArgument.
"""
# Initialize the empty RequestArgument
ra = RequestArgument('foo')
self.assertEqual(0, len(ra))
self.assertFalse(ra.is_scalar)
ra.clear()
# Clearing an empty RequestArgument shouldn't have any effect
self.assertEqual('foo', ra.name)
self.assertEqual(0, len(ra))
self.assertFalse(ra.is_scalar)
def test_clear_scalar(self):
"""
Test clearing a scalar RequestArgument.
"""
# Initialize the scalar RequestArgument
ra = RequestArgument('foo', ('text/plain', 'bar'))
self.assertEqual(1, len(ra))
self.assertTrue(ra.is_scalar)
ra.clear()
# Clearing a scalar RequestArgument should reduce its size to 0
self.assertEqual('foo', ra.name)
self.assertEqual(0, len(ra))
self.assertFalse(ra.is_scalar)
with self.assertRaises(IndexError):
ra.get_str()
def test_clear_array(self):
"""
Test clearing an array RequestArgument.
"""
# Initialize the array RequestArgument
ra = RequestArgument('foo', [
('text/plain', 'bar'),
('application/octet-stream', b'\x00\x80\xff\xfe'),
('text/plain', 'baz'),
])
self.assertEqual(3, len(ra))
self.assertFalse(ra.is_scalar)
ra.clear()
# Clearing an array RequestArgument should reduce its size to 0
self.assertEqual('foo', ra.name)
self.assertEqual(0, len(ra))
self.assertFalse(ra.is_scalar)
with self.assertRaises(IndexError):
ra.get_str()
def test_iterate_empty(self):
"""
Test iterating an empty RequestArgument.
"""
ra = RequestArgument('foo')
self.assertEqual(0, len(ra))
# No value must be yielded from iterating an empty instance
for _ in ra:
self.fail()
def test_iterate_scalar(self):
"""
Test iterating a scalar RequestArgument.
"""
ra = RequestArgument('foo', ('text/plain', 'bar'))
self.assertTrue(ra.is_scalar)
# Counter for the number of iterations
count: int = 0
for it in ra:
# Make sure the yielded value is a scalar view and has the same name as the original instance
self.assertIsInstance(it, _View)
self.assertTrue(it.is_view)
self.assertEqual('foo', it.name)
self.assertTrue(it.is_scalar)
count += 1
# Only one value must be yielded from iterating a scalar instance
self.assertEqual(1, count)
def test_iterate_array(self):
"""
Test iterating an array RequestArgument.
"""
ra = RequestArgument('foo', [('text/plain', 'bar'), ('abc', b'def'), ('xyz', '1337')])
self.assertFalse(ra.is_scalar)
# Container to put the iterated ctypes into
items: List[str] = list()
for it in ra:
# Make sure the yielded values are scalar views and have the same name as the original instance
self.assertIsInstance(it, _View)
self.assertTrue(it.is_view)
self.assertTrue(it.is_scalar)
# Collect the value's ctype
items.append(it.get_content_type())
# Compare collected ctypes with expected result
self.assertEqual(['text/plain', 'abc', 'xyz'], items)
def test_slice(self):
"""
Test slicing an array RequestArgument.
"""
ra = RequestArgument('foo', [('a', 'b'), ('c', 'd'), ('e', 'f'), ('g', 'h'), ('i', 'j'), ('k', 'l')])
# Create the sliced view
sliced = ra[1:4:2]
# Make sure the sliced value is a view
self.assertIsInstance(sliced, _View)
self.assertTrue(sliced.is_view)
# Make sure the slice has the same name
self.assertEqual('foo', sliced.name)
# Make sure the slice has the expected shape (array of the 2nd and 4th scalar in the original)
self.assertTrue(sliced.is_array)
self.assertEqual(2, len(sliced))
self.assertEqual('d', sliced.get_str(0))
self.assertEqual('h', sliced.get_str(1))
def test_iterate_sliced(self):
"""
Test iterating a sliced array RequestArgument.
"""
ra = RequestArgument('foo', [('a', 'b'), ('c', 'd'), ('e', 'f'), ('g', 'h'), ('i', 'j'), ('k', 'l')])
# Container to put the iterated ctypes into
items: List[str] = list()
# Iterate the sliced view
for it in ra[1:4:2]:
# Make sure the yielded values are scalar views and have the same name as the original instance
self.assertIsInstance(it, _View)
self.assertTrue(it.is_view)
self.assertEqual('foo', it.name)
self.assertTrue(it.is_scalar)
items.append(it.get_content_type())
# Make sure the expected values are collected (array of the 2nd and 4th scalar in the original)
self.assertEqual(['c', 'g'], items)
def test_index_scalar(self):
"""
Test indexing of a scalar RequestArgument.
"""
ra = RequestArgument('foo', ('bar', 'baz'))
# Index the scalar RequestArgument instance, obtaining an immutable view
it = ra[0]
# Make sure the value is a scalar view with the same properties as the original instance
self.assertIsInstance(it, _View)
self.assertTrue(it.is_scalar)
self.assertEqual('foo', it.name)
self.assertEqual('bar', it.get_content_type())
self.assertEqual('baz', it.get_str())
# Make sure other indices don't work
with self.assertRaises(IndexError):
_ = ra[1]
def test_index_array(self):
"""
Test indexing of an array RequestArgument.
"""
ra = RequestArgument('foo', [('a', 'b'), ('c', 'd')])
# Index the array RequestArgument instance, obtaining an immutable view
it = ra[1]
# Make sure the value is a scalar view with the same properties as the value in the original instance
self.assertIsInstance(it, _View)
self.assertEqual('foo', it.name)
self.assertEqual('c', it.get_content_type())
self.assertEqual('d', it.get_str())
def test_view_immutable(self):
"""
Test immutability of views.
"""
ra = RequestArgument('foo', ('bar', 'baz'))
# Index the scalar RequestArgument instance, obtaining an immutable view
it = ra[0]
# Make sure the returned value is a view
self.assertIsInstance(it, _View)
# Make sure the returned value is immutable
with self.assertRaises(TypeError):
it.append('foo', 'bar')
with self.assertRaises(TypeError):
it.clear()
def test_str_shorthand(self):
"""
Test the shorthand for get_str(0).
"""
ra = RequestArgument('foo', ('bar', 'baz'))
self.assertEqual('baz', str(ra))
def test_bytes_shorthand(self):
"""
Test the shorthand for get_bytes(0).
"""
ra = RequestArgument('foo', ('bar', b'\x00\x80\xff\xfe'))
self.assertEqual(b'\x00\x80\xff\xfe', bytes(ra))
# noinspection PyTypeChecker
def test_insert_garbage(self):
"""
Test proper handling with non-int indices and non-str/non-bytes data
:return:
"""
ra = RequestArgument('foo', 42)
with self.assertRaises(TypeError):
str(ra)
ra = RequestArgument('foo', (None, 42))
with self.assertRaises(TypeError):
str(ra)
with self.assertRaises(TypeError):
bytes(ra)
with self.assertRaises(TypeError):
ra.get_content_type()
with self.assertRaises(TypeError):
ra.get_str('foo')
with self.assertRaises(TypeError):
ra.get_bytes('foo')
with self.assertRaises(TypeError):
ra.get_content_type('foo')
def test_requestarguments_index(self):
"""
Make sure indexing a RequestArguments instance creates a new entry on the fly.
"""
ra = RequestArguments()
self.assertEqual(0, len(ra))
self.assertFalse('foo' in ra)
# Create new entry
_ = ra['foo']
self.assertEqual(1, len(ra))
self.assertTrue('foo' in ra)
# Already exists, no new entry created
_ = ra['foo']
self.assertEqual(1, len(ra))
# Entry must be empty and mutable, and have the correct name
self.assertFalse(ra['foo'].is_view)
self.assertEqual(0, len(ra['foo']))
self.assertEqual('foo', ra['foo'].name)
# Key must be a string
with self.assertRaises(TypeError):
# noinspection PyTypeChecker
_ = ra[42]
def test_requestarguments_attr(self):
"""
Test attribute access syntactic sugar.
"""
ra = RequestArguments()
# Attribute should not exist yet
with self.assertRaises(KeyError):
_ = ra.foo
# Create entry
_ = ra['foo']
# Creating entry should have created the attribute
self.assertEqual('foo', ra.foo.name)
# Attribute access should yield an immutable view
self.assertTrue(ra.foo.is_view)
def test_requestarguments_iterate(self):
"""
Test iterating a RequestArguments instance.
"""
# Create an instance with some values
ra = RequestArguments()
ra['foo'].append('a', 'b')
ra['bar'].append('c', 'd')
ra['foo'].append('e', 'f')
# Container for test values (name, value)
items: Set[Tuple[str, str]] = set()
# Iterate RequestArguments instance, adding the name and value of each to the set
for a in ra:
items.add((a.name, str(a)))
# Compare result with expected value
self.assertEqual(2, len(items))
self.assertIn(('foo', 'b'), items)
self.assertIn(('bar', 'd'), items)
def test_requestarguments_full_use_case(self):
"""
Simulate a minimal RequestArguments use case.
"""
# Create empty RequestArguments instance
ra = RequestArguments()
# Parse GET request
getargs: Dict[str, List[str]] = urllib.parse.parse_qs('foo=42&bar=1337&foo=43&baz=Hello,%20World!')
# Insert GET arguments into RequestArguments
for k, vs in getargs.items():
for v in vs:
ra[k].append('text/plain', v)
# Parse POST request
postargs: Dict[str, List[str]] = urllib.parse.parse_qs('foo=postfoo&postbar=42&foo=postfoo')
# Insert POST arguments into RequestArguments
for k, vs in postargs.items():
# In this implementation, POST args replace GET args
ra[k].clear()
for v in vs:
ra[k].append('text/plain', v)
# Someplace else: Use the RequestArguments instance.
self.assertEqual('1337', ra.bar.get_str())
self.assertEqual('Hello, World!', ra.baz.get_str())
self.assertEqual('42', ra.postbar.get_str())
for a in ra.foo:
self.assertEqual('postfoo', a.get_str())

View file

@ -1,332 +0,0 @@
from typing import Any, Dict, Union
import os
import os.path
from datetime import datetime, timedelta
from matemat.exceptions import HttpException
from matemat.webserver import HttpHandler, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet
@test_pagelet('/just/testing/serve_pagelet_str')
def serve_test_pagelet_str(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
return 'serve test pagelet str'
@test_pagelet('/just/testing/serve_pagelet_bytes')
def serve_test_pagelet_bytes(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
headers['Content-Type'] = 'application/x-foo-bar'
return b'serve\x80test\xffpagelet\xfebytes'
@test_pagelet('/just/testing/serve_pagelet_redirect')
def serve_test_pagelet_redirect(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
return RedirectResponse('/foo/bar')
@test_pagelet('/just/testing/serve_pagelet_template')
def serve_test_pagelet_template(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
return TemplateResponse('test.txt', what='World')
# noinspection PyTypeChecker
@test_pagelet('/just/testing/serve_pagelet_fail')
def serve_test_pagelet_fail(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
session_vars['test'] = 'hello, world!'
raise HttpException(599, 'Error expected during unit testing')
# noinspection PyTypeChecker
@test_pagelet('/just/testing/serve_pagelet_empty')
def serve_test_pagelet_empty(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
return PageletResponse()
# noinspection PyTypeChecker
@test_pagelet('/just/testing/serve_pagelet_type_error')
def serve_test_pagelet_fail(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
return 42
class TestServe(AbstractHttpdTest):
"""
Test cases for the content serving of the web server.
"""
def setUp(self):
super().setUp()
# Create a static resource in the temp dir
with open(os.path.join(self.tempdir.name, 'static_resource.txt'), 'w') as f:
f.write('static resource test')
# Create a second static resource chmodded to 0000, to test 403 Forbidden error
forbidden: str = os.path.join(self.tempdir.name, 'forbidden_static_resource.txt')
with open(forbidden, 'w') as f:
f.write('This should not be readable')
os.chmod(forbidden, 0)
# Create a CSS resource whose MIME type should be detected by file extension
with open(os.path.join(self.tempdir.name, 'teststyle.css'), 'w') as f:
f.write('.ninja { display: none; }\n')
# Create a file without extension (containing UTF-16 text with BOM); libmagic should take over
with open(os.path.join(self.tempdir.name, 'testdata'), 'wb') as f:
f.write(b'\xFE\xFFH\x00e\x00l\x00l\x00o\x00,\x00 \x00w\x00o\x00r\x00l\x00d\x00!\x00\n\x00')
# Create a file that will yield "text/plain; charset=binary"
with open(os.path.join(self.tempdir.name, 'testbin.txt'), 'wb') as f:
f.write(b'\x00\x00\x00\x00\x00\x00\x00\x00')
def test_serve_pagelet_str(self):
# Call the test pagelet that produces a 200 OK result
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_str HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure the correct pagelet was called
self.assertEqual('serve_test_pagelet_str', packet.pagelet)
# Make sure the expected content is served
self.assertEqual(200, packet.statuscode)
self.assertEqual(b'serve test pagelet str', packet.body)
def test_serve_pagelet_bytes(self):
# Call the test pagelet that produces a 200 OK result
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_bytes HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure the correct pagelet was called
self.assertEqual('serve_test_pagelet_bytes', packet.pagelet)
# Make sure the expected content is served
self.assertEqual(200, packet.statuscode)
self.assertEqual(b'serve\x80test\xffpagelet\xfebytes', packet.body)
def test_serve_pagelet_fail(self):
# Call the test pagelet that produces a 500 Internal Server Error result
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_fail HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure an error is raised
self.assertEqual(599, packet.statuscode)
def test_serve_pagelet_redirect(self):
# Call the test pagelet that redirects to another path
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_redirect HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure the correct pagelet was called
self.assertEqual('serve_test_pagelet_redirect', packet.pagelet)
# Make sure the correct redirect is issued
self.assertEqual(301, packet.statuscode)
self.assertEqual('/foo/bar', packet.headers['Location'])
# Make sure the response body is empty
self.assertEqual(0, len(packet.body))
def test_serve_pagelet_template(self):
# Call the test pagelet that redirects to another path
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_template HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure the correct pagelet was called
self.assertEqual('serve_test_pagelet_template', packet.pagelet)
self.assertEqual(200, packet.statuscode)
# Make sure the response body was rendered correctly by the templating engine
self.assertEqual(b'Hello, World!', packet.body)
def test_serve_pagelet_empty(self):
# Call the test pagelet that redirects to another path
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_empty HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure the correct pagelet was called
self.assertEqual('serve_test_pagelet_empty', packet.pagelet)
self.assertEqual(200, packet.statuscode)
# Make sure the response body was rendered correctly by the templating engine
self.assertEqual(b'', packet.body)
def test_serve_static_ok(self):
# Request a static resource
self.client_sock.set_request(b'GET /static_resource.txt HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure that no pagelet was called
self.assertIsNone(packet.pagelet)
# Make sure the expected content is served
self.assertEqual(200, packet.statuscode)
self.assertEqual(b'static resource test', packet.body)
def test_serve_static_forbidden(self):
# Request a static resource with lacking permissions
self.client_sock.set_request(b'GET /forbidden_static_resource.txt HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure that no pagelet was called
self.assertIsNone(packet.pagelet)
# Make sure a 403 header is served
self.assertEqual(403, packet.statuscode)
self.assertNotEqual(b'This should not be readable', packet.body)
def test_serve_static_cache(self):
# Request a static resource
timeout: datetime = datetime.utcnow() + timedelta(hours=1)
timeoutstr = timeout.strftime('%a, %d %b %Y %H:%M:%S GMT')
self.client_sock.set_request(
f'GET /static_resource.txt HTTP/1.1\r\nIf-Modified-Since: {timeoutstr}\r\n\r\n'.encode('utf-8'))
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure that no pagelet was called
self.assertIsNone(packet.pagelet)
# Make sure a 304 Not Modified is sent and the body is empty
self.assertEqual(304, packet.statuscode)
self.assertEqual(0, len(packet.body))
def test_serve_static_cache_renew(self):
# Request a static resource
self.client_sock.set_request(
b'GET /static_resource.txt HTTP/1.1\r\nIf-Modified-Since: Mon, 01 Jan 2018 13:37:42 GMT\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure that no pagelet was called
self.assertIsNone(packet.pagelet)
# Make sure the expected content is served
self.assertEqual(200, packet.statuscode)
self.assertEqual(b'static resource test', packet.body)
def test_serve_not_found(self):
# Request a nonexistent resource
self.client_sock.set_request(b'GET /nonexistent HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure that no pagelet was called
self.assertIsNone(packet.pagelet)
# Make sure a 404 header is served
self.assertEqual(404, packet.statuscode)
def test_serve_directory_traversal(self):
# Request a resource outside the webroot
self.client_sock.set_request(b'GET /../../../../../etc/passwd HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure that no pagelet was called
self.assertIsNone(packet.pagelet)
# Make sure a 404 header is served
self.assertEqual(404, packet.statuscode)
def test_static_post_not_allowed(self):
# Request a resource outside the webroot
self.client_sock.set_request(b'POST /iwanttopostthis HTTP/1.1\r\n'
b'Content-Type: application/x-www-form-urlencoded\r\n'
b'Content-length: 37\r\n\r\n'
b'q=this%20should%20not%20be%20uploaded')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure that no pagelet was called
self.assertIsNone(packet.pagelet)
# Make sure a 405 Method Not Allowed header is served
self.assertEqual(405, packet.statuscode)
def test_serve_pagelet_libmagic(self):
# The correct Content-Type header must be guessed, if a pagelet does not provide one
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_str HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual('text/plain; charset=us-ascii', packet.headers['Content-Type'])
def test_serve_pagelet_libmagic_skipped(self):
# The Content-Type set by a pagelet should not be overwritten
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_bytes HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual('application/x-foo-bar', packet.headers['Content-Type'])
def test_serve_pagelet_type_error(self):
# A 500 error should be returned if a pagelet returns an invalid type
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_type_error HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure a 500 header is served
self.assertEqual(500, packet.statuscode)
def test_serve_static_mime_extension(self):
# The correct Content-Type should be guessed by file extension primarily
self.client_sock.set_request(b'GET /teststyle.css HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# libmagic would say text/plain instead
self.assertEqual('text/css; charset=us-ascii', packet.headers['Content-Type'])
def test_serve_static_mime_magic(self):
# The correct Content-Type should be guessed by file extension primarily
self.client_sock.set_request(b'GET /testdata HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Extension-based would fail, as there is no extension
self.assertEqual('text/plain; charset=utf-16be', packet.headers['Content-Type'])
def test_serve_static_mime_magic_binary(self):
# The correct Content-Type should be guessed by file extension primarily
self.client_sock.set_request(b'GET /testbin.txt HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# No charset should be in the header. Yes, this is a stupid example
self.assertEqual('text/plain', packet.headers['Content-Type'])
def test_serve_static_pagelet_header(self):
# Send a request
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_str HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# The statically set header must be present
self.assertEqual('helloworld!', packet.headers['X-Static-Testing-Header'])
def test_serve_static_static_header(self):
# Send a request
self.client_sock.set_request(b'GET /testbin.txt HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# The statically set header must be present
self.assertEqual('helloworld!', packet.headers['X-Static-Testing-Header'])

View file

@ -1,161 +0,0 @@
from typing import Any, Dict
from datetime import datetime, timedelta
from time import sleep
from matemat.webserver import HttpHandler, RequestArguments
from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet
@test_pagelet('/just/testing/sessions')
def session_test_pagelet(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]):
session_vars['test'] = 'hello, world!'
headers['Content-Type'] = 'text/plain'
return 'session test'
class TestSession(AbstractHttpdTest):
"""
Test session handling of the Matemat webserver.
"""
def test_create_new_session(self):
# Reference date to make sure the session expiry lies in the future
refdate: datetime = datetime.utcnow() + timedelta(seconds=3500)
# Send a mock GET request for '/just/testing/sessions'
self.client_sock.set_request(b'GET /just/testing/sessions HTTP/1.1\r\n\r\n')
# Trigger request handling
handler = HttpHandler(self.client_sock, ('::1', 45678), self.server)
# Fetch the parsed response
packet = self.client_sock.get_response()
# Make sure a full HTTP response was parsed
self.assertEqual('done', packet.parse_phase)
# Make sure the request was served by the test pagelet
self.assertEqual('session_test_pagelet', packet.pagelet)
self.assertEqual(200, packet.statuscode)
session_id: str = list(handler.server.session_vars.keys())[0]
# Make sure a cookie was set - assuming that only one was set
self.assertIn('Set-Cookie', packet.headers)
# Split into the cookie itself
cookie, expiry = packet.headers['Set-Cookie'].split(';')
cookie: str = cookie.strip()
expiry: str = expiry.strip()
# Make sure the 'matemat_session_id' cookie was set to the session ID string
self.assertEqual(f'matemat_session_id={session_id}', cookie)
# Make sure the session expires in about one hour
self.assertTrue(expiry.startswith('expires='))
_, expdatestr = expiry.split('=', 1)
expdate = datetime.strptime(expdatestr, '%a, %d %b %Y %H:%M:%S GMT')
self.assertTrue(expdate > refdate)
# Make sure the session exists on the server
self.assertIn('test', handler.session_vars)
self.assertEqual('hello, world!', handler.session_vars['test'])
def test_resume_session(self):
# Test session expiry date
refdate: datetime = datetime.utcnow() + timedelta(hours=1)
# Session ID for testing
session_id: str = 'testsessionid'
# Insert test session
self.server.session_vars[session_id] = refdate, {'test': 'bar'}
sleep(2)
# Send a mock GET request for '/just/testing/sessions' with a matemat session cookie
self.client_sock.set_request(
f'GET /just/testing/sessions HTTP/1.1\r\nCookie: matemat_session_id={session_id}\r\n\r\n'.encode('utf-8'))
# Trigger request handling
handler = HttpHandler(self.client_sock, ('::1', 45678), self.server)
# Fetch the parsed response
packet = self.client_sock.get_response()
# Make sure a full HTTP response was parsed
self.assertEqual('done', packet.parse_phase)
# Make sure the request was served by the test pagelet
self.assertEqual('session_test_pagelet', packet.pagelet)
self.assertEqual(200, packet.statuscode)
response_session_id: str = list(handler.server.session_vars.keys())[0]
# Make sure a cookie was set - assuming that only one was set
self.assertIn('Set-Cookie', packet.headers)
# Split into the cookie itself
cookie, expiry = packet.headers['Set-Cookie'].split(';')
cookie: str = cookie.strip()
expiry: str = expiry.strip()
# Make sure the 'matemat_session_id' cookie was set to the session ID string
self.assertEqual(f'matemat_session_id={response_session_id}', cookie)
# Make sure the session ID matches the one we sent along
self.assertEqual(session_id, response_session_id)
# Make sure the session timeout was postponed
self.assertTrue(expiry.startswith('expires='))
_, expdatestr = expiry.split('=', 1)
expdate = datetime.strptime(expdatestr, '%a, %d %b %Y %H:%M:%S GMT')
self.assertTrue(expdate > refdate)
# Make sure the session exists on the server
self.assertIn('test', handler.session_vars)
self.assertEqual('hello, world!', handler.session_vars['test'])
def test_unknown_session_id(self):
# Unknown session ID
session_id: str = 'theserverdoesnotknowthisid'
refdate: datetime = datetime.utcnow() + timedelta(seconds=3500)
# Send a mock GET request for '/just/testing/sessions' with a session cookie not known to the server
self.client_sock.set_request(
f'GET /just/testing/sessions HTTP/1.1\r\nCookie: matemat_session_id={session_id}\r\n\r\n'.encode('utf-8'))
# Trigger request handling
handler = HttpHandler(self.client_sock, ('::1', 45678), self.server)
# Fetch the parsed response
packet = self.client_sock.get_response()
# Make sure a full HTTP response was parsed
self.assertEqual('done', packet.parse_phase)
# Make sure the request was served by the test pagelet
self.assertEqual('session_test_pagelet', packet.pagelet)
self.assertEqual(200, packet.statuscode)
server_session_id: str = list(handler.server.session_vars.keys())[0]
self.assertNotEqual(session_id, server_session_id)
# Make sure a cookie was set - assuming that only one was set
self.assertIn('Set-Cookie', packet.headers)
# Split into the cookie itself
cookie, expiry = packet.headers['Set-Cookie'].split(';')
cookie: str = cookie.strip()
expiry: str = expiry.strip()
# Make sure the 'matemat_session_id' cookie was set to the session ID string
self.assertEqual(f'matemat_session_id={server_session_id}', cookie)
# Make sure the session expires in about one hour
self.assertTrue(expiry.startswith('expires='))
_, expdatestr = expiry.split('=', 1)
expdate = datetime.strptime(expdatestr, '%a, %d %b %Y %H:%M:%S GMT')
self.assertTrue(expdate > refdate)
# Make sure the session exists on the server
self.assertIn('test', handler.session_vars)
self.assertEqual('hello, world!', handler.session_vars['test'])
def test_session_expired(self):
# Test session expiry date
refdate: datetime = datetime.utcnow() - timedelta(hours=1)
# Session ID for testing
session_id: str = 'testsessionid'
# Insert test session
self.server.session_vars[session_id] = refdate, {'test': 'bar'}
# Send a mock GET request for '/just/testing/sessions' with a matemat session cookie
self.client_sock.set_request(
f'GET /just/testing/sessions HTTP/1.1\r\nCookie: matemat_session_id={session_id}\r\n\r\n'.encode('utf-8'))
# Trigger request handling
handler = HttpHandler(self.client_sock, ('::1', 45678), self.server)
# Fetch the parsed response
packet = self.client_sock.get_response()
# Make sure a full HTTP response was parsed
self.assertEqual('done', packet.parse_phase)
# Make sure the server redirects to /
self.assertEqual(302, packet.statuscode)
self.assertIn('Location', packet.headers)
self.assertEqual('/', packet.headers['Location'])
# Make sure the session was terminated
self.assertNotIn(session_id, self.server.session_vars)

View file

@ -1,138 +0,0 @@
from typing import Dict, List, Tuple, Optional
import urllib.parse
from matemat.webserver import RequestArguments, RequestArgument
def _parse_multipart(body: bytes, boundary: str) -> List[RequestArgument]:
"""
Given a HTTP body with form-data in multipart form, and the multipart-boundary, parse the multipart items and
return them as a dictionary.
:param body: The HTTP multipart/form-data body.
:param boundary: The multipart boundary.
:return: A dictionary of field names as key, and content types and field values as value.
"""
# Prepend a CRLF for the first boundary to match
body = b'\r\n' + body
# Generate item header boundary and terminating boundary from general boundary string
_boundary = f'\r\n--{boundary}\r\n'.encode('utf-8')
_end_boundary = f'\r\n--{boundary}--\r\n'.encode('utf-8')
# Split at the end boundary and make sure there comes nothing after it
allparts = body.split(_end_boundary, 1)
if len(allparts) != 2 or allparts[1] != b'':
raise ValueError('Last boundary missing or corrupted')
# Split remaining body into its parts, and verify at least 1 part is there
parts: List[bytes] = (allparts[0]).split(_boundary)
if len(parts) < 1 or parts[0] != b'':
raise ValueError('First boundary missing or corrupted')
# Remove the first, empty part
parts = parts[1:]
# Results go into this dict
args: Dict[str, RequestArgument] = dict()
# Parse each multipart part
for part in parts:
# Parse multipart headers
hdr: Dict[str, str] = dict()
while True:
head, part = part.split(b'\r\n', 1)
# Break on header/body delimiter
if head == b'':
break
# Add header to hdr dict
hk, hv = head.decode('utf-8').split(':')
hdr[hk.strip()] = hv.strip()
# No content type set - set broadest possible type
if 'Content-Type' not in hdr:
hdr['Content-Type'] = 'application/octet-stream'
# At least Content-Disposition must be present
if 'Content-Disposition' not in hdr:
raise ValueError('Missing Content-Type or Content-Disposition header')
# Extract Content-Disposition header value and its arguments
cd, *cdargs = hdr['Content-Disposition'].split(';')
# Content-Disposition MUST be form-data; everything else is rejected
if cd.strip() != 'form-data':
raise ValueError(f'Unknown Content-Disposition: {cd}')
# Extract the "name" header argument
has_name = False
for cdarg in cdargs:
k, v = cdarg.split('=', 1)
if k.strip() == 'name':
has_name = True
name: str = v.strip()
# Remove quotation marks around the name value
if name.startswith('"') and name.endswith('"'):
name = v[1:-1]
# Add the Content-Type and the content to the header, with the provided name
if name not in args:
args[name] = RequestArgument(name)
args[name].append(hdr['Content-Type'].strip(), part)
if not has_name:
# Content-Disposition header without name attribute
raise ValueError('mutlipart/form-data part without name attribute')
return list(args.values())
def parse_args(request: str, postbody: Optional[bytes] = None, enctype: str = 'text/plain') \
-> Tuple[str, RequestArguments]:
"""
Given a HTTP request path, and optionally a HTTP POST body in application/x-www-form-urlencoded or
multipart/form-data form, parse the arguments and return them as a dictionary.
If a key is used both in GET and in POST, the POST value takes precedence, and the GET value is discarded.
:param request: The request string to parse.
:param postbody: The POST body to parse, defaults to None.
:param enctype: Encoding of the POST body; supported values are application/x-www-form-urlencoded and
multipart/form-data.
:return: A tuple consisting of the base path and a dictionary with the parsed key/value pairs, and the value's
content type.
"""
# Parse the request "URL" (i.e. only the path)
tokens = urllib.parse.urlparse(request)
# Parse the GET arguments
if len(tokens.query) == 0:
getargs: Dict[str, List[str]] = dict()
else:
getargs = urllib.parse.parse_qs(tokens.query, strict_parsing=True, keep_blank_values=True, errors='strict')
args = RequestArguments()
for k, vs in getargs.items():
args[k].clear()
for v in vs:
args[k].append('text/plain', v)
if postbody is not None:
if enctype == 'application/x-www-form-urlencoded':
# Parse the POST body
pb: str = postbody.decode('utf-8')
if len(pb) == 0:
postargs: Dict[str, List[str]] = dict()
else:
postargs = urllib.parse.parse_qs(pb, strict_parsing=True, keep_blank_values=True, errors='strict')
# Write all POST values into the dict, overriding potential duplicates from GET
for k, vs in postargs.items():
args[k].clear()
for v in vs:
args[k].append('text/plain', v)
elif enctype.startswith('multipart/form-data'):
# Parse the multipart boundary from the Content-Type header
try:
boundary: str = enctype.split('boundary=')[1].strip()
except IndexError:
raise ValueError('Multipart boundary in header not set or corrupted')
# Parse the multipart body
mpargs = _parse_multipart(postbody, boundary)
for ra in mpargs:
args[ra.name].clear()
for a in ra:
args[ra.name].append(a.get_content_type(), bytes(a))
else:
raise ValueError(f'Unsupported Content-Type: {enctype}')
# Return the path and the parsed arguments
return tokens.path, args

View file

@ -12,6 +12,7 @@ licence=('MIT')
depends=( depends=(
'python' 'python'
'ptyhon-bottle'
'python-jinja' 'python-jinja'
'python-pillow' 'python-pillow'
'python-magic' 'python-magic'

View file

@ -4,7 +4,7 @@ Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
Section: web Section: web
Priority: optional Priority: optional
Architecture: all Architecture: all
Depends: python3 (>= 3.6), python3-jinja2, python3-magic, python3-pil Depends: python3 (>= 3.6), python3-bottle, python3-jinja2, python3-magic, python3-pil
Description: Soda machine stock-keeping webservice Description: Soda machine stock-keeping webservice
A web service for automated stock-keeping of a soda machine written in Python. A web service for automated stock-keeping of a soda machine written in Python.
It provides a touch-input-friendly user interface (as most input happens It provides a touch-input-friendly user interface (as most input happens

View file

@ -3,11 +3,15 @@ FROM python:3.7-alpine
ADD . / ADD . /
RUN mkdir -p /var/matemat/db /var/matemat/upload \ RUN mkdir -p /var/matemat/db /var/matemat/upload \
&& chown 1000:0 -R /var/matemat/db /var/matemat/upload \
&& chmod g=u -R /var/matemat/db /var/matemat/upload \
&& apk --update add libmagic zlib jpeg zlib-dev jpeg-dev build-base \ && apk --update add libmagic zlib jpeg zlib-dev jpeg-dev build-base \
&& pip3 install -e . \ && pip3 install -e . \
&& apk del zlib-dev jpeg-dev build-base \ && apk del zlib-dev jpeg-dev build-base \
&& rm -rf /var/cache/apk /root/.cache/pip \ && rm -rf /var/cache/apk /root/.cache/pip \
&& rm -rf /package && rm -rf /package
USER 1000
EXPOSE 80/tcp EXPOSE 80/tcp
CMD [ "/run.sh" ] CMD [ "/run.sh" ]

View file

@ -19,6 +19,7 @@ soda machine's touch screen).
packages=find_packages(exclude=['*.test']), packages=find_packages(exclude=['*.test']),
python_requires='>=3.6', python_requires='>=3.6',
install_requires=[ install_requires=[
'bottle',
'file-magic', 'file-magic',
'jinja2', 'jinja2',
'Pillow' 'Pillow'

View file

@ -1,7 +1,7 @@
.thumblist-item { .thumblist-item {
display: inline-block; display: inline-block;
margin: 10px; margin: 5px;
padding: 10px; padding: 5px;
background: #f0f0f0; background: #f0f0f0;
text-decoration: none; text-decoration: none;
} }
@ -11,14 +11,14 @@
} }
.thumblist-item .imgcontainer { .thumblist-item .imgcontainer {
width: 150px; width: 100px;
height: 150px; height: 100px;
position: relative; position: relative;
} }
.thumblist-item .imgcontainer img { .thumblist-item .imgcontainer img {
max-width: 150px; max-width: 100px;
max-height: 150px; max-height: 100px;
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
@ -30,6 +30,15 @@
font-weight: bolder; font-weight: bolder;
} }
.thumblist-stock {
position: absolute;
z-index: 10;
bottom: 0;
right: 0;
background: #f0f0f0;
padding: 10px;
}
@media print { @media print {
footer { footer {
position: fixed; position: fixed;

View file

@ -31,7 +31,7 @@
<h2>Avatar</h2> <h2>Avatar</h2>
<form id="admin-avatar-form" method="post" action="/admin?change=avatar" enctype="multipart/form-data" accept-charset="UTF-8"> <form id="admin-avatar-form" method="post" action="/admin?change=avatar" enctype="multipart/form-data" accept-charset="UTF-8">
<img src="/upload/thumbnails/users/{{ authuser.id }}.png" alt="Avatar of {{ authuser.name }}" /><br/> <img src="/static/upload/thumbnails/users/{{ authuser.id }}.png" alt="Avatar of {{ authuser.name }}" /><br/>
<label for="admin-avatar-avatar">Upload new file: </label> <label for="admin-avatar-avatar">Upload new file: </label>
<input id="admin-avatar-avatar" type="file" name="avatar" accept="image/*" /><br/> <input id="admin-avatar-avatar" type="file" name="avatar" accept="image/*" /><br/>
@ -70,7 +70,7 @@
<input type="submit" value="Save changes" /> <input type="submit" value="Save changes" />
</form> </form>
<script src="/js/touchkey.js" ></script> <script src="/static/js/touchkey.js" ></script>
<script> <script>
initTouchkey(true, 'touchkey-svg', null, 'admin-touchkey-touchkey'); initTouchkey(true, 'touchkey-svg', null, 'admin-touchkey-touchkey');
</script> </script>

View file

@ -94,12 +94,12 @@
<form id="admin-default-images-form" method="post" action="/admin?adminchange=defaultimg" enctype="multipart/form-data" accept-charset="UTF-8"> <form id="admin-default-images-form" method="post" action="/admin?adminchange=defaultimg" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="admin-default-images-user"> <label for="admin-default-images-user">
<img src="/upload/thumbnails/users/default.png" alt="Default user avatar" /> <img src="/static/upload/thumbnails/users/default.png" alt="Default user avatar" />
</label><br/> </label><br/>
<input id="admin-default-images-user" type="file" name="users" accept="image/*" /><br/> <input id="admin-default-images-user" type="file" name="users" accept="image/*" /><br/>
<label for="admin-default-images-product"> <label for="admin-default-images-product">
<img src="/upload/thumbnails/products/default.png" alt="Default product avatar" /> <img src="/static/upload/thumbnails/products/default.png" alt="Default product avatar" />
</label><br/> </label><br/>
<input id="admin-default-images-product" type="file" name="products" accept="image/*" /><br/> <input id="admin-default-images-product" type="file" name="products" accept="image/*" /><br/>

View file

@ -5,7 +5,7 @@
{% block head %} {% block head %}
{# Show the setup name, as set in the config file, as tab title. Don't escape HTML entities. #} {# Show the setup name, as set in the config file, as tab title. Don't escape HTML entities. #}
<title>{{ setupname|safe }}</title> <title>{{ setupname|safe }}</title>
<link rel="stylesheet" href="/css/matemat.css"/> <link rel="stylesheet" href="/static/css/matemat.css"/>
{% endblock %} {% endblock %}
</head> </head>
@ -43,7 +43,6 @@
<ul> <ul>
<li> {{ setupname|safe }} <li> {{ setupname|safe }}
<li> Matemat {{ __version__ }} <li> Matemat {{ __version__ }}
<li> &copy; 2018 s3lph
<li> MIT License <li> MIT License
{# This used to be a link to the GitLab repo. However, users of the testing environment always clicked {# This used to be a link to the GitLab repo. However, users of the testing environment always clicked
that link and couldn't come back, because the UI was running in touch-only kiosk mode. #} that link and couldn't come back, because the UI was running in touch-only kiosk mode. #}

View file

@ -24,7 +24,7 @@
<input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/> <input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>
<label for="modproduct-image"> <label for="modproduct-image">
<img height="150" src="/upload/thumbnails/products/{{ product.id }}.png" alt="Image of {{ product.name }}" /> <img height="150" src="/static/upload/thumbnails/products/{{ product.id }}.png" alt="Image of {{ product.name }}" />
</label><br/> </label><br/>
<input id="modproduct-image" type="file" name="image" accept="image/*" /><br/> <input id="modproduct-image" type="file" name="image" accept="image/*" /><br/>

View file

@ -42,7 +42,7 @@
<input id="moduser-account-balance-reason" type="text" name="reason" placeholder="Shows up on receipt" /><br/> <input id="moduser-account-balance-reason" type="text" name="reason" placeholder="Shows up on receipt" /><br/>
<label for="moduser-account-avatar"> <label for="moduser-account-avatar">
<img src="/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}" /> <img src="/static/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}" />
</label><br/> </label><br/>
<input id="moduser-account-avatar" type="file" name="avatar" accept="image/*" /><br/> <input id="moduser-account-avatar" type="file" name="avatar" accept="image/*" /><br/>

View file

@ -12,8 +12,13 @@
Your balance: {{ authuser.balance|chf }} Your balance: {{ authuser.balance|chf }}
<br/> <br/>
{# Links to deposit two common amounts of cash. TODO: Will be replaced by a nicer UI later (#20) #} {# Links to deposit two common amounts of cash. TODO: Will be replaced by a nicer UI later (#20) #}
<a href="/deposit?n=100">Deposit CHF 1</a><br/> <div class="thumblist-item">
<a href="/deposit?n=1000">Deposit CHF 10</a><br/> <a href="/deposit?n=100">Deposit CHF 1</a>
</div>
<div class="thumblist-item">
<a href="/deposit?n=1000">Deposit CHF 10</a>
</div>
<br/>
{% for product in products %} {% for product in products %}
{# Show an item per product, consisting of the name, image, price and stock, triggering a purchase on click #} {# Show an item per product, consisting of the name, image, price and stock, triggering a purchase on click #}
@ -26,9 +31,10 @@
{% else %} {% else %}
{{ product.price_non_member|chf }} {{ product.price_non_member|chf }}
{% endif %} {% endif %}
; Stock: {{ product.stock }}</span><br/> </span><br/>
<div class="imgcontainer"> <div class="imgcontainer">
<img src="/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}"/> <img src="/static/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}"/>
<span class="thumblist-stock">{{ product.stock }}</span>
</div> </div>
</a> </a>
</div> </div>

View file

@ -4,7 +4,7 @@
{{ super() }} {{ super() }}
<style> <style>
svg { svg {
width: 600px; width: 400px;
height: auto; height: auto;
} }
</style> </style>
@ -26,7 +26,7 @@
</form> </form>
<a href="/">Cancel</a> <a href="/">Cancel</a>
<script src="/js/touchkey.js"></script> <script src="/static/js/touchkey.js"></script>
<script> <script>
initTouchkey(false, 'touchkey-svg', 'loginform', 'loginform-touchkey-value'); initTouchkey(false, 'touchkey-svg', 'loginform', 'loginform-touchkey-value');
</script> </script>

View file

@ -14,7 +14,7 @@
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}"> <a href="/touchkey?uid={{ user.id }}&username={{ user.name }}">
<span class="thumblist-title">{{ user.name }}</span><br/> <span class="thumblist-title">{{ user.name }}</span><br/>
<div class="imgcontainer"> <div class="imgcontainer">
<img src="/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}"/> <img src="/static/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}"/>
</div> </div>
</a> </a>
</div> </div>

View file

@ -1,15 +1,13 @@
# There is no buster image yet and stretch doesn't have a docker package. So let's just "upgrade" the image to buster. FROM python:3.7-buster
FROM python:3.6-stretch
RUN sed -re 's/stretch/buster/g' -i /etc/apt/sources.list \ RUN useradd -d /home/matemat -m matemat \
&& useradd -d /home/matemat -m matemat \
&& mkdir -p /var/matemat/db /var/matemat/upload \ && mkdir -p /var/matemat/db /var/matemat/upload \
&& chown matemat:matemat -R /var/matemat/db \ && chown matemat:0 -R /var/matemat/db /var/matemat/upload \
&& chown matemat:matemat -R /var/matemat/upload \ && chmod g=u -R /var/matemat/db /var/matemat/upload \
&& apt-get update -qy \ && apt-get update -qy \
&& apt-get install -y --no-install-recommends file sudo openssh-client git docker.io build-essential lintian rsync \ && apt-get install -y --no-install-recommends file sudo openssh-client git docker.io build-essential lintian rsync \
&& python3.6 -m pip install coverage wheel pycodestyle mypy \ && python3.7 -m pip install coverage wheel pycodestyle mypy \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /home/matemat WORKDIR /home/matemat