mirror of https://github.com/dirtbags/moth.git
commit
a4f92e7856
|
@ -2,6 +2,7 @@
|
|||
*#
|
||||
*.pyc
|
||||
*.o
|
||||
.idea
|
||||
./bin/
|
||||
build/
|
||||
cache/
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
Neale Pickett
|
||||
Patrick Avery
|
||||
Shannon Steinfadt
|
File diff suppressed because it is too large
Load Diff
108
devel-server.py
108
devel-server.py
|
@ -1,5 +1,6 @@
|
|||
#!/usr/bin/python3
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import cgi
|
||||
import glob
|
||||
import http.server
|
||||
import mistune
|
||||
|
@ -7,8 +8,19 @@ import os
|
|||
import pathlib
|
||||
import puzzles
|
||||
import socketserver
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
try:
|
||||
from http.server import HTTPStatus
|
||||
except ImportError:
|
||||
class HTTPStatus:
|
||||
NOT_FOUND = 404
|
||||
OK = 200
|
||||
|
||||
# XXX: This will eventually cause a problem. Do something more clever here.
|
||||
seed = 1
|
||||
|
||||
HTTPStatus = http.server.HTTPStatus
|
||||
|
||||
def page(title, body):
|
||||
return """<!DOCTYPE html>
|
||||
|
@ -24,6 +36,7 @@ def page(title, body):
|
|||
</body>
|
||||
</html>""".format(title, body)
|
||||
|
||||
|
||||
def mdpage(body):
|
||||
try:
|
||||
title, _ = body.split('\n', 1)
|
||||
|
@ -37,7 +50,21 @@ def mdpage(body):
|
|||
class ThreadingServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
pass
|
||||
|
||||
class MothHandler(http.server.CGIHTTPRequestHandler):
|
||||
|
||||
class MothHandler(http.server.SimpleHTTPRequestHandler):
|
||||
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)
|
||||
page = ("# Traceback (most recent call last)\n" +
|
||||
" " +
|
||||
" ".join(tblist[:-1]) +
|
||||
tblist[-1])
|
||||
self.serve_md(page)
|
||||
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/":
|
||||
self.serve_front()
|
||||
|
@ -75,39 +102,70 @@ you are a fool.
|
|||
body = []
|
||||
path = self.path.rstrip('/')
|
||||
parts = path.split("/")
|
||||
#raise ValueError(parts)
|
||||
if len(parts) < 3:
|
||||
# List all categories
|
||||
body.append("# Puzzle Categories")
|
||||
for i in glob.glob(os.path.join("puzzles", "*", "")):
|
||||
body.append("* [{}](/{})".format(i, i))
|
||||
elif len(parts) == 3:
|
||||
self.serve_md('\n'.join(body))
|
||||
return
|
||||
|
||||
fpath = os.path.join("puzzles", parts[2])
|
||||
cat = puzzles.Category(fpath, seed)
|
||||
if len(parts) == 3:
|
||||
# List all point values in a category
|
||||
body.append("# Puzzles in category `{}`".format(parts[2]))
|
||||
puzz = []
|
||||
for i in glob.glob(os.path.join("puzzles", parts[2], "*.moth")):
|
||||
base = os.path.basename(i)
|
||||
root, _ = os.path.splitext(base)
|
||||
points = int(root)
|
||||
puzz.append(points)
|
||||
for puzzle in sorted(puzz):
|
||||
body.append("* [puzzles/{cat}/{points}](/puzzles/{cat}/{points}/)".format(cat=parts[2], points=puzzle))
|
||||
elif len(parts) == 4:
|
||||
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]))
|
||||
with open("puzzles/{}/{}.moth".format(parts[2], parts[3])) as f:
|
||||
p = puzzles.Puzzle(f)
|
||||
body.append("* Author: `{}`".format(p.fields.get("author")))
|
||||
body.append("* Summary: `{}`".format(p.fields.get("summary")))
|
||||
body.append("* Author: `{}`".format(pzl['author']))
|
||||
body.append("* Summary: `{}`".format(pzl['summary']))
|
||||
body.append('')
|
||||
body.append("## Body")
|
||||
body.append(p.body)
|
||||
body.append("## Answers:")
|
||||
for a in p.answers:
|
||||
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))
|
||||
self.serve_md('\n'.join(body))
|
||||
return
|
||||
elif len(parts) == 5:
|
||||
try:
|
||||
self.serve_puzzle_file(pzl['files'][parts[4]])
|
||||
except KeyError:
|
||||
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
|
||||
return
|
||||
else:
|
||||
body.append("# Not Implemented Yet")
|
||||
self.serve_md('\n'.join(body))
|
||||
|
||||
CHUNK_SIZE = 4096
|
||||
def serve_puzzle_file(self, file):
|
||||
"""Serve a PuzzleFile object."""
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-type", "application/octet-stream")
|
||||
self.send_header('Content-Disposition', 'attachment; filename="{}"'.format(file.name))
|
||||
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()
|
||||
chunk = file.handle.read(self.CHUNK_SIZE)
|
||||
while chunk:
|
||||
self.wfile.write(chunk)
|
||||
chunk = file.handle.read(self.CHUNK_SIZE)
|
||||
|
||||
def serve_file(self):
|
||||
if self.path.endswith(".md"):
|
||||
self.serve_md()
|
||||
|
@ -125,8 +183,9 @@ you are a fool.
|
|||
return None
|
||||
content = mdpage(text)
|
||||
|
||||
self.send_response(http.server.HTTPStatus.OK)
|
||||
self.send_header("Content-type", "text/html; encoding=utf-8")
|
||||
self.send_response(HTTPStatus.OK)
|
||||
|
||||
self.send_header("Content-type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", len(content))
|
||||
try:
|
||||
fs = fspath.stat()
|
||||
|
@ -136,9 +195,10 @@ you are a fool.
|
|||
self.end_headers()
|
||||
self.wfile.write(content.encode('utf-8'))
|
||||
|
||||
def run(address=('', 8080)):
|
||||
|
||||
def run(address=('localhost', 8080)):
|
||||
httpd = ThreadingServer(address, MothHandler)
|
||||
print("=== Listening on http://{}:{}/".format(address[0] or "localhost", address[1]))
|
||||
print("=== Listening on http://{}:{}/".format(address[0], address[1]))
|
||||
httpd.serve_forever()
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
@ -335,7 +335,7 @@ class BlockLexer(object):
|
|||
|
||||
rest = len(item)
|
||||
if i != length - 1 and rest:
|
||||
_next = item[rest-1] == '\n'
|
||||
_next = item[rest - 1] == '\n'
|
||||
if not loose:
|
||||
loose = _next
|
||||
|
||||
|
|
279
puzzles.py
279
puzzles.py
|
@ -1,13 +1,14 @@
|
|||
#!/usr/bin/python3
|
||||
|
||||
import hmac
|
||||
import base64
|
||||
import argparse
|
||||
from collections import defaultdict, namedtuple
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import hashlib
|
||||
from importlib.machinery import SourceFileLoader
|
||||
import mistune
|
||||
import os
|
||||
import random
|
||||
import tempfile
|
||||
|
||||
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
|
||||
|
@ -17,13 +18,129 @@ def djb2hash(buf):
|
|||
h = ((h * 33) + c) & 0xffffffff
|
||||
return h
|
||||
|
||||
class Puzzle:
|
||||
def __init__(self, stream):
|
||||
self.message = bytes(random.choice(messageChars) for i in range(20))
|
||||
self.fields = {}
|
||||
self.answers = []
|
||||
self.hashes = []
|
||||
# We use a named tuple rather than a full class, because any random name generation has
|
||||
# to be done with Puzzle's random number generator, and it's cleaner to not pass that around.
|
||||
PuzzleFile = namedtuple('PuzzleFile', ['path', 'handle', 'name', 'visible'])
|
||||
PuzzleFile.__doc__ = """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
|
||||
and there is no original input.
|
||||
handle: A File-like object set to read the file from. You should be able to read straight
|
||||
from it without having to seek to the beginning of the file.
|
||||
name: The name of the output file.
|
||||
visible: A boolean indicating whether this file should visible to the user. If False,
|
||||
the file is still expected to be accessible, but it's path must be known
|
||||
(or figured out) to retrieve it."""
|
||||
|
||||
|
||||
class Puzzle:
|
||||
|
||||
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.
|
||||
:param category_seed: A byte string to use as a seed for random numbers for this puzzle.
|
||||
It is combined with the puzzle points.
|
||||
:param path: An optional path to a puzzle directory. The point value for the puzzle is taken
|
||||
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__()
|
||||
|
||||
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._seed = category_seed * self.points
|
||||
self.rand = random.Random(self._seed)
|
||||
|
||||
if path is not None:
|
||||
files = os.listdir(path)
|
||||
|
||||
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 _read_config(self, stream):
|
||||
"""Read a configuration file (ISO 2822)"""
|
||||
body = []
|
||||
header = True
|
||||
for line in stream:
|
||||
|
@ -33,36 +150,135 @@ class Puzzle:
|
|||
header = False
|
||||
continue
|
||||
key, val = line.split(':', 1)
|
||||
key = key.lower()
|
||||
val = val.strip()
|
||||
self._add_field(key, val)
|
||||
self[key] = val
|
||||
else:
|
||||
body.append(line)
|
||||
self.body = ''.join(body)
|
||||
|
||||
def _add_field(self, key, val):
|
||||
def random_hash(self):
|
||||
"""Create a random hash from our number generator suitable for use as a filename."""
|
||||
return hashlib.sha1(str(self.rand.random()).encode('ascii')).digest()
|
||||
|
||||
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):
|
||||
"""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.
|
||||
:param name: The name of the file for links within the puzzle. If this is None, a name
|
||||
will be generated for you.
|
||||
:param visible: Whether or not the file will be visible to the user.
|
||||
:return: A file object for writing
|
||||
"""
|
||||
|
||||
if name is None:
|
||||
name = self.random_hash()
|
||||
|
||||
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':
|
||||
h = djb2hash(val.encode('utf8'))
|
||||
self.answers.append(val)
|
||||
self.hashes.append(h)
|
||||
# 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:
|
||||
self.fields[key] = val
|
||||
raise KeyError("Invalid Attribute: {}".format(key))
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self._dict[item.lower()]
|
||||
|
||||
def make_answer(self, word_count, sep=' '):
|
||||
"""Generate and return a new answer. It's automatically added to the puzzle answer list.
|
||||
:param int word_count: The number of words to include in the answer.
|
||||
:param str|bytes sep: The word separator.
|
||||
:returns: The answer string
|
||||
"""
|
||||
|
||||
answer = sep.join(self.rand.sample(self.ANSWER_WORDS, word_count))
|
||||
self['answer'] = answer
|
||||
|
||||
return answer
|
||||
|
||||
def htmlify(self):
|
||||
"""Format and return the markdown for the puzzle body."""
|
||||
return mistune.markdown(self.body)
|
||||
|
||||
def publish(self):
|
||||
obj = {
|
||||
'author': self.fields['author'],
|
||||
'hashes': self.hashes,
|
||||
'author': self['author'],
|
||||
'hashes': self['hashes'],
|
||||
'body': self.htmlify(),
|
||||
}
|
||||
return obj
|
||||
|
||||
def secrets(self):
|
||||
obj = {
|
||||
'answers': self.answers,
|
||||
'summary': self.fields['summary'],
|
||||
'answers': self['answers'],
|
||||
'summary': self['summary'],
|
||||
}
|
||||
return obj
|
||||
|
||||
|
@ -78,10 +294,29 @@ if __name__ == '__main__':
|
|||
filename = os.path.basename(puzzlePath)
|
||||
points, ext = os.path.splitext(filename)
|
||||
points = int(points)
|
||||
puzzle = Puzzle(open(puzzlePath))
|
||||
puzzle = Puzzle(puzzlePath, "test")
|
||||
puzzles[points] = puzzle
|
||||
|
||||
for points in sorted(puzzles):
|
||||
puzzle = puzzles[points]
|
||||
print(puzzle.secrets())
|
||||
|
||||
|
||||
class Category:
|
||||
def __init__(self, path, seed):
|
||||
self.path = path
|
||||
self.seed = seed
|
||||
self.pointvals = []
|
||||
for fpath in glob.glob(os.path.join(path, "[0-9]*")):
|
||||
pn = os.path.basename(fpath)
|
||||
points = int(pn)
|
||||
self.pointvals.append(points)
|
||||
self.pointvals.sort()
|
||||
|
||||
def puzzle(self, points):
|
||||
path = os.path.join(self.path, str(points))
|
||||
return Puzzle(self.seed, path=path)
|
||||
|
||||
def puzzles(self):
|
||||
for points in self.pointvals:
|
||||
yield self.puzzle(points)
|
||||
|
|
Loading…
Reference in New Issue