aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--.gitattributes2
-rw-r--r--.gitignore3
-rwxr-xr-xapp.py152
-rwxr-xr-xserver.py127
-rwxr-xr-xtest/test.sh193
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
diff --git a/app.py b/app.py
new file mode 100755
index 0000000..82a1616
--- /dev/null
+++ b/app.py
@@ -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 "$@"