diff options
-rw-r--r-- | .gitattributes | 2 | ||||
-rw-r--r-- | .gitignore | 3 | ||||
-rwxr-xr-x | app.py | 152 | ||||
-rwxr-xr-x | server.py | 127 | ||||
-rwxr-xr-x | test/test.sh | 193 |
5 files changed, 477 insertions, 0 deletions
diff --git a/.gitattributes b/.gitattributes index 176a458..d76765e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ * text=auto + +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f88119e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ + +*.state @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2022 Egor Tensin <Egor.Tensin@gmail.com> +# This file is part of the "void" project. +# For details, see https://github.com/egor-tensin/void. +# Distributed under the MIT License. + +import argparse +import cgi +from enum import Enum +import http.server +import os +import sys +from threading import Lock + + +class Response: + DEFAULT_STATUS = http.server.HTTPStatus.OK + + def __init__(self, body, status=None): + if status is None: + status = Response.DEFAULT_STATUS + self.status = status + self.body = body + + def headers(self): + yield 'Content-Type', 'text/html; charset=utf-8' + + def encode_body(self): + return self.body.encode(errors='replace') + + def write_as_cgi_script(self): + self.write_headers_as_cgi_script() + self.write_body_as_cgi_script() + + def write_headers_as_cgi_script(self): + for name, val in self.headers(): + print(f'{name}: {val}') + print() + + def write_body_as_cgi_script(self): + if self.body is not None: + print(self.body) + + def write_to_request_handler(self, handler): + handler.send_response(self.status) + self.write_headers_to_request_handler(handler) + self.write_body_to_request_handler(handler) + + def write_headers_to_request_handler(self, handler): + for name, val in self.headers(): + handler.send_header(name, val) + handler.end_headers() + + def write_body_to_request_handler(self, handler): + if self.body is not None: + handler.wfile.write(self.encode_body()) + + +class Void: + def __init__(self, backup_path): + self.lck = Lock() + self.cnt = 0 + self.backup_path = backup_path + if backup_path is not None: + self.backup_path = os.path.abspath(backup_path) + + def __enter__(self): + if self.backup_path is not None and os.path.exists(self.backup_path): + self.restore(self.backup_path) + return self + + def __exit__(self, type, value, traceback): + if self.backup_path is not None: + os.makedirs(os.path.dirname(self.backup_path), exist_ok=True) + self.save(self.backup_path) + + def write(self, fd): + with self.lck: + cnt = self.cnt + fd.write(str(cnt)) + + def read(self, fd): + src = fd.read() + cnt = int(src) + with self.lck: + self.cnt = cnt + + def save(self, path): + with open(path, 'w') as fd: + self.write(fd) + + def restore(self, path): + with open(path) as fd: + self.read(fd) + + def scream_once(self): + with self.lck: + self.cnt += 1 + cnt = self.cnt + return Response(str(cnt)) + + def get_numof_screams(self): + with self.lck: + cnt = self.cnt + return Response(str(cnt)) + + +class Request(Enum): + SCREAM_ONCE = 'scream' + HOW_MANY_SCREAMS = 'screams' + + def __str__(self): + return self.value + + @staticmethod + def from_http_path(path): + if not path or path[0] != '/': + raise ValueError('HTTP path must start with a forward slash /') + return Request(path[1:]) + + def process(self, void): + if self is Request.SCREAM_ONCE: + return void.scream_once() + if self is Request.HOW_MANY_SCREAMS: + return void.get_numof_screams() + raise NotImplementedError(f'unknown request: {self}') + + +def process_cgi_request(void): + params = cgi.FieldStorage() + what = params['what'].value + Request(what).process(void).write_as_cgi_script() + + +def parse_args(args=None): + if args is None: + args = sys.argv[1:] + parser = argparse.ArgumentParser() + parser.add_argument('-v', '--void', metavar='PATH', dest='backup', + help='set path to the void') + return parser.parse_args(args) + + +def main(args=None): + args = parse_args(args) + with Void(args.backup) as void: + process_cgi_request(void) + + +if __name__ == '__main__': + main() diff --git a/server.py b/server.py new file mode 100755 index 0000000..9fa7530 --- /dev/null +++ b/server.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2022 Egor Tensin <Egor.Tensin@gmail.com> +# This file is part of the "void" project. +# For details, see https://github.com/egor-tensin/void. +# Distributed under the MIT License. + +import argparse +from contextlib import contextmanager +import http.server +import os +import signal +import sys +from threading import Condition, Lock, Thread +import traceback + +from app import Request, Response, Void + + +DEFAULT_PORT = 23666 +EXITING = False + + +def script_dir(): + return os.path.dirname(os.path.realpath(__file__)) + + +def set_exiting(): + global EXITING + with SignalHandler.LOCK: + EXITING = True + SignalHandler.CV.notify() + + +class SignalHandler: + LOCK = Lock() + CV = Condition(LOCK) + + def __init__(self, httpd): + self.httpd = httpd + self.thread = Thread(target=self.run) + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, type, value, traceback): + self.thread.join() + + def run(self): + with SignalHandler.CV: + while not EXITING: + SignalHandler.CV.wait() + self.httpd.shutdown() + + +def handle_sigterm(signum, frame): + print('\nSIGTERM received, exiting...') + set_exiting() + + +@contextmanager +def setup_signal_handlers(httpd): + handler_thread = SignalHandler(httpd) + with handler_thread: + signal.signal(signal.SIGTERM, handle_sigterm) + yield + # TODO: cleanup signal handlers? + + +class RequestHandler(http.server.SimpleHTTPRequestHandler): + VOID = None + + def do_GET(self): + try: + request = Request.from_http_path(self.path) + except ValueError: + return super().do_GET() + try: + response = request.process(RequestHandler.VOID) + response.write_to_request_handler(self) + except: + status = http.server.HTTPStatus.INTERNAL_SERVER_ERROR + response = Response(traceback.format_exc(), status) + response.write_to_request_handler(self) + return + + +def make_server(port): + addr = ('', port) + server = http.server.HTTPServer + if sys.version_info >= (3, 7): + server = http.server.ThreadingHTTPServer + return server(addr, RequestHandler) + + +def parse_args(args=None): + if args is None: + args = sys.argv[1:] + parser = argparse.ArgumentParser() + parser.add_argument('-p', '--port', metavar='PORT', + type=int, default=DEFAULT_PORT, + help='set port number') + parser.add_argument('-v', '--void', metavar='PATH', dest='backup', + help='set path to the void') + return parser.parse_args(args) + + +def main(args=None): + # It's a failsafe; this script is only allowed to serve the directory it + # resides in. + os.chdir(script_dir()) + + args = parse_args(args) + with Void(args.backup) as void: + RequestHandler.VOID = void + httpd = make_server(args.port) + with setup_signal_handlers(httpd): + try: + httpd.serve_forever() + except KeyboardInterrupt: + print('\nKeyboard interrupt received, exiting...') + set_exiting() + + +if __name__ == '__main__': + main() diff --git a/test/test.sh b/test/test.sh new file mode 100755 index 0000000..f01ce68 --- /dev/null +++ b/test/test.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash + +# Copyright (c) 2022 Egor Tensin <Egor.Tensin@gmail.com> +# This file is part of the "void" project. +# For details, see https://github.com/egor-tensin/void. +# Distributed under the MIT License. + +set -o errexit -o nounset -o pipefail + +script_dir="$( dirname -- "${BASH_SOURCE[0]}" )" +script_dir="$( cd -- "$script_dir" && pwd )" +readonly script_dir +script_name="$( basename -- "${BASH_SOURCE[0]}" )" +readonly script_name + +readonly server_port=23666 +server_pid= +curl_header_file= +curl_output_file= + +dump() { + local msg + for msg; do + echo "$script_name: $msg" + done +} + +run_server() { + dump "Starting up server..." + "$script_dir/../server.py" --port "$server_port" & + server_pid="$!" + dump "Its PID is $server_pid" + sleep 5 +} + +kill_server() { + [ -z "$server_pid" ] && return + dump "Killing server with PID $server_pid..." + kill "$server_pid" + dump "Waiting for it to terminate..." + wait "$server_pid" || true + dump "Done" +} + +create_files() { + curl_header_file="$( mktemp )" + curl_output_file="$( mktemp )" + dump "curl header file: $curl_header_file" + dump "curl output file: $curl_output_file" +} + +cleanup_files() { + dump "Cleaning up curl files..." + [ -n "$curl_header_file" ] && rm -f -- "$curl_header_file" + [ -n "$curl_output_file" ] && rm -f -- "$curl_output_file" +} + +prepare() { + run_server + create_files +} + +cleanup() { + kill_server || true + cleanup_files || true +} + +run_curl() { + if [ "$#" -ne 1 ]; then + echo "usage: ${FUNCNAME[0]} URL" >&2 + return 1 + fi + local url="$1" + curl \ + --silent --show-error \ + --dump-header "$curl_header_file" \ + --output "$curl_output_file" \ + --connect-timeout 3 \ + -- "http://localhost:$server_port$url" || true +} + +curl_check_status() { + if [ "$#" -ne 1 ]; then + echo "usage: ${FUNCNAME[0]} HTTP_STATUS" >&2 + return 1 + fi + + local expected="$1" + expected="HTTP/1.0 $expected"$'\r' + local actual + actual="$( head -n 1 -- "$curl_header_file" )" + + [ "$expected" == "$actual" ] && return 0 + + dump "Actual HTTP response: $actual" >&2 + dump "Expected: $expected" >&2 + + dump 'HTTP headers:' >&2 + cat -- "$curl_header_file" >&2 + dump 'HTTP response:' >&2 + cat -- "$curl_output_file" >&2 + return 1 +} + +curl_check_keyword() { + local keyword + for keyword; do + if ! grep --fixed-strings --quiet -- "$keyword" "$curl_output_file"; then + dump "The following pattern hasn't been found:" + dump "$keyword" + fi + done +} + +run_curl_test() { + if [ "$#" -lt 1 ]; then + echo "usage: ${FUNCNAME[0]} URL [KEYWORD...]" >&2 + return 1 + fi + + local url="$1" + shift + dump "Running test for URL: $url" + + run_curl "$url" + curl_check_status '200 OK' + + local keyword + for keyword; do + curl_check_keyword "$keyword" + done +} + +run_curl_tests() { + # / and /index.html are identical: + #run_curl_test '/' '<link rel="stylesheet" href="css/bootstrap.min.css">' 'var status_refresh_interval_seconds' + #run_curl_test '/index.html' '<link rel="stylesheet" href="css/bootstrap.min.css">' 'var status_refresh_interval_seconds' + + run_curl_test '/screams' '0' + run_curl_test '/scream' '1' +} + +cgi_check_header() { + local expected='Content-Type: text/html; charset=utf-8' + local actual + actual="$( head -n 1 -- "$curl_output_file" )" + + [ "$expected" == "$actual" ] && return 0 + + dump "Actual CGI header: $actual" >&2 + dump "Expected: $expected" >&2 + + diff <( echo "$actual" ) <( echo "$expected" ) | cat -te + return 1 +} + +run_cgi_test() { + if [ "$#" -lt 1 ]; then + echo "usage: ${FUNCNAME[0]} WHAT [KEYWORD...]" >&2 + return 1 + fi + + local what="$1" + shift + + local query_string="what=$what" + dump "Running CGI test for query string: $query_string" + + QUERY_STRING="$query_string" "$script_dir/../app.py" > "$curl_output_file" + + cgi_check_header + + local keyword + for keyword; do + curl_check_keyword "$keyword" + done +} + +run_cgi_tests() { + # Check that app.py still works as a CGI script. + + run_cgi_test 'screams' '0' + run_cgi_test 'scream' '1' +} + +main() { + trap cleanup EXIT + prepare + run_curl_tests + run_cgi_tests +} + +main "$@" |