Rewrite devel server to be more reliable, accept seed in URL

This commit is contained in:
Neale Pickett 2018-05-11 21:48:10 +00:00
parent 83b790a3b3
commit 2f06fd556c
3 changed files with 163 additions and 263 deletions

View File

@ -1,14 +1,14 @@
FROM neale/eris FROM neale/eris
RUN apk --no-cache add lua5.1 lua5.2 lua5.3
RUN ln -s lua5.2 /usr/bin/lua
# Install MOTH. This could be less obtuse. # Install MOTH. This could be less obtuse.
COPY www /moth/www/ COPY www /moth/www/
COPY bin /moth/bin/ COPY bin /moth/bin/
COPY src/moth-init /moth/init COPY src/moth-init /moth/init
RUN ln -s ../state/puzzles.json /moth/www/puzzles.json && \ RUN ln -s ../state/puzzles.json /moth/www/puzzles.json && \
ln -s ../state/points.json /moth/www/points.json && \ ln -s ../state/points.json /moth/www/points.json
apk --no-cache add lua5.2 lua5.3 && \
ln -s lua5.2 /usr/bin/lua
CMD ["/moth/init"] CMD ["/moth/init"]

View File

@ -1,14 +1,10 @@
FROM alpine FROM alpine
ARG http_proxy RUN apk --no-cache add python3 py3-pillow && \
ENV http_proxy=${http_proxy} pip3 install aiohttp
RUN apk --no-cache add python3 py3-pillow COPY . /moth/
COPY example-puzzles /puzzles/
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
WORKDIR /moth/ WORKDIR /moth/
ENTRYPOINT ["python3", "/moth/devel-server.py"] ENTRYPOINT ["python3", "/moth/tools/devel-server.py", "--bind", ":8080", "--puzzles", "/puzzles"]

View File

@ -1,268 +1,163 @@
#!/usr/bin/python3 #!/usr/bin/python3
# To pick up any changes to this file without restarting anything: import asyncio
# 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 glob import glob
import html import html
import http.server from aiohttp import web
import io import io
import mistune import mimetypes
import moth import moth
import logging
import os import os
import pathlib import pathlib
import random
import shutil import shutil
import socketserver import socketserver
import sys import sys
import traceback import traceback
try: sys.dont_write_bytecode = True # Don't write .pyc files
from http.server import HTTPStatus
except ImportError:
class HTTPStatus:
OK = 200
NOT_FOUND = 404
INTERNAL_SERVER_ERROR = 500
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. class Page:
seed = 1 def __init__(self, title, depth=0):
self.title = title
def page(title, body, baseurl, scripts=[]): if depth:
return """<!DOCTYPE html> self.base = "/".join([".."] * depth)
<html>
<head>
<title>{title}</title>
<link rel="stylesheet" href="{baseurl}/files/src/www/res/style.css">
{scripts}
</head>
<body>
<h1>{title}</h1>
<div id="preview" class="terminal">
{body}
</div>
</body>
</html>""".format(
title=title,
body=body,
baseurl=baseurl,
scripts="\n".join('<script src="{}"></script>'.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))
else: 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("<!DOCTYPE html>\n")
ret.write("<html>\n")
ret.write(" <head>\n")
ret.write(" <title>{}</title>\n".format(self.title))
ret.write(" <link rel=\"stylesheet\" href=\"{}/files/www/res/style.css\">\n".format(self.base))
for s in self.scripts:
ret.write(" {}\n".format(s))
ret.write(" </head>\n")
ret.write(" <body>\n")
ret.write(" <h1>{}</h1>\n".format(self.title))
ret.write(" <div id=\"preview\" class=\"terminal\">\n")
ret.write(self.body.getvalue())
ret.write(" </div>\n")
ret.write(" </body>\n")
ret.write("</html>\n")
return ret.getvalue()
def response(self, request):
return web.Response(text=self.text(), content_type="text/html")
def translate_path(self, path): async def handle_front(request):
if path.startswith('/files'): p = Page("Devel Server", 0)
path = path[7:] p.write("<p>Yo, it's the front page!</p>")
return super().translate_path(path) p.write("<ul>")
p.write("<li><a href=\"puzzles/\">Available puzzles</a></li>")
p.write("<li><a href=\"files/\">Raw filesystem view</a></li>")
p.write("<li><a href=\"https://github.com/dirtbags/moth/tree/master/docs\">Documentation</a></li>")
p.write("<li><a href=\"https://github.com/dirtbags/moth/blob/master/docs/devel-server.md\"Instructions</a> for using this server")
p.write("</ul>")
p.write("<p>If you use this development server to run a contest, you are a fool.</p>")
return p.response(request)
def serve_front(self): async def handle_puzzlelist(request):
body = """ p = Page("Puzzle Categories", 1)
MOTH Development Server Front Page p.write("<ul>")
==================== for i in sorted(glob.glob(os.path.join(request.app["puzzles_dir"], "*", ""))):
bn = os.path.basename(i.strip('/\\'))
p.write('<li><a href="{}/">puzzles/{}/</a></li>'.format(bn, bn))
p.write("</ul>")
return p.response(request)
Yo, it's the front page. async def handle_category(request):
There's stuff you can do here: 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("<ul>")
for points in cat.pointvals():
p.write('<li><a href="{points}/">puzzles/{category}/{points}/</a></li>'.format(category=category, points=points))
p.write("</ul>")
return p.response(request)
* [Available puzzles](puzzles/) async def handle_puzzle(request):
* [Raw filesystem view](files/) seed = request.query.get("seed", mkseed())
* [Documentation](files/docs/) category = request.match_info.get("category")
* [Instructions](files/docs/devel-server.md) for using this server 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, p = Page("{} puzzle {}".format(category, points), 3)
you are a fool. for s in puzzle.scripts:
""" p.add_script(s)
payload = self.mdpage(body).encode('utf-8') p.write("<h2>Body</h2>")
self.send_response(HTTPStatus.OK) p.write("<div id='body' style='border: solid 1px silver;'>")
self.send_header("Content-Type", "text/html; charset=utf-8") p.write(puzzle.html_body())
self.send_header("Content-Length", len(payload)) p.write("</div>")
self.end_headers() p.write("<h2>Files</h2>")
self.wfile.write(payload) p.write("<ul>")
for name,puzzlefile in sorted(puzzle.files.items()):
def serve_puzzles(self, path): if puzzlefile.visible:
body = io.StringIO() visibility = ''
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("<ul>")
for i in sorted(glob.glob(os.path.join(self.puzzles_dir, "*", ""))):
bn = os.path.basename(i.strip('/\\'))
body.write('<li><a href="{}/">puzzles/{}/</a></li>'.format(bn, bn))
body.write("</ul>")
elif not puzzle:
# List all point values in a category
title = "Puzzles in category `{}`".format(parts[2])
body.write("<ul>")
for points in cat.pointvals():
body.write('<li><a href="{points}/">puzzles/{cat}/{points}/</a></li>'.format(cat=parts[2], points=points))
body.write("</ul>")
elif len(parts) == 4:
# Serve up a puzzle
scripts = puzzle.scripts
title = "{} puzzle {}".format(parts[2], parts[3])
body.write("<h2>Body</h2>")
body.write("<div id='body' style='border: solid 1px silver;'>")
body.write(puzzle.html_body())
body.write("</div>")
body.write("<h2>Files</h2>")
body.write("<ul>")
for name,puzzlefile in sorted(puzzle.files.items()):
if puzzlefile.visible:
visibility = ''
else:
visibility = '(unlisted)'
body.write('<li><a href="{filename}">{filename}</a> {visibility}</li>'
.format(cat=parts[2],
points=puzzle.points,
filename=name,
visibility=visibility))
body.write("</ul>")
body.write("<h2>Answers</h2>")
body.write("<p>Input box (for scripts): <input id='answer' name='a'>")
body.write("<ul>")
assert puzzle.answers, 'No answers defined'
for a in puzzle.answers:
body.write("<li><code>{}</code></li>".format(html.escape(a)))
body.write("</ul>")
body.write("<h2>Authors</h2><p>{}</p>".format(', '.join(puzzle.get_authors())))
body.write("<h2>Summary</h2><p>{}</p>".format(puzzle.summary))
if puzzle.logs:
body.write("<h2>Debug Log</h2>")
body.write('<ul class="log">')
for l in puzzle.logs:
body.write("<li>{}</li>".format(html.escape(l)))
body.write("</ul>")
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)
else: else:
ctype = self.guess_type(path) visibility = '(unlisted)'
try: p.write('<li><a href="{filename}">{filename}</a> {visibility}</li>'
payload = fspath.read_bytes() .format(cat=category,
except OSError: points=puzzle.points,
self.send_error(HTTPStatus.NOT_FOUND, "File not found") filename=name,
return visibility=visibility))
if path.endswith(".md"): p.write("</ul>")
ctype = "text/html; charset=utf-8" p.write("<h2>Answers</h2>")
content = self.mdpage(payload.decode('utf-8')) p.write("<p>Input box (for scripts): <input id='answer' name='a'>")
payload = content.encode('utf-8') p.write("<ul>")
try: assert puzzle.answers, 'No answers defined'
fs = fspath.stat() for a in puzzle.answers:
lastmod = self.date_time_string(fs.st_mtime) p.write("<li><code>{}</code></li>".format(html.escape(a)))
except: p.write("</ul>")
pass p.write("<h2>Authors</h2><p>{}</p>".format(', '.join(puzzle.get_authors())))
p.write("<h2>Summary</h2><p>{}</p>".format(puzzle.summary))
if puzzle.logs:
p.write("<h2>Debug Log</h2>")
p.write('<ul class="log">')
for l in puzzle.logs:
p.write("<li>{}</li>".format(html.escape(l)))
p.write("</ul>")
return p.response(request)
self.send_response(HTTPStatus.OK) async def handle_puzzlefile(request):
self.send_header("Content-Type", ctype) seed = request.query.get("seed", mkseed())
self.send_header("Content-Length", len(payload)) category = request.match_info.get("category")
if lastmod: points = int(request.match_info.get("points"))
self.send_header("Last-Modified", lastmod) filename = request.match_info.get("filename")
self.end_headers() cat = moth.Category(os.path.join(request.app["puzzles_dir"], category), seed)
self.wfile.write(payload) 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__': if __name__ == '__main__':
import argparse import argparse
@ -273,11 +168,7 @@ if __name__ == '__main__':
help="Directory containing your puzzles" help="Directory containing your puzzles"
) )
parser.add_argument( parser.add_argument(
'--once', default=False, action='store_true', '--bind', default="127.0.0.1:8080",
help="Serve one page, then exit. For debugging the server."
)
parser.add_argument(
'--bind', default="0.0.0.0:8080",
help="Bind to ip:port" help="Bind to ip:port"
) )
parser.add_argument( parser.add_argument(
@ -285,8 +176,21 @@ if __name__ == '__main__':
help="Base URL to this server, for reverse proxy setup" help="Base URL to this server, for reverse proxy setup"
) )
args = parser.parse_args() args = parser.parse_args()
addr, port = args.bind.split(":") parts = args.bind.split(":")
port = int(port) addr = parts[0] or "0.0.0.0"
MothHandler.puzzles_dir = args.puzzles port = int(parts[1])
MothHandler.base_url = args.base
run(address=(addr, port), once=args.once) 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)