diff --git a/Dockerfile.moth-devel b/Dockerfile.moth-devel index e82aecf..9a563dc 100644 --- a/Dockerfile.moth-devel +++ b/Dockerfile.moth-devel @@ -6,11 +6,12 @@ RUN apk --no-cache add \ python3 \ python3-dev \ py3-pillow \ - && \ - pip3 install aiohttp + && pip3 install aiohttp -COPY . /moth/ +COPY devel /app/ COPY example-puzzles /puzzles/ +COPY theme /theme/ WORKDIR /moth/ -ENTRYPOINT ["python3", "/moth/devel/devel-server.py", "--bind", ":8080", "--puzzles", "/puzzles"] +ENTRYPOINT [ "python3", "/app/devel-server.py" ] +CMD [ "--bind", "0.0.0.0:8080", "--puzzles", "/puzzles", "--theme", "/theme" ] diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..4c50b88 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +3.1-RC2 diff --git a/build.sh b/build.sh index 6b32568..b6900b7 100755 --- a/build.sh +++ b/build.sh @@ -2,7 +2,7 @@ set -e -version=$(date +%Y%m%d%H%M) +read version < VERSION cd $(dirname $0) for img in moth moth-devel; do @@ -10,3 +10,5 @@ for img in moth moth-devel; do sudo docker build --build-arg http_proxy=$http_proxy --build-arg https_proxy=$https_proxy --tag dirtbags/$img --tag dirtbags/$img:$version -f Dockerfile.$img . [ "$1" = "-push" ] && docker push dirtbags/$img:$version && docker push dirtbags/$img done + +exit 0 diff --git a/devel/devel-server.py b/devel/devel-server.py index 4db0b4a..350ed07 100755 --- a/devel/devel-server.py +++ b/devel/devel-server.py @@ -2,10 +2,10 @@ import asyncio import cgitb -import glob import html from aiohttp import web import io +import json import mimetypes import moth import logging @@ -19,159 +19,72 @@ import traceback import mothballer sys.dont_write_bytecode = True # Don't write .pyc files - -def mkseed(): - return bytes(random.choice(b'abcdef0123456789') for i in range(40)).decode('ascii') - -class Page: - def __init__(self, title, depth=0): - self.title = title - if depth: - self.base = "/".join([".."] * depth) - else: - self.base = "." - self.body = io.StringIO() - self.scripts = [] - - def add_script(self, path): - self.scripts.append(path) - - def write(self, s): - self.body.write(s) - - def text(self): - ret = io.StringIO() - ret.write("\n") - ret.write("\n") - ret.write("
\n") - ret.write("Yo, it's the front page!
") - p.write("If you use this development server to run a contest, you are a fool.
") - return p.response(request) + async def handle_puzzlelist(request): - seed = request.query.get("seed", mkseed()) - p = Page("Puzzle Categories", 1) - p.write("seed = {}
".format(seed)) - p.write("Input box (for scripts): ") - p.write("
{}
{}
".format(', '.join(puzzle.get_authors()))) - p.write("{}
".format(puzzle.summary)) - if puzzle.logs: - p.write("+ You need to provide the contest seed in the URL. + If you don't have a contest seed in mind, + why not try {seed}? + + +""".format(seed=seed) + return web.Response( + content_type="text/html", + body=body, + ) + + +async def handle_static(request): + fn = request.match_info.get("filename") + if not fn: + fn = "puzzles-list.html" + fn = os.path.join(request.app["theme_dir"], fn) + return web.FileResponse(fn) + + if __name__ == '__main__': import argparse @@ -194,6 +136,9 @@ if __name__ == '__main__': '--puzzles', default='puzzles', help="Directory containing your puzzles" ) + parser.add_argument( + '--theme', default='theme', + help="Directory containing theme files") parser.add_argument( '--bind', default="127.0.0.1:8080", help="Bind to ip:port" @@ -212,13 +157,13 @@ if __name__ == '__main__': mydir = os.path.dirname(os.path.dirname(os.path.realpath(sys.argv[0]))) app = web.Application() - app["puzzles_dir"] = args.puzzles app["base_url"] = args.base - app.router.add_route("GET", "/", handle_front) - app.router.add_route("GET", "/puzzles/", handle_puzzlelist) - app.router.add_route("GET", "/puzzles/{category}/", handle_category) - app.router.add_route("GET", "/puzzles/{category}/{points}/", handle_puzzle) - app.router.add_route("GET", "/puzzles/{category}/{points}/{filename}", handle_puzzlefile) - app.router.add_route("GET", "/mothballer/{category}", handle_mothballer) - app.router.add_static("/files/", mydir, show_index=True) + app["puzzles_dir"] = pathlib.Path(args.puzzles) + app["theme_dir"] = pathlib.Path(args.theme) + app.router.add_route("GET", "/", handle_index) + app.router.add_route("GET", "/{seed}/puzzles.json", handle_puzzlelist) + app.router.add_route("GET", "/{seed}/content/{category}/{points}/puzzle.json", handle_puzzle) + app.router.add_route("GET", "/{seed}/content/{category}/{points}/{filename}", handle_puzzlefile) + app.router.add_route("GET", "/{seed}/mothballer/{category}", handle_mothballer) + app.router.add_route("GET", "/{seed}/{filename:.*}", handle_static) web.run_app(app, host=addr, port=port) diff --git a/devel/moth.py b/devel/moth.py index bcd0e32..9820783 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -75,6 +75,7 @@ class Puzzle: self.authors = [] self.answers = [] self.scripts = [] + self.hint = None self.files = {} self.body = io.StringIO() self.logs = [] @@ -104,7 +105,7 @@ class Puzzle: elif key == 'answer': self.answers.append(val) elif key == 'hint': - pass + self.hint = val elif key == 'name': pass elif key == 'file': @@ -260,6 +261,18 @@ class Puzzle: def html_body(self): """Format and return the markdown for the puzzle body.""" return mistune.markdown(self.get_body(), escape=False) + + def package(self, answers=False): + """Return a dict packaging of the puzzle.""" + + files = [fn for fn,f in self.files.items() if f.visible] + return { + 'authors': self.authors, + 'hashes': self.hashes(), + 'files': files, + 'scripts': self.scripts, + 'body': self.html_body(), + } def hashes(self): "Return a list of answer hashes" diff --git a/devel/mothballer.py b/devel/mothballer.py index 0dde01d..1134dba 100755 --- a/devel/mothballer.py +++ b/devel/mothballer.py @@ -38,7 +38,7 @@ def escape(s): def build_category(categorydir, outdir): - category_seed = binascii.b2a_hex(os.urandom(20)) + category_seed = random.getrandbits(32) categoryname = os.path.basename(categorydir.strip(os.sep)) zipfilename = os.path.join(outdir, "%s.mb" % categoryname) @@ -48,7 +48,7 @@ def build_category(categorydir, outdir): # open and gather some state existing = zipfile.ZipFile(zipfilename, 'r') try: - category_seed = existing.open(SEEDFN).read().strip() + category_seed = int(existing.open(SEEDFN).read().strip()) except Exception: pass existing.close() @@ -65,7 +65,7 @@ def build_category(categorydir, outdir): def package(categoryname, categorydir, seed): zfraw = io.BytesIO() zf = zipfile.ZipFile(zfraw, 'x') - zf.writestr("category_seed.txt", seed) + zf.writestr("category_seed.txt", str(seed)) cat = moth.Category(categorydir, seed) mapping = {} @@ -74,7 +74,7 @@ def package(categoryname, categorydir, seed): for puzzle in cat: logging.info("Processing point value {}".format(puzzle.points)) - hashmap = hashlib.sha1(seed.encode('utf-8')) + hashmap = hashlib.sha1(str(seed).encode('utf-8')) hashmap.update(str(puzzle.points).encode('utf-8')) puzzlehash = hashmap.hexdigest() @@ -82,23 +82,13 @@ def package(categoryname, categorydir, seed): answers[puzzle.points] = puzzle.answers summary[puzzle.points] = puzzle.summary - puzzledir = os.path.join('content', puzzlehash) - files = [] + puzzledir = os.path.join("content", puzzlehash) 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, - 'scripts': puzzle.scripts, - 'body': puzzle.html_body(), - } - puzzlejson = json.dumps(puzzledict) - zf.writestr(os.path.join(puzzledir, 'puzzle.json'), puzzlejson) + obj = puzzle.package() + zf.writestr(os.path.join(puzzledir, 'puzzle.json'), json.dumps(obj)) write_kv_pairs(zf, 'map.txt', mapping) write_kv_pairs(zf, 'answers.txt', answers) diff --git a/docs/philosophy.md b/docs/philosophy.md new file mode 100644 index 0000000..5320580 --- /dev/null +++ b/docs/philosophy.md @@ -0,0 +1,32 @@ +Philosophy +========== + +This is just some scattered thoughts by the architect, Neale. + +People are going to try to break this thing. +It needs to be bulletproof. +This pretty much set the entire design: + +* As much as possible is done client-side + * Participants can attack their own web browsers as much as they feel like + * Also reduces server load + * We will help you create brute-force attacks! + * Your laptop is faster than our server + * We give you the carrot of hashed answers and the hashing function + * This removes one incentive to DoS the server +* Generate static content whenever possible + * Puzzles are statically compiled before the event even starts + * `points.json` and `puzzles.json` are generated and cached by a maintenance loop +* Minimize dynamic handling + * There are only two (2) dynamic handlers + * team registration + * answer validation + * You can disable team registration if you want, just remove `teamids.txt` + * I even removed token handling once I realized we replicate the user experience with the `answer` handler and some client-side JavaScript +* As much as possible is read-only + * The only rw directory is `state` +* Server code should be as tiny as possible + * Server should provide highly limited functionality + * It should be easy to remember in your head everything it does +* Server is also compiled + * Static type-checking helps assure no run-time errors diff --git a/src/maintenance.go b/src/maintenance.go index 5f08fe6..136ba88 100644 --- a/src/maintenance.go +++ b/src/maintenance.go @@ -31,7 +31,7 @@ func (pm *PuzzleMap) MarshalJSON() ([]byte, error) { return []byte(ret), nil } -func (ctx *Instance) generatePuzzleList() error { +func (ctx *Instance) generatePuzzleList() { maxByCategory := map[string]int{} for _, a := range ctx.PointsLog() { if a.Points > maxByCategory[a.Category] { @@ -43,7 +43,8 @@ func (ctx *Instance) generatePuzzleList() error { for catName, mb := range ctx.Categories { mf, err := mb.Open("map.txt") if err != nil { - return err + // File isn't in there + continue } defer mf.Close() @@ -58,9 +59,11 @@ func (ctx *Instance) generatePuzzleList() error { n, err := fmt.Sscanf(line, "%d %s", &pointval, &dir) if err != nil { - return err + log.Printf("Parsing map for %s: %v", catName, err) + continue } else if n != 2 { - return fmt.Errorf("Parsing map for %s: short read", catName) + log.Printf("Parsing map for %s: short read", catName) + continue } pm = append(pm, PuzzleMap{pointval, dir}) @@ -78,13 +81,14 @@ func (ctx *Instance) generatePuzzleList() error { } jpl, err := json.Marshal(ret) - if err == nil { - ctx.jPuzzleList = jpl + if err != nil { + log.Printf("Marshalling puzzles.js: %v", err) + return } - return err + ctx.jPuzzleList = jpl } -func (ctx *Instance) generatePointsLog() error { +func (ctx *Instance) generatePointsLog() { var ret struct { Teams map[string]string `json:"teams"` Points []*Award `json:"points"` @@ -98,7 +102,7 @@ func (ctx *Instance) generatePointsLog() error { if !ok { teamName, err := ctx.TeamName(a.TeamId) if err != nil { - teamName = "[unregistered]" + teamName = "Rodney" // https://en.wikipedia.org/wiki/Rogue_(video_game)#Gameplay } teamNumber = nr teamNumbersById[a.TeamId] = teamNumber @@ -108,10 +112,11 @@ func (ctx *Instance) generatePointsLog() error { } jpl, err := json.Marshal(ret) - if err == nil { - ctx.jPointsLog = jpl + if err != nil { + log.Printf("Marshalling points.js: %v", err) + return } - return err + ctx.jPointsLog = jpl } // maintenance runs diff --git a/theme/basic.css b/theme/basic.css index 4e9bd88..65ab16f 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -28,7 +28,7 @@ p { form, pre { margin: 1em; } -input { +input, select { padding: 0.6em; margin: 0.2em; } diff --git a/theme/puzzle-list.html b/theme/puzzle-list.html index 8be6c01..72607ba 100644 --- a/theme/puzzle-list.html +++ b/theme/puzzle-list.html @@ -4,6 +4,7 @@
- Should your name be here? Please remind me! -
-- This contest would not exist were it not for hundreds of - thousands of lines of code from free software authors around the - world, including: -
-