Merge remote-tracking branch 'origin/v3.4_devel' into neale

This commit is contained in:
Neale Pickett 2019-11-12 19:10:52 +00:00
commit 78802261c9
9 changed files with 97 additions and 174 deletions

15
CHANGELOG.md Normal file
View File

@ -0,0 +1,15 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- A changelog
- Support for embedding Python libraries at the category or puzzle level
- Embedded graph in scoreboard
- Optional tracking of participant IDs
- New `notices.html` file for sending broadcast messages to players
### Changed
- Use native JS URL objects instead of wrangling everything by hand

View File

@ -1 +1 @@
3.3.2 3.4_rc1

View File

@ -266,12 +266,24 @@ if __name__ == '__main__':
'--base', default="", '--base', default="",
help="Base URL to this server, for reverse proxy setup" help="Base URL to this server, for reverse proxy setup"
) )
parser.add_argument(
"-v", "--verbose",
action="count",
default=1, # Leave at 1, for now, to maintain current default behavior
help="Include more verbose logging. Use multiple flags to increase level",
)
args = parser.parse_args() args = parser.parse_args()
parts = args.bind.split(":") parts = args.bind.split(":")
addr = parts[0] or "0.0.0.0" addr = parts[0] or "0.0.0.0"
port = int(parts[1]) port = int(parts[1])
if args.verbose >= 2:
log_level = logging.DEBUG
elif args.verbose == 1:
log_level = logging.INFO
else:
log_level = logging.WARNING
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=log_level)
server = MothServer((addr, port), MothRequestHandler) server = MothServer((addr, port), MothRequestHandler)
server.args["base_url"] = args.base server.args["base_url"] = args.base

View File

@ -2,21 +2,26 @@
import argparse import argparse
import contextlib import contextlib
import copy
import glob import glob
import hashlib import hashlib
import html import html
import io import io
import importlib.machinery import importlib.machinery
import logging
import mistune import mistune
import os import os
import random import random
import string import string
import sys
import tempfile import tempfile
import shlex import shlex
import yaml import yaml
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
LOGGER = logging.getLogger(__name__)
def djb2hash(str): def djb2hash(str):
h = 5381 h = 5381
for c in str.encode("utf-8"): for c in str.encode("utf-8"):
@ -26,10 +31,28 @@ def djb2hash(str):
@contextlib.contextmanager @contextlib.contextmanager
def pushd(newdir): def pushd(newdir):
curdir = os.getcwd() curdir = os.getcwd()
LOGGER.debug("Attempting to chdir from %s to %s" % (curdir, newdir))
os.chdir(newdir) os.chdir(newdir)
# Force a copy of the old path, instead of just a reference
old_path = list(sys.path)
old_modules = copy.copy(sys.modules)
sys.path.append(newdir)
try: try:
yield yield
finally: finally:
# Restore the old path
to_remove = []
for module in sys.modules:
if module not in old_modules:
to_remove.append(module)
for module in to_remove:
del(sys.modules[module])
sys.path = old_path
LOGGER.debug("Changing directory back from %s to %s" % (newdir, curdir))
os.chdir(curdir) os.chdir(curdir)
@ -136,8 +159,10 @@ class Puzzle:
stream.seek(0) stream.seek(0)
if header == "yaml": if header == "yaml":
LOGGER.info("Puzzle is YAML-formatted")
self.read_yaml_header(stream) self.read_yaml_header(stream)
elif header == "moth": elif header == "moth":
LOGGER.info("Puzzle is MOTH-formatted")
self.read_moth_header(stream) self.read_moth_header(stream)
for line in stream: for line in stream:
@ -172,6 +197,7 @@ class Puzzle:
self.handle_header_key(key, val) self.handle_header_key(key, val)
def handle_header_key(self, key, val): def handle_header_key(self, key, val):
LOGGER.debug("Handling key: %s, value: %s", key, val)
if key == 'author': if key == 'author':
self.authors.append(val) self.authors.append(val)
elif key == 'authors': elif key == 'authors':
@ -199,6 +225,7 @@ class Puzzle:
parts = shlex.split(val) parts = shlex.split(val)
name = parts[0] name = parts[0]
hidden = False hidden = False
LOGGER.debug("Attempting to open %s", os.path.abspath(name))
stream = open(name, 'rb') stream = open(name, 'rb')
try: try:
name = parts[1] name = parts[1]
@ -367,7 +394,7 @@ class Puzzle:
files = [fn for fn,f in self.files.items() if f.visible] files = [fn for fn,f in self.files.items() if f.visible]
return { return {
'authors': self.authors, 'authors': self.get_authors(),
'hashes': self.hashes(), 'hashes': self.hashes(),
'files': files, 'files': files,
'scripts': self.scripts, 'scripts': self.scripts,
@ -415,7 +442,8 @@ class Category:
with pushd(self.path): with pushd(self.path):
self.catmod.make(points, puzzle) self.catmod.make(points, puzzle)
else: else:
puzzle.read_directory(path) with pushd(self.path):
puzzle.read_directory(path)
return puzzle return puzzle
def __iter__(self): def __iter__(self):

View File

@ -1,169 +0,0 @@
#!/usr/bin/env python3
import argparse
import binascii
import glob
import hashlib
import io
import json
import logging
import moth
import os
import shutil
import string
import sys
import tempfile
import zipfile
def write_kv_pairs(ziphandle, filename, kv):
""" Write out a sorted map to file
:param ziphandle: a zipfile object
:param filename: The filename to write within the zipfile object
:param kv: the map to write out
:return:
"""
filehandle = io.StringIO()
for key in sorted(kv.keys()):
if type(kv[key]) == type([]):
for val in kv[key]:
filehandle.write("%s %s\n" % (key, val))
else:
filehandle.write("%s %s\n" % (key, kv[key]))
filehandle.seek(0)
ziphandle.writestr(filename, filehandle.read())
def escape(s):
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def generate_html(ziphandle, puzzle, puzzledir, category, points, authors, files):
html_content = io.StringIO()
file_content = io.StringIO()
if files:
file_content.write(
''' <section id="files">
<h2>Associated files:</h2>
<ul>
''')
for fn in files:
file_content.write(' <li><a href="{fn}">{efn}</a></li>\n'.format(fn=fn, efn=escape(fn)))
file_content.write(
''' </ul>
</section>
''')
scripts = ['<script src="{}"></script>'.format(s) for s in puzzle.scripts]
html_content.write(
'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width">
<title>{category} {points}</title>
<link rel="stylesheet" href="../../style.css">
{scripts}
</head>
<body>
<h1>{category} for {points} points</h1>
<section id="readme">
{body} </section>
{file_content} <section id="form">
<form id="puzzler" action="../../cgi-bin/puzzler.cgi" method="get" accept-charset="utf-8" autocomplete="off">
<input type="hidden" name="c" value="{category}">
<input type="hidden" name="p" value="{points}">
<div>Team hash:<input name="t" size="8"></div>
<div>Answer:<input name="a" id="answer" size="20"></div>
<input type="submit" value="submit">
</form>
</section>
<address>Puzzle by <span class="authors" data-handle="{authors}">{authors}</span></address>
</body>
</html>'''.format(
category=category,
points=points,
body=puzzle.html_body(),
file_content=file_content.getvalue(),
authors=', '.join(authors),
scripts='\n'.join(scripts),
)
)
ziphandle.writestr(os.path.join(puzzledir, 'index.html'), html_content.getvalue())
def build_category(categorydir, outdir):
zipfileraw = tempfile.NamedTemporaryFile(delete=False)
zf = zipfile.ZipFile(zipfileraw, 'x')
category_seed = binascii.b2a_hex(os.urandom(20))
puzzles_dict = {}
secrets = {}
categoryname = os.path.basename(categorydir.strip(os.sep))
seedfn = os.path.join("category_seed.txt")
zipfilename = os.path.join(outdir, "%s.mb" % categoryname)
logging.info("Building {} from {}".format(zipfilename, categorydir))
if os.path.exists(zipfilename):
# open and gather some state
existing = zipfile.ZipFile(zipfilename, 'r')
try:
category_seed = existing.open(seedfn).read().strip()
except:
pass
existing.close()
logging.debug("Using PRNG seed {}".format(category_seed))
zf.writestr(seedfn, category_seed)
cat = moth.Category(categorydir, category_seed)
mapping = {}
answers = {}
summary = {}
for puzzle in cat:
logging.info("Processing point value {}".format(puzzle.points))
hashmap = hashlib.sha1(category_seed)
hashmap.update(str(puzzle.points).encode('utf-8'))
puzzlehash = hashmap.hexdigest()
mapping[puzzle.points] = puzzlehash
answers[puzzle.points] = puzzle.answers
summary[puzzle.points] = puzzle.summary
puzzledir = os.path.join('content', puzzlehash)
files = []
for fn, f in puzzle.files.items():
if f.visible:
files.append(fn)
payload = f.stream.read()
zf.writestr(os.path.join(puzzledir, fn), payload)
puzzledict = {
'authors': puzzle.authors,
'hashes': puzzle.hashes(),
'files': files,
'body': puzzle.html_body(),
}
puzzlejson = json.dumps(puzzledict)
zf.writestr(os.path.join(puzzledir, 'puzzle.json'), puzzlejson)
generate_html(zf, puzzle, puzzledir, categoryname, puzzle.points, puzzle.get_authors(), files)
write_kv_pairs(zf, 'map.txt', mapping)
write_kv_pairs(zf, 'answers.txt', answers)
write_kv_pairs(zf, 'summaries.txt', summary)
# clean up
zf.close()
shutil.move(zipfileraw.name, zipfilename)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Build a category package')
parser.add_argument('outdir', help='Output directory')
parser.add_argument('categorydirs', nargs='+', help='Directory of category source')
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG)
for categorydir in args.categorydirs:
build_category(categorydir, args.outdir)

View File

@ -0,0 +1,19 @@
import io
import categorylib # Category-level libraries can be imported here
def make(puzzle):
import puzzlelib # puzzle-level libraries can only be imported inside of the make function
puzzle.authors = ['donaldson']
puzzle.summary = 'more crazy stuff you can do with puzzle generation using Python libraries'
puzzle.body.write("## Crazy Things You Can Do With Puzzle Generation (part II)\n")
puzzle.body.write("\n")
puzzle.body.write("The source to this puzzle has some more advanced examples of stuff you can do in Python.\n")
puzzle.body.write("\n")
puzzle.body.write("1 == %s\n\n" % puzzlelib.getone(),)
puzzle.body.write("2 == %s\n\n" % categorylib.gettwo(),)
puzzle.answers.append('tea')
answer = puzzle.make_answer() # Generates a random answer, appending it to puzzle.answers too
puzzle.log("Answers: {}".format(puzzle.answers))

View File

@ -0,0 +1,7 @@
"""This is an example of a puzzle-level library.
This library can be imported by sibling puzzles using `import puzzlelib`
"""
def getone():
return 1

View File

@ -0,0 +1,7 @@
"""This is an example of a category-level library.
This library can be imported by child puzzles using `import categorylib`
"""
def gettwo():
return 2

View File

@ -69,7 +69,11 @@ function renderPuzzles(obj) {
let a = document.createElement('a') let a = document.createElement('a')
i.appendChild(a) i.appendChild(a)
a.textContent = points a.textContent = points
a.href = "puzzle.html?cat=" + cat + "&points=" + points + "&pid=" + id let url = new URL("puzzle.html", window.location)
url.searchParams.set("cat", cat)
url.searchParams.set("points", points)
url.searchParams.set("pid", id)
a.href = url.toString()
} }
} }