diff --git a/Dockerfile.moth-compile b/Dockerfile.moth-compile index e00c96b..5680e4f 100644 --- a/Dockerfile.moth-compile +++ b/Dockerfile.moth-compile @@ -1,8 +1,5 @@ FROM alpine -ARG http_proxy -ENV http_proxy=${http_proxy} - RUN apk --no-cache add python3 py3-pillow COPY tools/package-puzzles.py tools/moth.py tools/mistune.py tools/answer_words.txt /moth/ diff --git a/Dockerfile.moth-devel b/Dockerfile.moth-devel index 4e94dfa..8058a5b 100644 --- a/Dockerfile.moth-devel +++ b/Dockerfile.moth-devel @@ -1,14 +1,10 @@ FROM alpine -ARG http_proxy -ENV http_proxy=${http_proxy} +RUN apk --no-cache add python3 py3-pillow && \ + pip3 install aiohttp -RUN apk --no-cache add python3 py3-pillow - -COPY tools/devel-server.py tools/moth.py tools/mistune.py tools/answer_words.txt /moth/ -COPY www /moth/src/www -COPY example-puzzles /moth/puzzles -COPY docs /moth/docs +COPY . /moth/ +COPY example-puzzles /puzzles/ WORKDIR /moth/ -ENTRYPOINT ["python3", "/moth/devel-server.py"] +ENTRYPOINT ["python3", "/moth/tools/devel-server.py", "--bind", ":8080", "--puzzles", "/puzzles"] diff --git a/tools/devel-server.py b/tools/devel-server.py index 48d2f82..354f899 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -1,268 +1,163 @@ #!/usr/bin/python3 -# To pick up any changes to this file without restarting anything: -# while true; do ./tools/devel-server.py --once; done -# It's kludgy, but it gets the job done. -# Feel free to make it suck less, for example using the `tcpserver` program. - +import asyncio import glob import html -import http.server +from aiohttp import web import io -import mistune +import mimetypes import moth +import logging import os import pathlib +import random import shutil import socketserver import sys import traceback -try: - from http.server import HTTPStatus -except ImportError: - class HTTPStatus: - OK = 200 - NOT_FOUND = 404 - INTERNAL_SERVER_ERROR = 500 +sys.dont_write_bytecode = True # Don't write .pyc files -sys.dont_write_bytecode = True +def mkseed(): + return bytes(random.choice(b'abcdef0123456789') for i in range(40)) -# XXX: This will eventually cause a problem. Do something more clever here. -seed = 1 - -def page(title, body, baseurl, scripts=[]): - return """ - - - {title} - - {scripts} - - -

{title}

-
- {body} -
- -""".format( - title=title, - body=body, - baseurl=baseurl, - scripts="\n".join(''.format(s) for s in scripts), - ) - - - - -# XXX: What horrors did we unleash with our chdir shenanigans that -# makes this serve 404 and 500 when we mix in ThreadingMixIn? -class ThreadingServer(socketserver.ForkingMixIn, http.server.HTTPServer): - pass - - -class MothHandler(http.server.SimpleHTTPRequestHandler): - puzzles_dir = "puzzles" - base_url = "" - - def mdpage(self, body, scripts=[]): - try: - title, _ = body.split('\n', 1) - except ValueError: - title = "Result" - title = title.lstrip("#") - title = title.strip() - return page(title, mistune.markdown(body, escape=False), self.base_url, scripts=scripts) - - - def handle_one_request(self): - try: - super().handle_one_request() - except: - tbtype, value, tb = sys.exc_info() - tblist = traceback.format_tb(tb, None) + traceback.format_exception_only(tbtype, value) - payload = ("Traceback (most recent call last)\n" + - "".join(tblist[:-1]) + - tblist[-1]).encode('utf-8') - self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", payload) - self.end_headers() - self.wfile.write(payload) - - def do_GET(self): - if self.path == "/": - self.serve_front() - elif self.path.startswith("/puzzles/"): - self.serve_puzzles(self.path) - elif self.path.startswith("/files/"): - self.serve_file(self.translate_path(self.path)) +class Page: + def __init__(self, title, depth=0): + self.title = title + if depth: + self.base = "/".join([".."] * depth) else: - self.send_error(HTTPStatus.NOT_FOUND, "File not found") + self.base = "." + self.body = io.StringIO() + self.scripts = [] + + def add_script(self, path): + self.scripts.append(path) + + def write(self, s): + self.body.write(s) + + def text(self): + ret = io.StringIO() + ret.write("\n") + ret.write("\n") + ret.write(" \n") + ret.write(" {}\n".format(self.title)) + ret.write(" \n".format(self.base)) + for s in self.scripts: + ret.write(" {}\n".format(s)) + ret.write(" \n") + ret.write(" \n") + ret.write("

{}

\n".format(self.title)) + ret.write("
\n") + ret.write(self.body.getvalue()) + ret.write("
\n") + ret.write(" \n") + ret.write("\n") + return ret.getvalue() + + def response(self, request): + return web.Response(text=self.text(), content_type="text/html") - def translate_path(self, path): - if path.startswith('/files'): - path = path[7:] - return super().translate_path(path) +async def handle_front(request): + p = Page("Devel Server", 0) + p.write("

Yo, it's the front page!

") + p.write("") + p.write("

If you use this development server to run a contest, you are a fool.

") + return p.response(request) - def serve_front(self): - body = """ -MOTH Development Server Front Page -==================== +async def handle_puzzlelist(request): + p = Page("Puzzle Categories", 1) + p.write("
") + return p.response(request) -Yo, it's the front page. -There's stuff you can do here: +async def handle_category(request): + seed = request.query.get("seed", mkseed()) + category = request.match_info.get("category") + cat = moth.Category(os.path.join(request.app["puzzles_dir"], category), seed) + p = Page("Puzzles in category {}".format(category), 2) + p.write("") + return p.response(request) -* [Available puzzles](puzzles/) -* [Raw filesystem view](files/) -* [Documentation](files/docs/) -* [Instructions](files/docs/devel-server.md) for using this server +async def handle_puzzle(request): + seed = request.query.get("seed", mkseed()) + category = request.match_info.get("category") + points = int(request.match_info.get("points")) + cat = moth.Category(os.path.join(request.app["puzzles_dir"], category), seed) + puzzle = cat.puzzle(points) -If you use this development server to run a contest, -you are a fool. -""" - payload = self.mdpage(body).encode('utf-8') - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", len(payload)) - self.end_headers() - self.wfile.write(payload) - - def serve_puzzles(self, path): - body = io.StringIO() - path = path.rstrip('/') - parts = path.split("/") - scripts = [] - title = None - fpath = None - points = None - cat = None - puzzle = None - - try: - fpath = os.path.join(self.puzzles_dir, parts[2]) - points = int(parts[3]) - except: - pass - - if fpath: - cat = moth.Category(fpath, seed) - if points: - puzzle = cat.puzzle(points) - - if not cat: - title = "Puzzle Categories" - body.write("") - elif not puzzle: - # List all point values in a category - title = "Puzzles in category `{}`".format(parts[2]) - body.write("") - elif len(parts) == 4: - # Serve up a puzzle - scripts = puzzle.scripts - title = "{} puzzle {}".format(parts[2], parts[3]) - body.write("

Body

") - body.write("
") - body.write(puzzle.html_body()) - body.write("
") - body.write("

Files

") - body.write("") - body.write("

Answers

") - body.write("

Input box (for scripts): ") - body.write("

") - body.write("

Authors

{}

".format(', '.join(puzzle.get_authors()))) - body.write("

Summary

{}

".format(puzzle.summary)) - if puzzle.logs: - body.write("

Debug Log

") - body.write('") - elif len(parts) == 5: - # Serve up a puzzle file - try: - pfile = puzzle.files[parts[4]] - except KeyError: - self.send_error(HTTPStatus.NOT_FOUND, "File not found. Did you add it to the Files: header or puzzle.add_stream?") - return - ctype = self.guess_type(pfile.name) - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", ctype) - self.end_headers() - shutil.copyfileobj(pfile.stream, self.wfile) - return - - payload = page(title, body.getvalue(), self.base_url, scripts=scripts).encode('utf-8') - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", len(payload)) - self.end_headers() - self.wfile.write(payload) - - def serve_file(self, path): - lastmod = None - fspath = pathlib.Path(path) - - if fspath.is_dir(): - ctype = "text/html; charset=utf-8" - payload = self.list_directory(path) - # it sends headers but not body - shutil.copyfileobj(payload, self.wfile) + p = Page("{} puzzle {}".format(category, points), 3) + for s in puzzle.scripts: + p.add_script(s) + p.write("

Body

") + p.write("
") + p.write(puzzle.html_body()) + p.write("
") + p.write("

Files

") + p.write("") + p.write("

Answers

") + p.write("

Input box (for scripts): ") + p.write("

") + p.write("

Authors

{}

".format(', '.join(puzzle.get_authors()))) + p.write("

Summary

{}

".format(puzzle.summary)) + if puzzle.logs: + p.write("

Debug Log

") + p.write('") + + return p.response(request) - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", len(payload)) - if lastmod: - self.send_header("Last-Modified", lastmod) - self.end_headers() - self.wfile.write(payload) +async def handle_puzzlefile(request): + seed = request.query.get("seed", mkseed()) + category = request.match_info.get("category") + points = int(request.match_info.get("points")) + filename = request.match_info.get("filename") + cat = moth.Category(os.path.join(request.app["puzzles_dir"], category), seed) + puzzle = cat.puzzle(points) + try: + file = puzzle.files[filename] + except KeyError: + return web.Response(status=404) + + resp = web.Response() + resp.content_type, _ = mimetypes.guess_type(file.name) + # This is the line where I decided Go was better than Python at multiprocessing + # You should be able to chain the puzzle file's output to the async output, + # without having to block. But if there's a way to do that, it certainly + # isn't documented anywhere. + resp.body = file.stream.read() + return resp -def run(address=('127.0.0.1', 8080), once=False): - httpd = ThreadingServer(address, MothHandler) - print("=== Listening on http://{}:{}/".format(address[0], address[1])) - if once: - httpd.handle_request() - else: - httpd.serve_forever() if __name__ == '__main__': import argparse @@ -272,10 +167,6 @@ if __name__ == '__main__': '--puzzles', default='puzzles', help="Directory containing your puzzles" ) - parser.add_argument( - '--once', default=False, action='store_true', - help="Serve one page, then exit. For debugging the server." - ) parser.add_argument( '--bind', default="127.0.0.1:8080", help="Bind to ip:port" @@ -285,8 +176,21 @@ if __name__ == '__main__': help="Base URL to this server, for reverse proxy setup" ) args = parser.parse_args() - addr, port = args.bind.split(":") - port = int(port) - MothHandler.puzzles_dir = args.puzzles - MothHandler.base_url = args.base - run(address=(addr, port), once=args.once) + parts = args.bind.split(":") + addr = parts[0] or "0.0.0.0" + port = int(parts[1]) + + logging.basicConfig(level=logging.INFO) + + mydir = os.path.dirname(os.path.dirname(os.path.realpath(sys.argv[0]))) + + app = web.Application() + app["puzzles_dir"] = args.puzzles + app["base_url"] = args.base + app.router.add_route("GET", "/", handle_front) + app.router.add_route("GET", "/puzzles/", handle_puzzlelist) + app.router.add_route("GET", "/puzzles/{category}/", handle_category) + app.router.add_route("GET", "/puzzles/{category}/{points}/", handle_puzzle) + app.router.add_route("GET", "/puzzles/{category}/{points}/{filename}", handle_puzzlefile) + app.router.add_static("/files/", mydir, show_index=True) + web.run_app(app, host=addr, port=port)