mirror of https://github.com/dirtbags/moth.git
Merge branch 'pflarr_edits' of https://github.com/pflarr/moth into pflarr
This commit is contained in:
commit
b167ccccdd
|
@ -2,8 +2,9 @@
|
||||||
*#
|
*#
|
||||||
*.pyc
|
*.pyc
|
||||||
*.o
|
*.o
|
||||||
|
.idea
|
||||||
./bin/
|
./bin/
|
||||||
build/
|
build/
|
||||||
cache/
|
cache/
|
||||||
target/
|
target/
|
||||||
puzzles
|
puzzles
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -8,11 +8,9 @@ import pathlib
|
||||||
import puzzles
|
import puzzles
|
||||||
import socketserver
|
import socketserver
|
||||||
|
|
||||||
|
try:
|
||||||
#HTTPStatus = http.server.HTTPStatus
|
from http.server import HTTPStatus
|
||||||
if hasattr(http.server, 'HTTPStatus'):
|
except ImportError:
|
||||||
HTTPStatus = http.HTTPStatus
|
|
||||||
else:
|
|
||||||
class HTTPStatus:
|
class HTTPStatus:
|
||||||
NOT_FOUND = 404
|
NOT_FOUND = 404
|
||||||
OK = 200
|
OK = 200
|
||||||
|
@ -31,6 +29,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)
|
||||||
|
@ -44,6 +43,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 == "/":
|
||||||
|
@ -144,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]))
|
||||||
|
|
206
puzzles.py
206
puzzles.py
|
@ -1,29 +1,105 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
import hmac
|
|
||||||
import base64
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from collections import defaultdict, namedtuple
|
||||||
import glob
|
import glob
|
||||||
import json
|
import hashlib
|
||||||
import os
|
from importlib.machinery import SourceFileLoader
|
||||||
import mistune
|
import mistune
|
||||||
|
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:
|
# We use a named tuple rather than a full class, because any random name generation has
|
||||||
def __init__(self, stream):
|
# to be done with Puzzle's random number generator, and it's cleaner to not pass that around.
|
||||||
self.message = bytes(random.choice(messageChars) for i in range(20))
|
PuzzleFile = namedtuple('PuzzleFile', ['path', 'handle', 'name', 'visible'])
|
||||||
self.fields = {}
|
|
||||||
self.answers = []
|
|
||||||
self.hashes = []
|
|
||||||
|
|
||||||
|
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 = []
|
body = []
|
||||||
header = True
|
header = True
|
||||||
for line in stream:
|
for line in stream:
|
||||||
|
@ -35,34 +111,120 @@ class Puzzle:
|
||||||
key, val = line.split(':', 1)
|
key, val = line.split(':', 1)
|
||||||
key = key.lower()
|
key = key.lower()
|
||||||
val = val.strip()
|
val = val.strip()
|
||||||
self._add_field(key, val)
|
self[key] = val
|
||||||
else:
|
else:
|
||||||
body.append(line)
|
body.append(line)
|
||||||
self.body = ''.join(body)
|
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':
|
if key == 'answer':
|
||||||
h = djb2hash(val.encode('utf8'))
|
# Handle adding answers to the puzzle
|
||||||
self.answers.append(val)
|
self._dict['hashes'].append(djb2hash(value.encode('utf8')))
|
||||||
self.hashes.append(h)
|
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:
|
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):
|
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.fields['author'],
|
'author': self['author'],
|
||||||
'hashes': self.hashes,
|
'hashes': self['hashes'],
|
||||||
'body': self.htmlify(),
|
'body': self.htmlify(),
|
||||||
}
|
}
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def secrets(self):
|
def secrets(self):
|
||||||
obj = {
|
obj = {
|
||||||
'answers': self.answers,
|
'answers': self['answers'],
|
||||||
'summary': self.fields['summary'],
|
'summary': self['summary'],
|
||||||
}
|
}
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
@ -78,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(open(puzzlePath))
|
puzzle = Puzzle(puzzlePath, "test")
|
||||||
puzzles[points] = puzzle
|
puzzles[points] = puzzle
|
||||||
|
|
||||||
for points in sorted(puzzles):
|
for points in sorted(puzzles):
|
||||||
|
|
Loading…
Reference in New Issue