Files now come with a file handle to the original file.

This commit is contained in:
Paul Ferrell 2016-10-17 19:58:51 -06:00
parent 5072b23838
commit f22d7d95fb
3 changed files with 4278 additions and 23 deletions

4096
answer_words.txt Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,37 +1,105 @@
#!/usr/bin/python3 #!/usr/bin/python3
import argparse import argparse
import base64 from collections import defaultdict, namedtuple
import glob import glob
import hmac import hashlib
import json from importlib.machinery import SourceFileLoader
import mistune import mistune
import multidict
import os import os
import random import random
import tempfile
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
def djb2hash(buf): def djb2hash(buf):
h = 5381 h = 5381
for c in buf: for c in buf:
h = ((h * 33) + c) & 0xffffffff h = ((h * 33) + c) & 0xffffffff
return h return h
class Puzzle(multidict.MultiDict): # 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'])
def __init__(self, seed): 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__() 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.message = bytes(random.choice(messageChars) for i in range(20))
self.body = '' self.body = ''
self.rand = random.Random(seed) 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")
@classmethod stream = open(path)
def from_stream(cls, stream): self._read_config(stream)
pzl = cls(None) 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 = [] body = []
header = True header = True
for line in stream: for line in stream:
@ -43,31 +111,119 @@ class Puzzle(multidict.MultiDict):
key, val = line.split(':', 1) key, val = line.split(':', 1)
key = key.lower() key = key.lower()
val = val.strip() val = val.strip()
pzl.add(key, val) self[key] = val
else: else:
body.append(line) body.append(line)
pzl.body = ''.join(body) self.body = ''.join(body)
return pzl
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.")
def add(self, key, value):
super().add(key, value)
if key == 'answer': if key == 'answer':
super().add(hash, djb2hash(value.encode('utf8'))) # 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:
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): def htmlify(self):
return mistune.markdown(self.body) 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 = { obj = {
'author': self['author'], 'author': self['author'],
'hashes': self.getall('hash'), 'hashes': self['hashes'],
'body': self.htmlify(), 'body': self.htmlify(),
} }
return obj return obj
def secrets(self): def secrets(self):
obj = { obj = {
'answers': self.getall('answer'), 'answers': self['answers'],
'summary': self['summary'], 'summary': self['summary'],
} }
return obj return obj
@ -84,7 +240,7 @@ if __name__ == '__main__':
filename = os.path.basename(puzzlePath) filename = os.path.basename(puzzlePath)
points, ext = os.path.splitext(filename) points, ext = os.path.splitext(filename)
points = int(points) points = int(points)
puzzle = Puzzle.from_stream(open(puzzlePath)) puzzle = Puzzle(puzzlePath, "test")
puzzles[points] = puzzle puzzles[points] = puzzle
for points in sorted(puzzles): for points in sorted(puzzles):