mirror of https://github.com/dirtbags/moth.git
Merge pull request #16 from dirtbags/neale
Thanks for your feedback, everybody
This commit is contained in:
commit
2c365f0a62
10
README.md
10
README.md
|
@ -7,13 +7,14 @@ which in the past has been called
|
||||||
"Project 2",
|
"Project 2",
|
||||||
"HACK",
|
"HACK",
|
||||||
"Queen Of The Hill",
|
"Queen Of The Hill",
|
||||||
and "Cyber FIRE".
|
"Cyber Spark",
|
||||||
|
and "Cyber Fire".
|
||||||
|
|
||||||
Information about these events is at
|
Information about these events is at
|
||||||
http://dirtbags.net/contest/
|
http://dirtbags.net/contest/
|
||||||
|
|
||||||
This software serves up puzzles in a manner similar to Jeopardy.
|
This software serves up puzzles in a manner similar to Jeopardy.
|
||||||
It also track scores,
|
It also tracks scores,
|
||||||
and comes with a JavaScript-based scoreboard to display team rankings.
|
and comes with a JavaScript-based scoreboard to display team rankings.
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,7 +22,7 @@ How everything works
|
||||||
---------------------------
|
---------------------------
|
||||||
|
|
||||||
This section wound up being pretty long.
|
This section wound up being pretty long.
|
||||||
Please check out [the overview](doc/overview.md)
|
Please check out [the overview](docs/overview.md)
|
||||||
for details.
|
for details.
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,6 +35,9 @@ Getting Started Developing
|
||||||
Then point a web browser at http://localhost:8080/
|
Then point a web browser at http://localhost:8080/
|
||||||
and start hacking on things in your `puzzles` directory.
|
and start hacking on things in your `puzzles` directory.
|
||||||
|
|
||||||
|
More on how the devel sever works in
|
||||||
|
[the devel server documentation](docs/devel-server.md)
|
||||||
|
|
||||||
|
|
||||||
Running A Production Server
|
Running A Production Server
|
||||||
====================
|
====================
|
||||||
|
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
@ -95,7 +95,16 @@ if __name__ == '__main__':
|
||||||
for points in sorted(puzzles_dict):
|
for points in sorted(puzzles_dict):
|
||||||
puzzle = puzzles_dict[points]
|
puzzle = puzzles_dict[points]
|
||||||
puzzledir = os.path.join(categoryname, 'content', mapping[points])
|
puzzledir = os.path.join(categoryname, 'content', mapping[points])
|
||||||
puzzlejson = puzzle.publish()
|
puzzledict = {
|
||||||
|
'author': puzzle.author,
|
||||||
|
'hashes': puzzle.hashes(),
|
||||||
|
'files': [f.name for f in puzzle.files if f.visible],
|
||||||
|
'body': puzzle.html_body(),
|
||||||
|
}
|
||||||
|
secretsdict = {
|
||||||
|
'summary': puzzle.summary,
|
||||||
|
'answers': puzzle.answers,
|
||||||
|
}
|
||||||
|
|
||||||
# write associated files
|
# write associated files
|
||||||
assoc_files = []
|
assoc_files = []
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import cgi
|
|
||||||
import glob
|
import glob
|
||||||
|
import html
|
||||||
import http.server
|
import http.server
|
||||||
|
import io
|
||||||
import mistune
|
import mistune
|
||||||
import moth
|
import moth
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import shutil
|
||||||
import socketserver
|
import socketserver
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
@ -15,8 +17,9 @@ try:
|
||||||
from http.server import HTTPStatus
|
from http.server import HTTPStatus
|
||||||
except ImportError:
|
except ImportError:
|
||||||
class HTTPStatus:
|
class HTTPStatus:
|
||||||
NOT_FOUND = 404
|
OK = (200, 'OK', 'Request fulfilled, document follows')
|
||||||
OK = 200
|
NOT_FOUND = (404, 'Not Found', 'Nothing matches the given URI')
|
||||||
|
INTERNAL_SERVER_ERROR = (500, 'Internal Server Error', 'Server got itself in trouble')
|
||||||
|
|
||||||
# XXX: This will eventually cause a problem. Do something more clever here.
|
# XXX: This will eventually cause a problem. Do something more clever here.
|
||||||
seed = 1
|
seed = 1
|
||||||
|
@ -26,15 +29,16 @@ def page(title, body):
|
||||||
return """<!DOCTYPE html>
|
return """<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>{}</title>
|
<title>{title}</title>
|
||||||
<link rel="stylesheet" href="/files/src/www/res/style.css">
|
<link rel="stylesheet" href="/files/src/www/res/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<h1>{title}</h1>
|
||||||
<div id="preview" class="terminal">
|
<div id="preview" class="terminal">
|
||||||
{}
|
{body}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>""".format(title, body)
|
</html>""".format(title=title, body=body)
|
||||||
|
|
||||||
|
|
||||||
def mdpage(body):
|
def mdpage(body):
|
||||||
|
@ -58,12 +62,14 @@ class MothHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
except:
|
except:
|
||||||
tbtype, value, tb = sys.exc_info()
|
tbtype, value, tb = sys.exc_info()
|
||||||
tblist = traceback.format_tb(tb, None) + traceback.format_exception_only(tbtype, value)
|
tblist = traceback.format_tb(tb, None) + traceback.format_exception_only(tbtype, value)
|
||||||
page = ("# Traceback (most recent call last)\n" +
|
payload = ("Traceback (most recent call last)\n" +
|
||||||
" " +
|
|
||||||
"".join(tblist[:-1]) +
|
"".join(tblist[:-1]) +
|
||||||
tblist[-1])
|
tblist[-1]).encode('utf-8')
|
||||||
self.serve_md(page)
|
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):
|
def do_GET(self):
|
||||||
if self.path == "/":
|
if self.path == "/":
|
||||||
|
@ -71,7 +77,7 @@ class MothHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
elif self.path.startswith("/puzzles"):
|
elif self.path.startswith("/puzzles"):
|
||||||
self.serve_puzzles()
|
self.serve_puzzles()
|
||||||
elif self.path.startswith("/files"):
|
elif self.path.startswith("/files"):
|
||||||
self.serve_file()
|
self.serve_file(self.translate_path(self.path))
|
||||||
else:
|
else:
|
||||||
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
|
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
|
||||||
|
|
||||||
|
@ -81,7 +87,7 @@ class MothHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
return super().translate_path(path)
|
return super().translate_path(path)
|
||||||
|
|
||||||
def serve_front(self):
|
def serve_front(self):
|
||||||
page = """
|
body = """
|
||||||
MOTH Development Server Front Page
|
MOTH Development Server Front Page
|
||||||
====================
|
====================
|
||||||
|
|
||||||
|
@ -90,133 +96,131 @@ There's stuff you can do here:
|
||||||
|
|
||||||
* [Available puzzles](/puzzles)
|
* [Available puzzles](/puzzles)
|
||||||
* [Raw filesystem view](/files/)
|
* [Raw filesystem view](/files/)
|
||||||
* [Documentation](/files/doc/)
|
* [Documentation](/files/docs/)
|
||||||
* [Instructions](/files/doc/devel-server.md) for using this server
|
* [Instructions](/files/docs/devel-server.md) for using this server
|
||||||
|
|
||||||
If you use this development server to run a contest,
|
If you use this development server to run a contest,
|
||||||
you are a fool.
|
you are a fool.
|
||||||
"""
|
"""
|
||||||
self.serve_md(page)
|
payload = 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):
|
def serve_puzzles(self):
|
||||||
body = []
|
body = io.StringIO()
|
||||||
path = self.path.rstrip('/')
|
path = self.path.rstrip('/')
|
||||||
parts = path.split("/")
|
parts = path.split("/")
|
||||||
#raise ValueError(parts)
|
title = None
|
||||||
if len(parts) < 3:
|
fpath = None
|
||||||
# List all categories
|
points = None
|
||||||
body.append("# Puzzle Categories")
|
cat = None
|
||||||
for i in glob.glob(os.path.join("puzzles", "*", "")):
|
puzzle = None
|
||||||
body.append("* [{}](/{})".format(i, i))
|
|
||||||
self.serve_md('\n'.join(body))
|
|
||||||
return
|
|
||||||
|
|
||||||
fpath = os.path.join("puzzles", parts[2])
|
|
||||||
cat = moth.Category(fpath, seed)
|
|
||||||
if len(parts) == 3:
|
|
||||||
# List all point values in a category
|
|
||||||
body.append("# Puzzles in category `{}`".format(parts[2]))
|
|
||||||
for points in cat.pointvals:
|
|
||||||
body.append("* [puzzles/{cat}/{points}](/puzzles/{cat}/{points}/)".format(cat=parts[2], points=points))
|
|
||||||
self.serve_md('\n'.join(body))
|
|
||||||
return
|
|
||||||
|
|
||||||
pzl = cat.puzzle(int(parts[3]))
|
|
||||||
if len(parts) == 4:
|
|
||||||
body.append("# {} puzzle {}".format(parts[2], parts[3]))
|
|
||||||
body.append("* Author: `{}`".format(pzl['author']))
|
|
||||||
body.append("* Summary: `{}`".format(pzl['summary']))
|
|
||||||
body.append('')
|
|
||||||
body.append("## Body")
|
|
||||||
body.append(pzl.body)
|
|
||||||
body.append("## Answers")
|
|
||||||
for a in pzl['answer']:
|
|
||||||
body.append("* `{}`".format(a))
|
|
||||||
body.append("")
|
|
||||||
body.append("## Files")
|
|
||||||
for pzl_file in pzl['files']:
|
|
||||||
body.append("* [puzzles/{cat}/{points}/{filename}]({filename})"
|
|
||||||
.format(cat=parts[2], points=pzl.points, filename=pzl_file))
|
|
||||||
|
|
||||||
if len(pzl.logs) > 0:
|
|
||||||
body.extend(["", "## Logs"])
|
|
||||||
body.append("* [Full Log File](_logs)"
|
|
||||||
.format(cat=parts[2], points=pzl.points))
|
|
||||||
body.extend(["", "### Logs Head"])
|
|
||||||
for log in pzl.logs[:10]:
|
|
||||||
body.append("* `{}`".format(log))
|
|
||||||
body.extend(["", "### Logs Tail"])
|
|
||||||
for log in pzl.logs[-10:]:
|
|
||||||
body.append("* `{}`".format(log))
|
|
||||||
self.serve_md('\n'.join(body))
|
|
||||||
return
|
|
||||||
elif len(parts) == 5:
|
|
||||||
if parts[4] == '_logs':
|
|
||||||
self.serve_puzzle_logs(pzl.logs)
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
self.serve_puzzle_file(pzl['files'][parts[4]])
|
fpath = os.path.join("puzzles", 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("puzzles", "*", ""))):
|
||||||
|
body.write('<li><a href="{}">{}</a></li>'.format(i, i))
|
||||||
|
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="/puzzles/{cat}/{points}">puzzles/{cat}/{points}</a></li>'.format(cat=parts[2], points=points))
|
||||||
|
body.write("</ul>")
|
||||||
|
elif len(parts) == 4:
|
||||||
|
# Serve up a puzzle
|
||||||
|
title = "{} puzzle {}".format(parts[2], parts[3])
|
||||||
|
body.write("<h2>Body</h2>")
|
||||||
|
body.write(puzzle.html_body())
|
||||||
|
body.write("<h2>Files</h2>")
|
||||||
|
body.write("<ul>")
|
||||||
|
for name in puzzle.files:
|
||||||
|
body.write('<li><a href="/puzzles/{cat}/{points}/{filename}">{filename}</a></li>'
|
||||||
|
.format(cat=parts[2], points=puzzle.points, filename=name))
|
||||||
|
body.write("</ul>")
|
||||||
|
body.write("<h2>Answers</h2>")
|
||||||
|
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>Author</h2><p>{}</p>".format(puzzle.author))
|
||||||
|
body.write("<h2>Summary</h2><p>{}</p>".format(puzzle.summary))
|
||||||
|
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:
|
except KeyError:
|
||||||
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
|
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
|
||||||
return
|
return
|
||||||
else:
|
ctype = self.guess_type(pfile.name)
|
||||||
body.append("# Not Implemented Yet")
|
|
||||||
self.serve_md('\n'.join(body))
|
|
||||||
|
|
||||||
def serve_puzzle_logs(self, logs):
|
|
||||||
"""Serve a PuzzleFile object."""
|
|
||||||
self.send_response(HTTPStatus.OK)
|
self.send_response(HTTPStatus.OK)
|
||||||
self.send_header("Content-type", "text/plain; charset=utf-8")
|
self.send_header("Content-Type", ctype)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
for log in logs:
|
shutil.copyfileobj(pfile.stream, self.wfile)
|
||||||
self.wfile.write(log.encode('ascii'))
|
return
|
||||||
self.wfile.write(b"\n")
|
|
||||||
|
|
||||||
CHUNK_SIZE = 4096
|
payload = page(title, body.getvalue()).encode('utf-8')
|
||||||
def serve_puzzle_file(self, file):
|
|
||||||
"""Serve a PuzzleFile object."""
|
|
||||||
self.send_response(HTTPStatus.OK)
|
self.send_response(HTTPStatus.OK)
|
||||||
self.send_header("Content-type", "application/octet-stream")
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
self.send_header('Content-Disposition', 'attachment; filename="{}"'.format(file.name))
|
self.send_header("Content-Length", len(payload))
|
||||||
if file.path is not None:
|
|
||||||
fs = os.stat(file.path)
|
|
||||||
self.send_header("Last-Modified", self.date_time_string(fs.st_mtime))
|
|
||||||
|
|
||||||
# We're using application/octet stream, so we can send the raw bytes.
|
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
chunk = file.handle.read(self.CHUNK_SIZE)
|
self.wfile.write(payload)
|
||||||
while chunk:
|
|
||||||
self.wfile.write(chunk)
|
|
||||||
chunk = file.handle.read(self.CHUNK_SIZE)
|
|
||||||
|
|
||||||
def serve_file(self):
|
def serve_file(self, path):
|
||||||
if self.path.endswith(".md"):
|
lastmod = None
|
||||||
self.serve_md()
|
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:
|
||||||
super().do_GET()
|
ctype = self.guess_type(path)
|
||||||
|
|
||||||
def serve_md(self, text=None):
|
|
||||||
fspathstr = self.translate_path(self.path)
|
|
||||||
fspath = pathlib.Path(fspathstr)
|
|
||||||
if not text:
|
|
||||||
try:
|
try:
|
||||||
text = fspath.read_text()
|
payload = fspath.read_bytes()
|
||||||
except OSError:
|
except OSError:
|
||||||
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
|
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
|
||||||
return None
|
return
|
||||||
content = mdpage(text)
|
if path.endswith(".md"):
|
||||||
|
ctype = "text/html; charset=utf-8"
|
||||||
self.send_response(HTTPStatus.OK)
|
content = mdpage(payload.decode('utf-8'))
|
||||||
|
payload = content.encode('utf-8')
|
||||||
self.send_header("Content-type", "text/html; charset=utf-8")
|
|
||||||
self.send_header("Content-Length", len(content))
|
|
||||||
try:
|
try:
|
||||||
fs = fspath.stat()
|
fs = fspath.stat()
|
||||||
self.send_header("Last-Modified", self.date_time_string(fs.st_mtime))
|
lastmod = self.date_time_string(fs.st_mtime)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
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.end_headers()
|
||||||
self.wfile.write(content.encode('utf-8'))
|
self.wfile.write(payload)
|
||||||
|
|
||||||
|
|
||||||
def run(address=('localhost', 8080)):
|
def run(address=('localhost', 8080)):
|
||||||
|
|
359
tools/moth.py
359
tools/moth.py
|
@ -1,13 +1,15 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from collections import defaultdict, namedtuple
|
import contextlib
|
||||||
import glob
|
import glob
|
||||||
import hashlib
|
import hashlib
|
||||||
from importlib.machinery import SourceFileLoader
|
import io
|
||||||
|
import importlib.machinery
|
||||||
import mistune
|
import mistune
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import string
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||||
|
@ -18,10 +20,22 @@ def djb2hash(buf):
|
||||||
h = ((h * 33) + c) & 0xffffffff
|
h = ((h * 33) + c) & 0xffffffff
|
||||||
return h
|
return h
|
||||||
|
|
||||||
# We use a named tuple rather than a full class, because any random name generation has
|
@contextlib.contextmanager
|
||||||
# to be done with Puzzle's random number generator, and it's cleaner to not pass that around.
|
def pushd(newdir):
|
||||||
PuzzleFile = namedtuple('PuzzleFile', ['path', 'handle', 'name', 'visible'])
|
curdir = os.getcwd()
|
||||||
PuzzleFile.__doc__ = """A file associated with a puzzle.
|
os.chdir(newdir)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
os.chdir(curdir)
|
||||||
|
|
||||||
|
# Get a big list of clean words for our answer file.
|
||||||
|
ANSWER_WORDS = [w.strip() for w in open(os.path.join(os.path.dirname(__file__),
|
||||||
|
'answer_words.txt'))]
|
||||||
|
|
||||||
|
class PuzzleFile:
|
||||||
|
"""A file associated with a puzzle.
|
||||||
|
|
||||||
path: The path to the original input file. May be None (when this is created from a file handle
|
path: The path to the original input file. May be None (when this is created from a file handle
|
||||||
and there is no original input.
|
and there is no original input.
|
||||||
handle: A File-like object set to read the file from. You should be able to read straight
|
handle: A File-like object set to read the file from. You should be able to read straight
|
||||||
|
@ -31,246 +45,106 @@ PuzzleFile.__doc__ = """A file associated with a puzzle.
|
||||||
the file is still expected to be accessible, but it's path must be known
|
the file is still expected to be accessible, but it's path must be known
|
||||||
(or figured out) to retrieve it."""
|
(or figured out) to retrieve it."""
|
||||||
|
|
||||||
|
def __init__(self, stream, name, visible=True):
|
||||||
|
self.stream = stream
|
||||||
|
self.name = name
|
||||||
|
self.visible = visible
|
||||||
|
|
||||||
|
|
||||||
class Puzzle:
|
class Puzzle:
|
||||||
|
def __init__(self, category_seed, points):
|
||||||
KNOWN_KEYS = [
|
|
||||||
'answer',
|
|
||||||
'author',
|
|
||||||
'file',
|
|
||||||
'hidden',
|
|
||||||
'name'
|
|
||||||
'resource',
|
|
||||||
'summary'
|
|
||||||
]
|
|
||||||
REQUIRED_KEYS = [
|
|
||||||
'author',
|
|
||||||
'answer',
|
|
||||||
]
|
|
||||||
SINGULAR_KEYS = [
|
|
||||||
'name'
|
|
||||||
]
|
|
||||||
|
|
||||||
# Get a big list of clean words for our answer file.
|
|
||||||
ANSWER_WORDS = [w.strip() for w in open(os.path.join(os.path.dirname(__file__),
|
|
||||||
'answer_words.txt'))]
|
|
||||||
|
|
||||||
def __init__(self, category_seed, path=None, points=None):
|
|
||||||
"""A MOTH Puzzle.
|
"""A MOTH Puzzle.
|
||||||
|
|
||||||
:param category_seed: A byte string to use as a seed for random numbers for this puzzle.
|
:param category_seed: A byte string to use as a seed for random numbers for this puzzle.
|
||||||
It is combined with the puzzle points.
|
It is combined with the puzzle points.
|
||||||
:param path: An optional path to a puzzle directory. The point value for the puzzle is taken
|
:param points: The point value of the puzzle.
|
||||||
from the puzzle directories name (it must be an integer greater than zero).
|
|
||||||
Within this directory, we expect:
|
|
||||||
(optional) A puzzle.moth file in RFC2822 format. The puzzle will get its attributes
|
|
||||||
from the headers, and the body will be the puzzle description in
|
|
||||||
Markdown format.
|
|
||||||
(optional) A puzzle.py file. This is expected to have a callable called make
|
|
||||||
that takes a single positional argument (this puzzle object).
|
|
||||||
This callable can then do whatever it needs to with this object.
|
|
||||||
:param points: The point value of the puzzle. Mutually exclusive with path.
|
|
||||||
If neither of the above are given, the point value for the puzzle will have to
|
|
||||||
be set at instantiation.
|
|
||||||
|
|
||||||
For puzzle attributes, this class acts like a dictionary that in most cases assigns
|
|
||||||
always returns a list. Certain keys, however behave differently:
|
|
||||||
- Keys in Puzzle.SINGULAR_KEYS can only have one value, and writing to these overwrites
|
|
||||||
that value.
|
|
||||||
- The keys 'hidden', 'file', and 'resource' all create a new PuzzleFile object that
|
|
||||||
gets added under the 'files' key.
|
|
||||||
- The 'answer' also adds a new hash under the the 'hash' key.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
if (points is None and path is None) or (points is not None and path is not None):
|
|
||||||
raise ValueError("Either points or path must be set, but not both.")
|
|
||||||
|
|
||||||
self._dict = defaultdict(lambda: [])
|
|
||||||
if os.path.isdir(path):
|
|
||||||
self.puzzle_dir = path
|
|
||||||
else:
|
|
||||||
self.puzzle_dir = None
|
|
||||||
self.message = bytes(random.choice(messageChars) for i in range(20))
|
|
||||||
self.body = ''
|
|
||||||
|
|
||||||
# This defaults to a dict, not a list like most things
|
|
||||||
self._dict['files'] = {}
|
|
||||||
|
|
||||||
# A list of temporary files we've created that will need to be deleted.
|
|
||||||
self._temp_files = []
|
|
||||||
if path is not None:
|
|
||||||
if not os.path.isdir(path):
|
|
||||||
raise ValueError("No such directory: {}".format(path))
|
|
||||||
|
|
||||||
pathname = os.path.split(path)[-1]
|
|
||||||
try:
|
|
||||||
self.points = int(pathname)
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError("Directory name must be a point value: {}".format(path))
|
|
||||||
elif points is not None:
|
|
||||||
self.points = points
|
self.points = points
|
||||||
|
self.author = None
|
||||||
self._seed = category_seed * self.points
|
self.summary = None
|
||||||
self.rand = random.Random(self._seed)
|
self.answers = []
|
||||||
|
self.files = {}
|
||||||
self._logs = []
|
self.body = io.StringIO()
|
||||||
|
self.logs = []
|
||||||
if path is not None:
|
self.randseed = category_seed * self.points
|
||||||
files = os.listdir(path)
|
self.rand = random.Random(self.randseed)
|
||||||
|
|
||||||
if 'puzzle.moth' in files:
|
|
||||||
self._read_config(open(os.path.join(path, 'puzzle.moth')))
|
|
||||||
|
|
||||||
if 'puzzle.py' in files:
|
|
||||||
# Good Lord this is dangerous as fuck.
|
|
||||||
loader = SourceFileLoader('puzzle_mod', os.path.join(path, 'puzzle.py'))
|
|
||||||
puzzle_mod = loader.load_module()
|
|
||||||
if hasattr(puzzle_mod, 'make'):
|
|
||||||
self.body = '# `puzzle.body` was not set by the `make` function'
|
|
||||||
puzzle_mod.make(self)
|
|
||||||
else:
|
|
||||||
self.body = '# `puzzle.py` does not define a `make` function'
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""Cleanup any outstanding temporary files."""
|
|
||||||
for path in self._temp_files:
|
|
||||||
if os.path.exists(path):
|
|
||||||
try:
|
|
||||||
os.unlink(path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def log(self, msg):
|
def log(self, msg):
|
||||||
"""Add a new log message to this puzzle."""
|
"""Add a new log message to this puzzle."""
|
||||||
self._logs.append(msg)
|
self.logs.append(msg)
|
||||||
|
|
||||||
@property
|
def read_stream(self, stream):
|
||||||
def logs(self):
|
|
||||||
"""Get all the log messages, as strings."""
|
|
||||||
|
|
||||||
_logs = []
|
|
||||||
for log in self._logs:
|
|
||||||
if type(log) is bytes:
|
|
||||||
log = log.decode('utf-8')
|
|
||||||
elif type(log) is not str:
|
|
||||||
log = str(log)
|
|
||||||
|
|
||||||
_logs.append(log)
|
|
||||||
|
|
||||||
return _logs
|
|
||||||
|
|
||||||
def _read_config(self, stream):
|
|
||||||
"""Read a configuration file (ISO 2822)"""
|
|
||||||
body = []
|
|
||||||
header = True
|
header = True
|
||||||
for line in stream:
|
for line in stream:
|
||||||
if header:
|
if header:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line.strip():
|
if not line:
|
||||||
header = False
|
header = False
|
||||||
continue
|
continue
|
||||||
key, val = line.split(':', 1)
|
key, val = line.split(':', 1)
|
||||||
val = val.strip()
|
key = key.lower()
|
||||||
self[key] = val
|
if key == 'author':
|
||||||
|
self.author = val
|
||||||
|
elif key == 'summary':
|
||||||
|
self.summary = val
|
||||||
|
elif key == 'answer':
|
||||||
|
self.answers.append(val)
|
||||||
|
elif key == 'file':
|
||||||
|
parts = val.split()
|
||||||
|
name = parts[0]
|
||||||
|
hidden = False
|
||||||
|
stream = open(name, 'rb')
|
||||||
|
try:
|
||||||
|
name = parts[1]
|
||||||
|
hidden = parts[2]
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
self.files[name] = PuzzleFile(stream, name, not hidden)
|
||||||
else:
|
else:
|
||||||
body.append(line)
|
raise ValueError("Unrecognized header field: {}".format(key))
|
||||||
self.body = ''.join(body)
|
else:
|
||||||
|
self.body.write(line)
|
||||||
|
|
||||||
|
def read_directory(self, path):
|
||||||
|
try:
|
||||||
|
fn = os.path.join(path, "puzzle.py")
|
||||||
|
loader = importlib.machinery.SourceFileLoader('puzzle_mod', fn)
|
||||||
|
puzzle_mod = loader.load_module()
|
||||||
|
except FileNotFoundError:
|
||||||
|
puzzle_mod = None
|
||||||
|
|
||||||
|
if puzzle_mod:
|
||||||
|
with pushd(path):
|
||||||
|
puzzle_mod.make(self)
|
||||||
|
else:
|
||||||
|
with open(os.path.join(path, 'puzzle.moth')) as f:
|
||||||
|
self.read_stream(f)
|
||||||
|
|
||||||
def random_hash(self):
|
def random_hash(self):
|
||||||
"""Create a random hash from our number generator suitable for use as a filename."""
|
"""Create a file basename (no extension) with our number generator."""
|
||||||
return hashlib.sha1(str(self.rand.random()).encode('ascii')).digest()
|
return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8))
|
||||||
|
|
||||||
def _puzzle_file(self, path, name, visible=True):
|
|
||||||
"""Make a puzzle file instance for the given file. To add files as you would in the config
|
|
||||||
file (to 'file', 'hidden', or 'resource', simply assign to that keyword in the object:
|
|
||||||
puzzle['file'] = 'some_file.txt'
|
|
||||||
puzzle['hidden'] = 'some_hidden_file.txt'
|
|
||||||
puzzle['resource'] = 'some_file_in_the_category_resource_directory_omg_long_name.txt'
|
|
||||||
:param path: The path to the file
|
|
||||||
:param name: The name of the file. If set to None, the published file will have
|
|
||||||
a random hash as a name and have visible set to False.
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Make sure it actually exists.
|
|
||||||
if not os.path.exists(path):
|
|
||||||
raise ValueError("Included file {} does not exist.".format(path))
|
|
||||||
|
|
||||||
file = open(path, 'rb')
|
|
||||||
|
|
||||||
return PuzzleFile(path=path, handle=file, name=name, visible=visible)
|
|
||||||
|
|
||||||
def make_temp_file(self, name=None, visible=True):
|
def make_temp_file(self, name=None, visible=True):
|
||||||
"""Get a file object for adding dynamically generated data to the puzzle. When you're
|
"""Get a file object for adding dynamically generated data to the puzzle. When you're
|
||||||
done with this file, flush it, but don't close it.
|
done with this file, flush it, but don't close it.
|
||||||
|
|
||||||
:param name: The name of the file for links within the puzzle. If this is None, a name
|
:param name: The name of the file for links within the puzzle. If this is None, a name
|
||||||
will be generated for you.
|
will be generated for you.
|
||||||
:param visible: Whether or not the file will be visible to the user.
|
:param visible: Whether or not the file will be visible to the user.
|
||||||
:return: A file object for writing
|
:return: A file object for writing
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
stream = tempfile.TemporaryFile()
|
||||||
|
self.add_stream(stream, name, visible)
|
||||||
|
return stream
|
||||||
|
|
||||||
|
def add_stream(self, stream, name=None, visible=True):
|
||||||
if name is None:
|
if name is None:
|
||||||
name = self.random_hash()
|
name = self.random_hash()
|
||||||
|
self.files[name] = PuzzleFile(stream, name, visible)
|
||||||
file = tempfile.NamedTemporaryFile(mode='w+b', delete=False)
|
|
||||||
file_read = open(file.name, 'rb')
|
|
||||||
|
|
||||||
self._dict['files'][name] = PuzzleFile(path=file.name, handle=file_read,
|
|
||||||
name=name, visible=visible)
|
|
||||||
|
|
||||||
return file
|
|
||||||
|
|
||||||
def make_handle_file(self, handle, name, visible=True):
|
|
||||||
"""Add a file to the puzzle from a file handle.
|
|
||||||
:param handle: A file object or equivalent.
|
|
||||||
:param name: The name of the file in the final puzzle.
|
|
||||||
:param visible: Whether or not it's visible.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
|
||||||
"""Set a value for this puzzle, as if it were set in the config file. Most values default
|
|
||||||
being added to a list. Files (regardless of type) go in a dict under ['files']. Keys
|
|
||||||
in Puzzle.SINGULAR_KEYS are single values that get overwritten with subsequent assignments.
|
|
||||||
Only keys in Puzzle.KNOWN_KEYS are accepted.
|
|
||||||
:param key:
|
|
||||||
:param value:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
|
|
||||||
key = key.lower()
|
|
||||||
|
|
||||||
if key in ('file', 'resource', 'hidden') and self.puzzle_dir is None:
|
|
||||||
raise KeyError("Cannot set a puzzle file for single file puzzles.")
|
|
||||||
|
|
||||||
if key == 'answer':
|
|
||||||
# Handle adding answers to the puzzle
|
|
||||||
self._dict['hash'].append(djb2hash(value.encode('utf8')))
|
|
||||||
self._dict['answer'].append(value)
|
|
||||||
elif key == 'file':
|
|
||||||
# Handle adding files to the puzzle
|
|
||||||
path = os.path.join(self.puzzle_dir, 'files', value)
|
|
||||||
self._dict['files'][value] = self._puzzle_file(path, value)
|
|
||||||
elif key == 'resource':
|
|
||||||
# Handle adding category files to the puzzle
|
|
||||||
path = os.path.join(self.puzzle_dir, '../res', value)
|
|
||||||
self._dict['files'].append(self._puzzle_file(path, value))
|
|
||||||
elif key == 'hidden':
|
|
||||||
# Handle adding secret, 'hidden' files to the puzzle.
|
|
||||||
path = os.path.join(self.puzzle_dir, 'files', value)
|
|
||||||
name = self.random_hash()
|
|
||||||
self._dict['files'].append(self._puzzle_file(path, name, visible=False))
|
|
||||||
elif key in self.SINGULAR_KEYS:
|
|
||||||
# These keys can only have one value
|
|
||||||
self._dict[key] = value
|
|
||||||
elif key in self.KNOWN_KEYS:
|
|
||||||
self._dict[key].append(value)
|
|
||||||
else:
|
|
||||||
raise KeyError("Invalid Attribute: {}".format(key))
|
|
||||||
|
|
||||||
def __getitem__(self, item):
|
|
||||||
return self._dict[item.lower()]
|
|
||||||
|
|
||||||
def make_answer(self, word_count, sep=' '):
|
def make_answer(self, word_count, sep=' '):
|
||||||
"""Generate and return a new answer. It's automatically added to the puzzle answer list.
|
"""Generate and return a new answer. It's automatically added to the puzzle answer list.
|
||||||
|
@ -279,48 +153,21 @@ class Puzzle:
|
||||||
:returns: The answer string
|
:returns: The answer string
|
||||||
"""
|
"""
|
||||||
|
|
||||||
answer = sep.join(self.rand.sample(self.ANSWER_WORDS, word_count))
|
answer = sep.join(self.rand.sample(ANSWER_WORDS, word_count))
|
||||||
self['answer'] = answer
|
self.answers.append(answer)
|
||||||
|
|
||||||
return answer
|
return answer
|
||||||
|
|
||||||
def htmlify(self):
|
def get_body(self):
|
||||||
|
return self.body.getvalue()
|
||||||
|
|
||||||
|
def html_body(self):
|
||||||
"""Format and return the markdown for the puzzle body."""
|
"""Format and return the markdown for the puzzle body."""
|
||||||
return mistune.markdown(self.body)
|
return mistune.markdown(self.get_body())
|
||||||
|
|
||||||
def publish(self):
|
def hashes(self):
|
||||||
obj = {
|
"Return a list of answer hashes"
|
||||||
'author': self['author'],
|
|
||||||
'hashes': self['hashes'],
|
|
||||||
'body': self.htmlify(),
|
|
||||||
}
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def secrets(self):
|
return [djbhash(a) for a in self.answers]
|
||||||
obj = {
|
|
||||||
'answers': self['answers'],
|
|
||||||
'summary': self['summary'],
|
|
||||||
}
|
|
||||||
return obj
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
parser = argparse.ArgumentParser(description='Build a puzzle category')
|
|
||||||
parser.add_argument('puzzledir', nargs='+', help='Directory of puzzle source')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
for puzzledir in args.puzzledir:
|
|
||||||
puzzles = {}
|
|
||||||
secrets = {}
|
|
||||||
for puzzlePath in glob.glob(os.path.join(puzzledir, "*.moth")):
|
|
||||||
filename = os.path.basename(puzzlePath)
|
|
||||||
points, ext = os.path.splitext(filename)
|
|
||||||
points = int(points)
|
|
||||||
puzzle = Puzzle(puzzlePath, "test")
|
|
||||||
puzzles[points] = puzzle
|
|
||||||
|
|
||||||
for points in sorted(puzzles):
|
|
||||||
puzzle = puzzles[points]
|
|
||||||
print(puzzle.secrets())
|
|
||||||
|
|
||||||
|
|
||||||
class Category:
|
class Category:
|
||||||
|
@ -328,15 +175,31 @@ class Category:
|
||||||
self.path = path
|
self.path = path
|
||||||
self.seed = seed
|
self.seed = seed
|
||||||
self.pointvals = []
|
self.pointvals = []
|
||||||
|
self.catmod = None
|
||||||
|
|
||||||
|
if os.path.exists(os.path.join(path, 'category.py')):
|
||||||
|
self.catmod = importlib.machinery.SourceFileLoader(
|
||||||
|
'catmod',
|
||||||
|
os.path.join(path, 'category.py')).load_module()
|
||||||
|
self.pointvals.extend(self.catmod.points)
|
||||||
|
self.pointvals = self.catmod.points[:]
|
||||||
|
else:
|
||||||
for fpath in glob.glob(os.path.join(path, "[0-9]*")):
|
for fpath in glob.glob(os.path.join(path, "[0-9]*")):
|
||||||
pn = os.path.basename(fpath)
|
pn = os.path.basename(fpath)
|
||||||
points = int(pn)
|
points = int(pn)
|
||||||
self.pointvals.append(points)
|
self.pointvals.append(points)
|
||||||
|
|
||||||
self.pointvals.sort()
|
self.pointvals.sort()
|
||||||
|
|
||||||
def puzzle(self, points):
|
def puzzle(self, points):
|
||||||
|
puzzle = Puzzle(self.seed, points)
|
||||||
path = os.path.join(self.path, str(points))
|
path = os.path.join(self.path, str(points))
|
||||||
return Puzzle(self.seed, path)
|
if self.catmod:
|
||||||
|
with pushd(self.path):
|
||||||
|
self.catmod.make(points, puzzle)
|
||||||
|
else:
|
||||||
|
puzzle.read_directory(path)
|
||||||
|
return puzzle
|
||||||
|
|
||||||
def puzzles(self):
|
def puzzles(self):
|
||||||
for points in self.pointvals:
|
for points in self.pointvals:
|
||||||
|
|
Loading…
Reference in New Issue