#!/usr/bin/python3 import argparse from collections import defaultdict, namedtuple import glob 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 # 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/.moth self['points'] = int(os.path.split(path)[-1].split('.')[0]) except (IndexError, ValueError): raise ValueError("Invalid puzzle config. " "Expected something like .moth") stream = open(path) self._read_config(stream) elif os.path.isdir(path): try: # Expected format is path/.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: if header: line = line.strip() if not line.strip(): header = False continue key, val = line.split(':', 1) key = key.lower() val = val.strip() self[key] = val else: body.append(line) self.body = ''.join(body) 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': # 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): return mistune.markdown(self.body) 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['author'], 'hashes': self['hashes'], 'body': self.htmlify(), } return obj 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())