From e8ec19e49642ced679a6b5bdcb7908ce91cd9ea8 Mon Sep 17 00:00:00 2001 From: "J. Patrick Avery, Jr" Date: Thu, 20 Oct 2016 17:07:34 -0600 Subject: [PATCH 01/13] puzzles: add in support for monolithic categories This also supports static or dynamic puzzles within a category following the earlier design strategy. Tested on a monolithic category, needs testing with a variety of category types. --- puzzles.py | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/puzzles.py b/puzzles.py index adb929f..d8a38c9 100644 --- a/puzzles.py +++ b/puzzles.py @@ -55,7 +55,7 @@ class Puzzle: 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): + def __init__(self, category_seed, path=None, points=None, category=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. @@ -83,11 +83,14 @@ class Puzzle: 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.") + assert any([ + points is None and path is not None, + points is not None and path is None, + points is not None and category is not None]), \ + "Either points or path must be set, but not both." self._dict = defaultdict(lambda: []) - if os.path.isdir(path): + if path is not None and os.path.isdir(path): self.puzzle_dir = path else: self.puzzle_dir = None @@ -131,6 +134,9 @@ class Puzzle: puzzle_mod.make(self) else: self.body = '# `puzzle.py` does not define a `make` function' + elif category is not None and points is not None: + category.make(self, points) + def cleanup(self): """Cleanup any outstanding temporary files.""" @@ -328,15 +334,36 @@ class Category: self.path = path self.seed = seed self.pointvals = [] + self.catmod = None + + try: + catmod = SourceFileLoader( + 'catmod', + os.path.join(path, 'category.py')).load_module() + assert all([ + hasattr(catmod, 'make'), + hasattr(catmod, 'points'), + type(catmod.points) is list, + ]) + self.catmod = catmod + self.pointvals.extend(catmod.points) + except: + pass + 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) + print("Category.puzzle! %r %r" % (points, self.catmod.points)) + if self.catmod is not None and points in self.catmod.points: + return Puzzle(self.seed, points=points, category=self.catmod) + else: + path = os.path.join(self.path, str(points)) + return Puzzle(self.seed, path=path) def puzzles(self): for points in self.pointvals: From 8ddb3ae9917a0d9ece7121d70b61adbaaeaafcc4 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 21 Oct 2016 20:38:19 +0000 Subject: [PATCH 02/13] Github wants docs/, not doc/ --- README.md | 10 +++++++--- {doc => docs}/CREDITS.md | 0 {doc => docs}/LICENSE.md | 0 {doc => docs}/devel-server.md | 0 {doc => docs}/dirtbags.svg | 0 {doc => docs}/overview.md | 0 {doc => docs}/tokens.md | 0 {doc => docs}/writing-puzzles.md | 0 tools/devel-server.py | 4 ++-- 9 files changed, 9 insertions(+), 5 deletions(-) rename {doc => docs}/CREDITS.md (100%) rename {doc => docs}/LICENSE.md (100%) rename {doc => docs}/devel-server.md (100%) rename {doc => docs}/dirtbags.svg (100%) rename {doc => docs}/overview.md (100%) rename {doc => docs}/tokens.md (100%) rename {doc => docs}/writing-puzzles.md (100%) diff --git a/README.md b/README.md index 766f78c..c167201 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,14 @@ which in the past has been called "Project 2", "HACK", "Queen Of The Hill", -and "Cyber FIRE". +"Cyber Spark", +and "Cyber Fire". Information about these events is at http://dirtbags.net/contest/ 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. @@ -21,7 +22,7 @@ How everything works --------------------------- 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. @@ -34,6 +35,9 @@ Getting Started Developing Then point a web browser at http://localhost:8080/ 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 ==================== diff --git a/doc/CREDITS.md b/docs/CREDITS.md similarity index 100% rename from doc/CREDITS.md rename to docs/CREDITS.md diff --git a/doc/LICENSE.md b/docs/LICENSE.md similarity index 100% rename from doc/LICENSE.md rename to docs/LICENSE.md diff --git a/doc/devel-server.md b/docs/devel-server.md similarity index 100% rename from doc/devel-server.md rename to docs/devel-server.md diff --git a/doc/dirtbags.svg b/docs/dirtbags.svg similarity index 100% rename from doc/dirtbags.svg rename to docs/dirtbags.svg diff --git a/doc/overview.md b/docs/overview.md similarity index 100% rename from doc/overview.md rename to docs/overview.md diff --git a/doc/tokens.md b/docs/tokens.md similarity index 100% rename from doc/tokens.md rename to docs/tokens.md diff --git a/doc/writing-puzzles.md b/docs/writing-puzzles.md similarity index 100% rename from doc/writing-puzzles.md rename to docs/writing-puzzles.md diff --git a/tools/devel-server.py b/tools/devel-server.py index 8befebc..7fd8f33 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -90,8 +90,8 @@ There's stuff you can do here: * [Available puzzles](/puzzles) * [Raw filesystem view](/files/) -* [Documentation](/files/doc/) -* [Instructions](/files/doc/devel-server.md) for using this server +* [Documentation](/files/docs/) +* [Instructions](/files/docs/devel-server.md) for using this server If you use this development server to run a contest, you are a fool. From 3a8d90f96da55b4200294725528982ba51749cce Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sat, 22 Oct 2016 16:35:55 +0000 Subject: [PATCH 03/13] First pass cleanup, still broken --- package-puzzles | 11 +- tools/devel-server.py | 241 +++++++++++++++--------------- tools/moth.py | 340 ++++++++++++------------------------------ 3 files changed, 221 insertions(+), 371 deletions(-) diff --git a/package-puzzles b/package-puzzles index 344d85a..29a2c08 100755 --- a/package-puzzles +++ b/package-puzzles @@ -95,7 +95,16 @@ if __name__ == '__main__': for points in sorted(puzzles_dict): puzzle = puzzles_dict[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 assoc_files = [] diff --git a/tools/devel-server.py b/tools/devel-server.py index 7fd8f33..770f5f2 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 -import cgi import glob +import html import http.server +import io import mistune import moth import os import pathlib +import shutil import socketserver import sys import traceback @@ -15,8 +17,9 @@ try: from http.server import HTTPStatus except ImportError: class HTTPStatus: - NOT_FOUND = 404 - OK = 200 + OK = (200, 'OK', 'Request fulfilled, document follows') + 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. seed = 1 @@ -58,12 +61,14 @@ class MothHandler(http.server.SimpleHTTPRequestHandler): 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) - + payload = ("Traceback (most recent call last)\n" + + "".join(tblist[:-1]) + + tblist[-1]).encode('utf-8') + 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): if self.path == "/": @@ -71,7 +76,7 @@ class MothHandler(http.server.SimpleHTTPRequestHandler): elif self.path.startswith("/puzzles"): self.serve_puzzles() elif self.path.startswith("/files"): - self.serve_file() + self.serve_file(self.translate_path(self.path)) else: self.send_error(HTTPStatus.NOT_FOUND, "File not found") @@ -81,7 +86,7 @@ class MothHandler(http.server.SimpleHTTPRequestHandler): return super().translate_path(path) def serve_front(self): - page = """ + body = """ MOTH Development Server Front Page ==================== @@ -96,127 +101,117 @@ There's stuff you can do here: If you use this development server to run a contest, 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): - body = [] + body = io.StringIO() 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)) - self.serve_md('\n'.join(body)) - return + title = None + cat = None - 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: - 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)) - - def serve_puzzle_logs(self, logs): - """Serve a PuzzleFile object.""" - self.send_response(HTTPStatus.OK) - self.send_header("Content-type", "text/plain; charset=utf-8") - self.end_headers() - for log in logs: - self.wfile.write(log.encode('ascii')) - self.wfile.write(b"\n") - - 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() - else: - super().do_GET() - - def serve_md(self, text=None): - fspathstr = self.translate_path(self.path) - fspath = pathlib.Path(fspathstr) - if not text: - try: - text = fspath.read_text() - except OSError: - self.send_error(HTTPStatus.NOT_FOUND, "File not found") - return None - content = mdpage(text) - - 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() - self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) + fpath = os.path.join("puzzles", parts[2]) + cat = moth.Category(path, seed) + puzzle = cat.puzzle(int(parts[3])) except: pass + + if not cat: + title = "Puzzle Categories" + body.write("
    ") + for i in glob.glob(os.path.join("puzzles", "*", "")): + body.write('
  • {}
  • '.format(i, i)) + body.write("
") + elif not puzzle: + # List all point values in a category + title = "Puzzles in category `{}`".format(parts[2]) + body.write("
    ") + for points in cat.pointvals: + body.write('
  • puzzles/{cat}{points}
  • '.format(cat=parts[2], points=points)) + body.write("
") + if len(parts) == 4: + # Serve up a puzzle + title = "{} puzzle {}".format(parts[2], parts[3]) + body.write("

Author

{}

".format(puzzle.author)) + body.write("

Summary

{}

".format(puzzle.summary)) + body.write("

Body

") + body.write(puzzle.html_body()) + body.write("

Answers

") + body.write("
    ") + for a in puzzle.answers: + body.write("
  • {}
  • ".format(html.escape(a))) + body.write("
") + body.write("

Files

") + body.write("
    ") + for f in puzzle.files: + body.write('
  • {filename}
  • ' + .format(cat=parts[2], points=puzzle.points, filename=f.name)) + body.write("
") + body.write("

Debug Log

") + body.write('
    ') + for l in puzzle.logs: + body.write("
  • {}
  • ".format(html.escape(l))) + body.write("
") + elif len(parts) == 5: + # Serve up a puzzle file + try: + pfile = puzzle.files[parts[4]] + except KeyError: + self.send_error(HTTPStatus.NOT_FOUND, "File not found") + return + ctype = self.guess_type(pfile.name) + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", ctype) + self.end_headers() + shutil.copyfileobj(pfile.stream, self.wfile) + return + + payload = page(title, body.getvalue()).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(content.encode('utf-8')) + self.wfile.write(payload) + + def serve_file(self, path): + lastmod = None + 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: + ctype = self.guess_type(path) + try: + payload = fspath.read_bytes() + except OSError: + self.send_error(HTTPStatus.NOT_FOUND, "File not found") + return + if fspath.endswith(".md"): + ctype = "text/html; charset=utf-8" + content = mdpage(payload.decode('utf-8')) + payload = content.encode('utf-8') + try: + fs = fspath.stat() + lastmod = self.date_time_string(fs.st_mtime) + except: + 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.wfile.write(payload) def run(address=('localhost', 8080)): diff --git a/tools/moth.py b/tools/moth.py index adb929f..3b4e8bd 100644 --- a/tools/moth.py +++ b/tools/moth.py @@ -1,10 +1,11 @@ #!/usr/bin/python3 import argparse -from collections import defaultdict, namedtuple +import contextlib import glob import hashlib -from importlib.machinery import SourceFileLoader +import io +import importlib.machinery import mistune import os import random @@ -18,10 +19,22 @@ def djb2hash(buf): h = ((h * 33) + c) & 0xffffffff return h -# 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. +@contextlib.contextmanager +def pushd(newdir): + curdir = os.getcwd() + 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 and there is no original input. handle: A File-like object set to read the file from. You should be able to read straight @@ -31,179 +44,92 @@ PuzzleFile.__doc__ = """A file associated with a puzzle. the file is still expected to be accessible, but it's path must be known (or figured out) to retrieve it.""" + def __init__(self, stream, name, visible=True): + self.stream = stream + self.name = name + self.visible = visible + 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): + def __init__(self, category_seed, points): """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. + :param points: The point value of the puzzle. """ 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) - - self._logs = [] - - 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 + self.points = points + self.author = None + self.summary = None + self.answers = [] + self.files = {} + self.body = io.StringIO() + self.logs = [] + self.randseed = category_seed * self.points + self.rand = random.Random(self.randseed) def log(self, msg): """Add a new log message to this puzzle.""" - self._logs.append(msg) + self.logs.append(msg) - @property - 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 = [] + def read_stream(self, stream): header = True for line in stream: if header: line = line.strip() - if not line.strip(): + if not line: header = False continue key, val = line.split(':', 1) - val = val.strip() - self[key] = val + key = key.lower() + 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: + raise ValueError("Unrecognized header field: {}".format(key)) else: - body.append(line) - self.body = ''.join(body) + 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): - """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) + """Create a file basename (no extension) with our number generator.""" + return ''.join(self.random.choice(string.ascii_lowercase) for i in range(8)) 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. @@ -213,64 +139,9 @@ class Puzzle: 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': - # 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()] + stream = tempfile.TemporaryFile() + self.files[name] = PuzzleFile(stream, name, visible) + return stream def make_answer(self, word_count, sep=' '): """Generate and return a new answer. It's automatically added to the puzzle answer list. @@ -280,47 +151,20 @@ class Puzzle: """ answer = sep.join(self.rand.sample(self.ANSWER_WORDS, word_count)) - self['answer'] = answer - + self.answers.append(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.""" - return mistune.markdown(self.body) + return mistune.markdown(self.get_body()) - def publish(self): - obj = { - 'author': self['author'], - 'hashes': self['hashes'], - 'body': self.htmlify(), - } - return obj + def hashes(self): + "Return a list of answer hashes" - def secrets(self): - 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()) + return [djbhash(a) for a in self.answers] class Category: @@ -335,8 +179,10 @@ class Category: self.pointvals.sort() def puzzle(self, points): + puzzle = Puzzle(self.seed, points) path = os.path.join(self.path, str(points)) - return Puzzle(self.seed, path) + puzzle.read_directory(path) + return puzzle def puzzles(self): for points in self.pointvals: From 0264c3c525c537cfbed52dc5e553582c3b92fe75 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sun, 23 Oct 2016 20:55:35 +0000 Subject: [PATCH 04/13] further debugging; now appears to work properly --- tools/devel-server.py | 33 ++++++++++++++++++++------------- tools/moth.py | 13 ++++++++----- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/tools/devel-server.py b/tools/devel-server.py index 770f5f2..fbf9d46 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -113,15 +113,22 @@ you are a fool. path = self.path.rstrip('/') parts = path.split("/") title = None + fpath = None + points = None cat = None + puzzle = None try: fpath = os.path.join("puzzles", parts[2]) - cat = moth.Category(path, seed) - puzzle = cat.puzzle(int(parts[3])) + points = int(parts[3]) except: pass + if fpath: + cat = moth.Category(fpath, seed) + if points: + puzzle = cat.puzzle(int(parts[3])) + if not cat: title = "Puzzle Categories" body.write("
    ") @@ -133,26 +140,26 @@ you are a fool. title = "Puzzles in category `{}`".format(parts[2]) body.write("") - if len(parts) == 4: + elif len(parts) == 4: # Serve up a puzzle title = "{} puzzle {}".format(parts[2], parts[3]) - body.write("

    Author

    {}

    ".format(puzzle.author)) - body.write("

    Summary

    {}

    ".format(puzzle.summary)) body.write("

    Body

    ") body.write(puzzle.html_body()) + body.write("

    Files

    ") + body.write("
      ") + for name in puzzle.files: + body.write('
    • {filename}
    • ' + .format(cat=parts[2], points=puzzle.points, filename=name)) + body.write("
    ") body.write("

    Answers

    ") body.write("
      ") for a in puzzle.answers: body.write("
    • {}
    • ".format(html.escape(a))) body.write("
    ") - body.write("

    Files

    ") - body.write("
      ") - for f in puzzle.files: - body.write('
    • {filename}
    • ' - .format(cat=parts[2], points=puzzle.points, filename=f.name)) - body.write("
    ") + body.write("

    Author

    {}

    ".format(puzzle.author)) + body.write("

    Summary

    {}

    ".format(puzzle.summary)) body.write("

    Debug Log

    ") body.write('
      ') for l in puzzle.logs: @@ -195,7 +202,7 @@ you are a fool. except OSError: self.send_error(HTTPStatus.NOT_FOUND, "File not found") return - if fspath.endswith(".md"): + if path.endswith(".md"): ctype = "text/html; charset=utf-8" content = mdpage(payload.decode('utf-8')) payload = content.encode('utf-8') diff --git a/tools/moth.py b/tools/moth.py index 3b4e8bd..bad3349 100644 --- a/tools/moth.py +++ b/tools/moth.py @@ -9,6 +9,7 @@ import importlib.machinery import mistune import os import random +import string import tempfile messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' @@ -124,7 +125,7 @@ class Puzzle: def random_hash(self): """Create a file basename (no extension) with our number generator.""" - return ''.join(self.random.choice(string.ascii_lowercase) for i in range(8)) + return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8)) def make_temp_file(self, name=None, visible=True): """Get a file object for adding dynamically generated data to the puzzle. When you're @@ -136,12 +137,14 @@ class Puzzle: :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: name = self.random_hash() - - stream = tempfile.TemporaryFile() self.files[name] = PuzzleFile(stream, name, visible) - return stream def make_answer(self, word_count, sep=' '): """Generate and return a new answer. It's automatically added to the puzzle answer list. @@ -150,7 +153,7 @@ class Puzzle: :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.answers.append(answer) return answer From d48c4defd421463df809ba1f712b9bdf36f4f33f Mon Sep 17 00:00:00 2001 From: "J. Patrick Avery, Jr" Date: Mon, 24 Oct 2016 13:46:42 -0600 Subject: [PATCH 05/13] tools/moth: remove errant debug statement --- tools/moth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/moth.py b/tools/moth.py index d8a38c9..ee45635 100644 --- a/tools/moth.py +++ b/tools/moth.py @@ -358,7 +358,6 @@ class Category: self.pointvals.sort() def puzzle(self, points): - print("Category.puzzle! %r %r" % (points, self.catmod.points)) if self.catmod is not None and points in self.catmod.points: return Puzzle(self.seed, points=points, category=self.catmod) else: From b3693eb5cb42a8d30cb93bd475b1515a603bb3fb Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 25 Oct 2016 02:51:39 +0000 Subject: [PATCH 06/13] Correct puzzle file link --- tools/devel-server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/devel-server.py b/tools/devel-server.py index fbf9d46..ccffe66 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -150,7 +150,7 @@ you are a fool. body.write("

      Files

      ") body.write("
        ") for name in puzzle.files: - body.write('
      • {filename}
      • ' + body.write('
      • {filename}
      • ' .format(cat=parts[2], points=puzzle.points, filename=name)) body.write("
      ") body.write("

      Answers

      ") From e93197fc916d0f2575e5c71baecc8d93e99ebaac Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 21 Oct 2016 20:38:19 +0000 Subject: [PATCH 07/13] Github wants docs/, not doc/ --- README.md | 10 +++++++--- {doc => docs}/CREDITS.md | 0 {doc => docs}/LICENSE.md | 0 {doc => docs}/devel-server.md | 0 {doc => docs}/dirtbags.svg | 0 {doc => docs}/overview.md | 0 {doc => docs}/tokens.md | 0 {doc => docs}/writing-puzzles.md | 0 tools/devel-server.py | 4 ++-- 9 files changed, 9 insertions(+), 5 deletions(-) rename {doc => docs}/CREDITS.md (100%) rename {doc => docs}/LICENSE.md (100%) rename {doc => docs}/devel-server.md (100%) rename {doc => docs}/dirtbags.svg (100%) rename {doc => docs}/overview.md (100%) rename {doc => docs}/tokens.md (100%) rename {doc => docs}/writing-puzzles.md (100%) diff --git a/README.md b/README.md index 766f78c..c167201 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,14 @@ which in the past has been called "Project 2", "HACK", "Queen Of The Hill", -and "Cyber FIRE". +"Cyber Spark", +and "Cyber Fire". Information about these events is at http://dirtbags.net/contest/ 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. @@ -21,7 +22,7 @@ How everything works --------------------------- 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. @@ -34,6 +35,9 @@ Getting Started Developing Then point a web browser at http://localhost:8080/ 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 ==================== diff --git a/doc/CREDITS.md b/docs/CREDITS.md similarity index 100% rename from doc/CREDITS.md rename to docs/CREDITS.md diff --git a/doc/LICENSE.md b/docs/LICENSE.md similarity index 100% rename from doc/LICENSE.md rename to docs/LICENSE.md diff --git a/doc/devel-server.md b/docs/devel-server.md similarity index 100% rename from doc/devel-server.md rename to docs/devel-server.md diff --git a/doc/dirtbags.svg b/docs/dirtbags.svg similarity index 100% rename from doc/dirtbags.svg rename to docs/dirtbags.svg diff --git a/doc/overview.md b/docs/overview.md similarity index 100% rename from doc/overview.md rename to docs/overview.md diff --git a/doc/tokens.md b/docs/tokens.md similarity index 100% rename from doc/tokens.md rename to docs/tokens.md diff --git a/doc/writing-puzzles.md b/docs/writing-puzzles.md similarity index 100% rename from doc/writing-puzzles.md rename to docs/writing-puzzles.md diff --git a/tools/devel-server.py b/tools/devel-server.py index 8befebc..7fd8f33 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -90,8 +90,8 @@ There's stuff you can do here: * [Available puzzles](/puzzles) * [Raw filesystem view](/files/) -* [Documentation](/files/doc/) -* [Instructions](/files/doc/devel-server.md) for using this server +* [Documentation](/files/docs/) +* [Instructions](/files/docs/devel-server.md) for using this server If you use this development server to run a contest, you are a fool. From e4b30617a6d00b72273e382d5fca5eb477fdcbb6 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sat, 22 Oct 2016 16:35:55 +0000 Subject: [PATCH 08/13] First pass cleanup, still broken --- package-puzzles | 11 +- tools/devel-server.py | 241 ++++++++++++++-------------- tools/moth.py | 353 ++++++++++++------------------------------ 3 files changed, 224 insertions(+), 381 deletions(-) diff --git a/package-puzzles b/package-puzzles index bf0eb71..f730a17 100755 --- a/package-puzzles +++ b/package-puzzles @@ -95,7 +95,16 @@ if __name__ == '__main__': for points in sorted(puzzles_dict): puzzle = puzzles_dict[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 assoc_files = [] diff --git a/tools/devel-server.py b/tools/devel-server.py index 7fd8f33..770f5f2 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 -import cgi import glob +import html import http.server +import io import mistune import moth import os import pathlib +import shutil import socketserver import sys import traceback @@ -15,8 +17,9 @@ try: from http.server import HTTPStatus except ImportError: class HTTPStatus: - NOT_FOUND = 404 - OK = 200 + OK = (200, 'OK', 'Request fulfilled, document follows') + 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. seed = 1 @@ -58,12 +61,14 @@ class MothHandler(http.server.SimpleHTTPRequestHandler): 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) - + payload = ("Traceback (most recent call last)\n" + + "".join(tblist[:-1]) + + tblist[-1]).encode('utf-8') + 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): if self.path == "/": @@ -71,7 +76,7 @@ class MothHandler(http.server.SimpleHTTPRequestHandler): elif self.path.startswith("/puzzles"): self.serve_puzzles() elif self.path.startswith("/files"): - self.serve_file() + self.serve_file(self.translate_path(self.path)) else: self.send_error(HTTPStatus.NOT_FOUND, "File not found") @@ -81,7 +86,7 @@ class MothHandler(http.server.SimpleHTTPRequestHandler): return super().translate_path(path) def serve_front(self): - page = """ + body = """ MOTH Development Server Front Page ==================== @@ -96,127 +101,117 @@ There's stuff you can do here: If you use this development server to run a contest, 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): - body = [] + body = io.StringIO() 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)) - self.serve_md('\n'.join(body)) - return + title = None + cat = None - 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: - 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)) - - def serve_puzzle_logs(self, logs): - """Serve a PuzzleFile object.""" - self.send_response(HTTPStatus.OK) - self.send_header("Content-type", "text/plain; charset=utf-8") - self.end_headers() - for log in logs: - self.wfile.write(log.encode('ascii')) - self.wfile.write(b"\n") - - 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() - else: - super().do_GET() - - def serve_md(self, text=None): - fspathstr = self.translate_path(self.path) - fspath = pathlib.Path(fspathstr) - if not text: - try: - text = fspath.read_text() - except OSError: - self.send_error(HTTPStatus.NOT_FOUND, "File not found") - return None - content = mdpage(text) - - 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() - self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) + fpath = os.path.join("puzzles", parts[2]) + cat = moth.Category(path, seed) + puzzle = cat.puzzle(int(parts[3])) except: pass + + if not cat: + title = "Puzzle Categories" + body.write("
        ") + for i in glob.glob(os.path.join("puzzles", "*", "")): + body.write('
      • {}
      • '.format(i, i)) + body.write("
      ") + elif not puzzle: + # List all point values in a category + title = "Puzzles in category `{}`".format(parts[2]) + body.write("
        ") + for points in cat.pointvals: + body.write('
      • puzzles/{cat}{points}
      • '.format(cat=parts[2], points=points)) + body.write("
      ") + if len(parts) == 4: + # Serve up a puzzle + title = "{} puzzle {}".format(parts[2], parts[3]) + body.write("

      Author

      {}

      ".format(puzzle.author)) + body.write("

      Summary

      {}

      ".format(puzzle.summary)) + body.write("

      Body

      ") + body.write(puzzle.html_body()) + body.write("

      Answers

      ") + body.write("
        ") + for a in puzzle.answers: + body.write("
      • {}
      • ".format(html.escape(a))) + body.write("
      ") + body.write("

      Files

      ") + body.write("
        ") + for f in puzzle.files: + body.write('
      • {filename}
      • ' + .format(cat=parts[2], points=puzzle.points, filename=f.name)) + body.write("
      ") + body.write("

      Debug Log

      ") + body.write('
        ') + for l in puzzle.logs: + body.write("
      • {}
      • ".format(html.escape(l))) + body.write("
      ") + elif len(parts) == 5: + # Serve up a puzzle file + try: + pfile = puzzle.files[parts[4]] + except KeyError: + self.send_error(HTTPStatus.NOT_FOUND, "File not found") + return + ctype = self.guess_type(pfile.name) + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", ctype) + self.end_headers() + shutil.copyfileobj(pfile.stream, self.wfile) + return + + payload = page(title, body.getvalue()).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(content.encode('utf-8')) + self.wfile.write(payload) + + def serve_file(self, path): + lastmod = None + 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: + ctype = self.guess_type(path) + try: + payload = fspath.read_bytes() + except OSError: + self.send_error(HTTPStatus.NOT_FOUND, "File not found") + return + if fspath.endswith(".md"): + ctype = "text/html; charset=utf-8" + content = mdpage(payload.decode('utf-8')) + payload = content.encode('utf-8') + try: + fs = fspath.stat() + lastmod = self.date_time_string(fs.st_mtime) + except: + 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.wfile.write(payload) def run(address=('localhost', 8080)): diff --git a/tools/moth.py b/tools/moth.py index ee45635..bf9281a 100644 --- a/tools/moth.py +++ b/tools/moth.py @@ -1,10 +1,11 @@ #!/usr/bin/python3 import argparse -from collections import defaultdict, namedtuple +import contextlib import glob import hashlib -from importlib.machinery import SourceFileLoader +import io +import importlib.machinery import mistune import os import random @@ -18,10 +19,22 @@ def djb2hash(buf): h = ((h * 33) + c) & 0xffffffff return h -# 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. +@contextlib.contextmanager +def pushd(newdir): + curdir = os.getcwd() + 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 and there is no original input. handle: A File-like object set to read the file from. You should be able to read straight @@ -31,185 +44,92 @@ PuzzleFile.__doc__ = """A file associated with a puzzle. the file is still expected to be accessible, but it's path must be known (or figured out) to retrieve it.""" + def __init__(self, stream, name, visible=True): + self.stream = stream + self.name = name + self.visible = visible + 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, category=None): + def __init__(self, category_seed, points): """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. + :param points: The point value of the puzzle. """ super().__init__() - assert any([ - points is None and path is not None, - points is not None and path is None, - points is not None and category is not None]), \ - "Either points or path must be set, but not both." - - self._dict = defaultdict(lambda: []) - if path is not None and 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) - - self._logs = [] - - 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' - elif category is not None and points is not None: - category.make(self, points) - - - 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 + self.points = points + self.author = None + self.summary = None + self.answers = [] + self.files = {} + self.body = io.StringIO() + self.logs = [] + self.randseed = category_seed * self.points + self.rand = random.Random(self.randseed) def log(self, msg): """Add a new log message to this puzzle.""" - self._logs.append(msg) + self.logs.append(msg) - @property - 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 = [] + def read_stream(self, stream): header = True for line in stream: if header: line = line.strip() - if not line.strip(): + if not line: header = False continue key, val = line.split(':', 1) - val = val.strip() - self[key] = val + key = key.lower() + 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: + raise ValueError("Unrecognized header field: {}".format(key)) else: - body.append(line) - self.body = ''.join(body) + 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): - """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) + """Create a file basename (no extension) with our number generator.""" + return ''.join(self.random.choice(string.ascii_lowercase) for i in range(8)) 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. @@ -219,64 +139,9 @@ class Puzzle: 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': - # 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()] + stream = tempfile.TemporaryFile() + self.files[name] = PuzzleFile(stream, name, visible) + return stream def make_answer(self, word_count, sep=' '): """Generate and return a new answer. It's automatically added to the puzzle answer list. @@ -286,47 +151,20 @@ class Puzzle: """ answer = sep.join(self.rand.sample(self.ANSWER_WORDS, word_count)) - self['answer'] = answer - + self.answers.append(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.""" - return mistune.markdown(self.body) + return mistune.markdown(self.get_body()) - def publish(self): - obj = { - 'author': self['author'], - 'hashes': self['hashes'], - 'body': self.htmlify(), - } - return obj + def hashes(self): + "Return a list of answer hashes" - def secrets(self): - 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()) + return [djbhash(a) for a in self.answers] class Category: @@ -358,11 +196,12 @@ class Category: self.pointvals.sort() def puzzle(self, points): - if self.catmod is not None and points in self.catmod.points: - return Puzzle(self.seed, points=points, category=self.catmod) - else: - path = os.path.join(self.path, str(points)) - return Puzzle(self.seed, path=path) + puzzle = Puzzle(self.seed, points) + path = os.path.join(self.path, str(points)) + if self.catmod: + self.catmod.make(p, points) + puzzle.read_directory(path) + return puzzle def puzzles(self): for points in self.pointvals: From 99a3502617174d28c1742c066ac178d8c06845e6 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sun, 23 Oct 2016 20:55:35 +0000 Subject: [PATCH 09/13] further debugging; now appears to work properly --- tools/devel-server.py | 33 ++++++++++++++++++++------------- tools/moth.py | 13 ++++++++----- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/tools/devel-server.py b/tools/devel-server.py index 770f5f2..fbf9d46 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -113,15 +113,22 @@ you are a fool. path = self.path.rstrip('/') parts = path.split("/") title = None + fpath = None + points = None cat = None + puzzle = None try: fpath = os.path.join("puzzles", parts[2]) - cat = moth.Category(path, seed) - puzzle = cat.puzzle(int(parts[3])) + points = int(parts[3]) except: pass + if fpath: + cat = moth.Category(fpath, seed) + if points: + puzzle = cat.puzzle(int(parts[3])) + if not cat: title = "Puzzle Categories" body.write("
        ") @@ -133,26 +140,26 @@ you are a fool. title = "Puzzles in category `{}`".format(parts[2]) body.write("") - if len(parts) == 4: + elif len(parts) == 4: # Serve up a puzzle title = "{} puzzle {}".format(parts[2], parts[3]) - body.write("

        Author

        {}

        ".format(puzzle.author)) - body.write("

        Summary

        {}

        ".format(puzzle.summary)) body.write("

        Body

        ") body.write(puzzle.html_body()) + body.write("

        Files

        ") + body.write("
          ") + for name in puzzle.files: + body.write('
        • {filename}
        • ' + .format(cat=parts[2], points=puzzle.points, filename=name)) + body.write("
        ") body.write("

        Answers

        ") body.write("
          ") for a in puzzle.answers: body.write("
        • {}
        • ".format(html.escape(a))) body.write("
        ") - body.write("

        Files

        ") - body.write("
          ") - for f in puzzle.files: - body.write('
        • {filename}
        • ' - .format(cat=parts[2], points=puzzle.points, filename=f.name)) - body.write("
        ") + body.write("

        Author

        {}

        ".format(puzzle.author)) + body.write("

        Summary

        {}

        ".format(puzzle.summary)) body.write("

        Debug Log

        ") body.write('
          ') for l in puzzle.logs: @@ -195,7 +202,7 @@ you are a fool. except OSError: self.send_error(HTTPStatus.NOT_FOUND, "File not found") return - if fspath.endswith(".md"): + if path.endswith(".md"): ctype = "text/html; charset=utf-8" content = mdpage(payload.decode('utf-8')) payload = content.encode('utf-8') diff --git a/tools/moth.py b/tools/moth.py index bf9281a..6f23652 100644 --- a/tools/moth.py +++ b/tools/moth.py @@ -9,6 +9,7 @@ import importlib.machinery import mistune import os import random +import string import tempfile messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' @@ -124,7 +125,7 @@ class Puzzle: def random_hash(self): """Create a file basename (no extension) with our number generator.""" - return ''.join(self.random.choice(string.ascii_lowercase) for i in range(8)) + return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8)) def make_temp_file(self, name=None, visible=True): """Get a file object for adding dynamically generated data to the puzzle. When you're @@ -136,12 +137,14 @@ class Puzzle: :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: name = self.random_hash() - - stream = tempfile.TemporaryFile() self.files[name] = PuzzleFile(stream, name, visible) - return stream def make_answer(self, word_count, sep=' '): """Generate and return a new answer. It's automatically added to the puzzle answer list. @@ -150,7 +153,7 @@ class Puzzle: :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.answers.append(answer) return answer From 17fe6cc77986f1560ccde0f5446ed2d1d4324007 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 25 Oct 2016 02:51:39 +0000 Subject: [PATCH 10/13] Correct puzzle file link --- tools/devel-server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/devel-server.py b/tools/devel-server.py index fbf9d46..ccffe66 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -150,7 +150,7 @@ you are a fool. body.write("

          Files

          ") body.write("
            ") for name in puzzle.files: - body.write('
          • {filename}
          • ' + body.write('
          • {filename}
          • ' .format(cat=parts[2], points=puzzle.points, filename=name)) body.write("
          ") body.write("

          Answers

          ") From 5c5cb36530f563526461c30837ce9e5703c16fdb Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 25 Oct 2016 03:09:04 +0000 Subject: [PATCH 11/13] Make category-wide puzzles work --- tools/devel-server.py | 10 ++++++---- tools/moth.py | 29 +++++++++++------------------ 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/tools/devel-server.py b/tools/devel-server.py index ccffe66..42785c6 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -29,15 +29,16 @@ def page(title, body): return """ - {} + {title} +

          {title}

          - {} + {body}
          -""".format(title, body) +""".format(title=title, body=body) def mdpage(body): @@ -132,7 +133,7 @@ you are a fool. if not cat: title = "Puzzle Categories" body.write("
            ") - for i in glob.glob(os.path.join("puzzles", "*", "")): + for i in sorted(glob.glob(os.path.join("puzzles", "*", ""))): body.write('
          • {}
          • '.format(i, i)) body.write("
          ") elif not puzzle: @@ -155,6 +156,7 @@ you are a fool. body.write("
        ") body.write("

        Answers

        ") body.write("
          ") + assert puzzle.answers, 'No answers defined' for a in puzzle.answers: body.write("
        • {}
        • ".format(html.escape(a))) body.write("
        ") diff --git a/tools/moth.py b/tools/moth.py index 6f23652..7b2b526 100644 --- a/tools/moth.py +++ b/tools/moth.py @@ -177,24 +177,16 @@ class Category: self.pointvals = [] self.catmod = None - try: - catmod = SourceFileLoader( + if os.path.exists(os.path.join(path, 'category.py')): + self.catmod = importlib.machinery.SourceFileLoader( 'catmod', os.path.join(path, 'category.py')).load_module() - assert all([ - hasattr(catmod, 'make'), - hasattr(catmod, 'points'), - type(catmod.points) is list, - ]) - self.catmod = catmod - self.pointvals.extend(catmod.points) - except: - pass - - 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 = self.catmod.points[:] + else: + 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() @@ -202,8 +194,9 @@ class Category: puzzle = Puzzle(self.seed, points) path = os.path.join(self.path, str(points)) if self.catmod: - self.catmod.make(p, points) - puzzle.read_directory(path) + self.catmod.make(points, puzzle) + else: + puzzle.read_directory(path) return puzzle def puzzles(self): From 5128a728423b525dc51a7fe4cbb2accc926dae81 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 25 Oct 2016 03:49:32 +0000 Subject: [PATCH 12/13] chdir to category before calling catmod.make() --- tools/moth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/moth.py b/tools/moth.py index 7b2b526..663c33d 100644 --- a/tools/moth.py +++ b/tools/moth.py @@ -194,7 +194,8 @@ class Category: puzzle = Puzzle(self.seed, points) path = os.path.join(self.path, str(points)) if self.catmod: - self.catmod.make(points, puzzle) + with pushd(self.path): + self.catmod.make(points, puzzle) else: puzzle.read_directory(path) return puzzle From 1f1f41a696936c4c0da8f50fb49e97ece0d1cd0d Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 17 Nov 2016 14:24:01 -0700 Subject: [PATCH 13/13] Integrate suggestions from code review --- tools/devel-server.py | 2 +- tools/moth.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/devel-server.py b/tools/devel-server.py index 42785c6..9b925cd 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -128,7 +128,7 @@ you are a fool. if fpath: cat = moth.Category(fpath, seed) if points: - puzzle = cat.puzzle(int(parts[3])) + puzzle = cat.puzzle(points) if not cat: title = "Puzzle Categories" diff --git a/tools/moth.py b/tools/moth.py index 663c33d..25ee393 100644 --- a/tools/moth.py +++ b/tools/moth.py @@ -181,6 +181,7 @@ class Category: 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]*")):