Merge branch 'pflarr'

This commit is contained in:
Neale Pickett 2016-10-18 02:09:32 +00:00
commit 891b3f733f
4 changed files with 4288 additions and 28 deletions

3
.gitignore vendored
View File

@ -2,8 +2,9 @@
*#
*.pyc
*.o
.idea
./bin/
build/
cache/
target/
puzzles
puzzles

4096
answer_words.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,11 +8,9 @@ import pathlib
import puzzles
import socketserver
#HTTPStatus = http.server.HTTPStatus
if hasattr(http.server, 'HTTPStatus'):
HTTPStatus = http.HTTPStatus
else:
try:
from http.server import HTTPStatus
except ImportError:
class HTTPStatus:
NOT_FOUND = 404
OK = 200
@ -31,6 +29,7 @@ def page(title, body):
</body>
</html>""".format(title, body)
def mdpage(body):
try:
title, _ = body.split('\n', 1)
@ -44,6 +43,7 @@ def mdpage(body):
class ThreadingServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
pass
class MothHandler(http.server.CGIHTTPRequestHandler):
def do_GET(self):
if self.path == "/":
@ -144,6 +144,7 @@ you are a fool.
self.end_headers()
self.wfile.write(content.encode('utf-8'))
def run(address=('', 8080)):
httpd = ThreadingServer(address, MothHandler)
print("=== Listening on http://{}:{}/".format(address[0] or "localhost", address[1]))

View File

@ -1,29 +1,105 @@
#!/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'
def djb2hash(buf):
h = 5381
for c in 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'])
class Puzzle:
KNOWN_KEYS = [
'file',
'resource',
'temp_file',
'answer',
'points',
'author',
'summary'
]
REQUIRED_KEYS = [
'author',
'answer',
'points'
]
SINGULAR_KEYS = [
'points'
]
# 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, path, category_seed):
super().__init__()
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 = ''
if not os.path.exists(path):
raise ValueError("No puzzle at path: {]".format(path))
elif os.path.isfile(path):
try:
# Expected format is path/<points_int>.moth
self['points'] = int(os.path.split(path)[-1].split('.')[0])
except (IndexError, ValueError):
raise ValueError("Invalid puzzle config. "
"Expected something like <point_value>.moth")
stream = open(path)
self._read_config(stream)
elif os.path.isdir(path):
try:
# Expected format is path/<points_int>.moth
self['points'] = int(os.path.split(path)[-1])
except (IndexError, ValueError):
raise ValueError("Invalid puzzle config. Expected an integer point value for a "
"directory name.")
files = os.listdir(path)
if 'config.moth' in files:
self._read_config(open(os.path.join(path, 'config.moth')))
if 'make.py' in files:
# Good Lord this is dangerous as fuck.
loader = SourceFileLoader('puzzle_mod', os.path.join(path, 'make.py'))
puzzle_mod = loader.load_module()
if hasattr(puzzle_mod, 'make'):
puzzle_mod.make(self)
else:
raise ValueError("Unacceptable file type for puzzle at {}".format(path))
self._seed = hashlib.sha1(category_seed + bytes(self['points'])).digest()
self.rand = random.Random(self._seed)
# Set our 'files' as a dict, since we want register them uniquely by name.
self['files'] = dict()
def _read_config(self, stream):
"""Read a configuration file (ISO 2822)"""
body = []
header = True
for line in stream:
@ -35,34 +111,120 @@ class Puzzle:
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.
: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.")
file = open(path, 'rb')
return PuzzleFile(path=path, handle=file, name=name, visible=visible)
def make_file(self, name=None, mode='rw+b'):
"""Get a file object for adding dynamically generated data to the puzzle.
:param name: The name of the file for links within the puzzle. If this is None,
the file will be hidden with a random hash as the name.
:return: A file object for writing
"""
file = tempfile.TemporaryFile(mode=mode, delete=False)
self._dict['files'].append(self._puzzle_file(file.name, name))
return file
def __setitem__(self, key, value):
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['hashes'].append(djb2hash(value.encode('utf8')))
self._dict['answers'].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]
def make_answer(self, word_count, sep=b' '):
"""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 bytes
"""
if type(sep) == str:
sep = sep.encode('ascii')
answer = sep.join(self.rand.sample(self.ANSWER_WORDS, word_count))
self['answer'] = answer
return answer
def htmlify(self):
return mistune.markdown(self.body)
def publish(self):
def publish(self, dest):
"""Deploy the puzzle to the given directory, and return the info needed for describing
the puzzle and accepting answers in MOTH."""
if not os.path.exists(dest):
raise ValueError("Puzzle destination directory does not exist.")
# Delete the original directory
# Save puzzle html file
# Copy over all the files.
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,7 +240,7 @@ 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):