diff --git a/Dockerfile.moth b/Dockerfile.moth index 43e55e1..84330fa 100644 --- a/Dockerfile.moth +++ b/Dockerfile.moth @@ -5,4 +5,5 @@ RUN go build -o /mothd /src/*.go FROM alpine COPY --from=builder /mothd /mothd +COPY theme /theme ENTRYPOINT [ "/mothd" ] diff --git a/Dockerfile.moth-devel b/Dockerfile.moth-devel index ba6a4d8..f6364e2 100644 --- a/Dockerfile.moth-devel +++ b/Dockerfile.moth-devel @@ -1,7 +1,13 @@ -FROM alpine +FROM alpine:3.7 -RUN apk --no-cache add python3 py3-pillow && \ - pip3 install aiohttp +RUN apk --no-cache add \ + gcc \ + musl-dev \ + python3 \ + python3-dev \ + py3-pillow \ + && \ + pip3 install aiohttp COPY . /moth/ COPY example-puzzles /puzzles/ diff --git a/build.sh b/build.sh index d0cec51..6b32568 100755 --- a/build.sh +++ b/build.sh @@ -4,6 +4,7 @@ set -e version=$(date +%Y%m%d%H%M) +cd $(dirname $0) for img in moth moth-devel; do echo "==== $img" 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 . diff --git a/devel/devel-server.py b/devel/devel-server.py index 354f899..4db0b4a 100755 --- a/devel/devel-server.py +++ b/devel/devel-server.py @@ -1,6 +1,7 @@ #!/usr/bin/python3 import asyncio +import cgitb import glob import html from aiohttp import web @@ -15,11 +16,12 @@ import shutil import socketserver import sys 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)) + return bytes(random.choice(b'abcdef0123456789') for i in range(40)).decode('ascii') class Page: def __init__(self, title, depth=0): @@ -72,11 +74,17 @@ async def handle_front(request): 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("") return p.response(request) @@ -137,7 +145,7 @@ async def handle_puzzle(request): return p.response(request) async def handle_puzzlefile(request): - seed = request.query.get("seed", mkseed()) + seed = request.query.get("seed", mkseed()).encode('ascii') category = request.match_info.get("category") points = int(request.match_info.get("points")) filename = request.match_info.get("filename") @@ -158,6 +166,25 @@ async def handle_puzzlefile(request): resp.body = file.stream.read() return resp +async def handle_mothballer(request): + seed = request.query.get("seed", mkseed()) + category = request.match_info.get("category") + + try: + catdir = os.path.join(request.app["puzzles_dir"], category) + mb = mothballer.package(category, catdir, seed) + except: + body = cgitb.html(sys.exc_info()) + resp = web.Response(text=body, content_type="text/html") + return resp + + mb_buf = mb.read() + resp = web.Response( + body=mb_buf, + headers={"Content-Disposition": "attachment; filename={}.mb".format(category)}, + content_type="application/octet_stream", + ) + return resp if __name__ == '__main__': import argparse @@ -192,5 +219,6 @@ if __name__ == '__main__': 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) web.run_app(app, host=addr, port=port) diff --git a/devel/moth.py b/devel/moth.py index 6ba2646..bcd0e32 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -4,6 +4,7 @@ import argparse import contextlib import glob import hashlib +import html import io import importlib.machinery import mistune @@ -244,7 +245,7 @@ class Puzzle: self.body.write(' ') self.body.write(' '.join(hexes[8:])) self.body.write(' |') - self.body.write(''.join(chars)) + self.body.write(html.escape(''.join(chars))) self.body.write('|\n') offset += len(chars) self.body.write('{:08x}\n'.format(offset)) diff --git a/devel/mothballer.py b/devel/mothballer.py new file mode 100755 index 0000000..f0799b1 --- /dev/null +++ b/devel/mothballer.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 + +import argparse +import binascii +import hashlib +import io +import json +import logging +import moth +import os +import shutil +import tempfile +import zipfile + +SEEDFN = "SEED" + + +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 isinstance(kv[key], list): + 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('&', '&').replace('<', '<').replace('>', '>') + + +def generate_html(ziphandle, puzzle, puzzledir, category, points, authors, files): + html_content = io.StringIO() + file_content = io.StringIO() + if files: + file_content.write( +'''
+

Associated files:

+ +
+''') + scripts = [''.format(s) for s in puzzle.scripts] + + html_content.write( +''' + + + + + {category} {points} + + {scripts} + + +

{category} for {points} points

+
+{body}
+{file_content}
+
+ + +
Team hash:
+
Answer:
+ +
+
+
Puzzle by {authors}
+ +'''.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): + category_seed = binascii.b2a_hex(os.urandom(20)) + + categoryname = os.path.basename(categorydir.strip(os.sep)) + 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 Exception: + pass + existing.close() + logging.debug("Using PRNG seed {}".format(category_seed)) + + zipfileraw = tempfile.NamedTemporaryFile(delete=False) + mothball = package(categoryname, categorydir, category_seed) + shutil.copyfileobj(mothball, zipfileraw) + zipfileraw.close() + shutil.move(zipfileraw.name, zipfilename) + + +# Returns a file-like object containing the contents of the new zip file +def package(categoryname, categorydir, seed): + zfraw = io.BytesIO() + zf = zipfile.ZipFile(zfraw, 'x') + zf.writestr("category_seed.txt", seed) + + cat = moth.Category(categorydir, seed) + mapping = {} + answers = {} + summary = {} + for puzzle in cat: + logging.info("Processing point value {}".format(puzzle.points)) + + hashmap = hashlib.sha1(seed.encode('utf-8')) + 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() + zfraw.seek(0) + return zfraw + + +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) diff --git a/devel/package-puzzles.py b/devel/package-puzzles.py index 4a8da80..cbd7429 100755 --- a/devel/package-puzzles.py +++ b/devel/package-puzzles.py @@ -98,7 +98,7 @@ def build_category(categorydir, outdir): categoryname = os.path.basename(categorydir.strip(os.sep)) seedfn = os.path.join("category_seed.txt") - zipfilename = os.path.join(outdir, "%s.zip" % categoryname) + zipfilename = os.path.join(outdir, "%s.mb" % categoryname) logging.info("Building {} from {}".format(zipfilename, categorydir)) if os.path.exists(zipfilename): diff --git a/res/credits.html b/res/credits.html index 84c27fc..5155f30 100644 --- a/res/credits.html +++ b/res/credits.html @@ -51,6 +51,8 @@ window.addEventListener("load", init);
  • William Phillips
  • Jeremy Hefner
  • James Wernicke
  • +
  • Clark Taylor
  • +
  • John Donaldson
  • diff --git a/src/handlers.go b/src/handlers.go index 6516297..f9334f3 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -13,11 +13,11 @@ import ( ) type JSend struct { - Status string `json:"status"` - Data JSendData `json:"data"` + Status string `json:"status"` + Data JSendData `json:"data"` } type JSendData struct { - Short string `json:"short"` + Short string `json:"short"` Description string `json:"description"` } @@ -27,7 +27,7 @@ func ShowJSend(w http.ResponseWriter, status Status, short string, description s resp := JSend{ Status: "success", Data: JSendData{ - Short: short, + Short: short, Description: description, }, } @@ -41,16 +41,24 @@ func ShowJSend(w http.ResponseWriter, status Status, short string, description s } respBytes, err := json.Marshal(resp) - if (err != nil) { + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) // RFC2616 makes it pretty clear that 4xx codes are for the user-agent w.Write(respBytes) } +type Status int + +const ( + Success = iota + Fail + Error +) + // ShowHtml delevers an HTML response to w func ShowHtml(w http.ResponseWriter, status Status, title string, body string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -333,7 +341,29 @@ func (ctx *Instance) contentHandler(w http.ResponseWriter, req *http.Request) { } func (ctx *Instance) staticHandler(w http.ResponseWriter, req *http.Request) { - ServeStatic(w, req, ctx.ResourcesDir) + path := req.URL.Path + if strings.Contains(path, "..") { + http.Error(w, "Invalid URL path", http.StatusBadRequest) + return + } + if path == "/" { + path = "/index.html" + } + + f, err := os.Open(ctx.ResourcePath(path)) + if err != nil { + http.NotFound(w, req) + return + } + defer f.Close() + + d, err := f.Stat() + if err != nil { + http.NotFound(w, req) + return + } + + http.ServeContent(w, req, path, d.ModTime(), f) } func (ctx *Instance) BindHandlers(mux *http.ServeMux) { diff --git a/src/instance.go b/src/instance.go index 4a73e03..2794768 100644 --- a/src/instance.go +++ b/src/instance.go @@ -93,6 +93,11 @@ func (ctx *Instance) StatePath(parts ...string) string { return path.Join(ctx.StateDir, tail) } +func (ctx *Instance) ResourcePath(parts ...string) string { + tail := path.Join(parts...) + return path.Join(ctx.ResourcesDir, tail) +} + func (ctx *Instance) PointsLog() []*Award { var ret []*Award diff --git a/src/maintenance.go b/src/maintenance.go index 0879d68..5f08fe6 100644 --- a/src/maintenance.go +++ b/src/maintenance.go @@ -208,7 +208,7 @@ func (ctx *Instance) isEnabled() bool { log.Print("Suspended: disabled file found") return false } - + untilspec, err := ioutil.ReadFile(ctx.StatePath("until")) if err == nil { untilspecs := strings.TrimSpace(string(untilspec)) diff --git a/src/mothd.go b/src/mothd.go index acfda71..5834f24 100644 --- a/src/mothd.go +++ b/src/mothd.go @@ -27,18 +27,18 @@ func main() { ) mothballDir := flag.String( "mothballs", - "/moth/mothballs", + "/mothballs", "Path to read mothballs", ) stateDir := flag.String( "state", - "/moth/state", + "/state", "Path to write state", ) - resourcesDir := flag.String( - "resources", - "/moth/resources", - "Path to static resources (HTML, images, css, ...)", + themeDir := flag.String( + "theme", + "/theme", + "Path to static theme resources (HTML, images, css, ...)", ) maintenanceInterval := flag.Duration( "maint", @@ -56,7 +56,7 @@ func main() { log.Fatal(err) } - ctx, err := NewInstance(*base, *mothballDir, *stateDir, *resourcesDir) + ctx, err := NewInstance(*base, *mothballDir, *stateDir, *themeDir) if err != nil { log.Fatal(err) } diff --git a/src/static.go b/src/static.go deleted file mode 100644 index 41a60bc..0000000 --- a/src/static.go +++ /dev/null @@ -1,435 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "strings" -) - -type Status int - -const ( - Success = iota - Fail - Error -) - -// staticStylesheet serves up a basic stylesheet. -// This is designed to be usable on small touchscreens (like mobile phones) -func staticStylesheet(w http.ResponseWriter) { - w.Header().Set("Content-Type", "text/css") - w.WriteHeader(http.StatusOK) - - fmt.Fprint( - w, - ` -/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */ -body { - font-family: sans-serif; - max-width: 40em; - background: #282a33; - color: #f6efdc; -} -a:any-link { - color: #8b969a; -} -h1 { - background: #5e576b; - color: #9e98a8; -} -.Fail, .Error { - background: #3a3119; - color: #ffcc98; -} -.Fail:before { - content: "Fail: "; -} -.Error:before { - content: "Error: "; -} -p { - margin: 1em 0em; -} -form, pre { - margin: 1em; -} -input { - padding: 0.6em; - margin: 0.2em; -} -nav { - border: solid black 2px; -} -nav ul, .category ul { - padding: 1em; -} -nav li, .category li { - display: inline; - margin: 1em; -} -iframe#body { - border: inherit; - width: 100%; -} -img { - max-width: 100%; -} -#scoreboard { - width: 100%; - position: relative; -} - -#scoreboard span { - font-size: 75%; - display: inline-block; - overflow: hidden; - height: 1.7em; -} -#scoreboard span.teamname { - font-size: inherit; - color: white; - text-shadow: 0 0 3px black; - opacity: 0.8; - position: absolute; - right: 0.2em; -} -#scoreboard div * {white-space: nowrap;} -.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;} -.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;} -.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;} -.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;} -.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;} -.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;} -.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;} -.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;} - `, - ) -} - -// staticIndex serves up a basic landing page -func staticIndex(w http.ResponseWriter) { - ShowHtml( - w, Success, - "Welcome", - ` -

    Register your team

    - -
    - Team ID:
    - Team name: - -
    - -

    - If someone on your team has already registered, - proceed to the - puzzles overview. -

    - `, - ) -} - -func staticScoreboard(w http.ResponseWriter) { - ShowHtml( - w, Success, - "Scoreboard", - ` -
    - - `, - ) -} - -func staticPuzzleList(w http.ResponseWriter) { - ShowHtml( - w, Success, - "Open Puzzles", - ` -
    -
    -
    - - `, - ) -} - -func staticPuzzle(w http.ResponseWriter) { - ShowHtml( - w, Success, - "Open Puzzles", - ` -
    -
    Loading...
    -
    -
    - - - Team ID:
    - Answer:
    - -
    - - `, - ) -} - -func tryServeFile(w http.ResponseWriter, req *http.Request, path string) bool { - f, err := os.Open(path) - if err != nil { - return false - } - defer f.Close() - - d, err := f.Stat() - if err != nil { - return false - } - - http.ServeContent(w, req, path, d.ModTime(), f) - return true -} - -func ServeStatic(w http.ResponseWriter, req *http.Request, resourcesDir string) { - path := req.URL.Path - if strings.Contains(path, "..") { - http.Error(w, "Invalid URL path", http.StatusBadRequest) - return - } - if path == "/" { - path = "/index.html" - } - - fpath := filepath.Join(resourcesDir, path) - if tryServeFile(w, req, fpath) { - return - } - - switch path { - case "/basic.css": - staticStylesheet(w) - case "/index.html": - staticIndex(w) - case "/scoreboard.html": - staticScoreboard(w) - case "/puzzle-list.html": - staticPuzzleList(w) - case "/puzzle.html": - staticPuzzle(w) - default: - http.NotFound(w, req) - } -} diff --git a/res/basic.css b/theme/basic.css similarity index 100% rename from res/basic.css rename to theme/basic.css diff --git a/res/index.html b/theme/index.html similarity index 100% rename from res/index.html rename to theme/index.html diff --git a/res/puzzle-list.html b/theme/puzzle-list.html similarity index 78% rename from res/puzzle-list.html rename to theme/puzzle-list.html index 2f3c6af..a057dc8 100644 --- a/res/puzzle-list.html +++ b/theme/puzzle-list.html @@ -39,29 +39,29 @@ function render(obj) { l.appendChild(i); if (points === 0) { - i.textContent = "✿"; + i.textContent = "✿"; } else { - var a = document.createElement('a'); - i.appendChild(a); - a.textContent = points; + var a = document.createElement('a'); + i.appendChild(a); + a.textContent = points; a.href = "puzzle.html?cat=" + cat + "&points=" + points + "&pid=" + id; - } - } + } + } - puzzlesElement.appendChild(pdiv); - document.getElementById("puzzles").appendChild(puzzlesElement); + puzzlesElement.appendChild(pdiv); + document.getElementById("puzzles").appendChild(puzzlesElement); } } function init() { - fetch("puzzles.json") - .then(function(resp) { - return resp.json(); - }).then(function(obj) { - render(obj); - }).catch(function(err) { - console.log("Error", err); - }); + fetch("puzzles.json") + .then(function(resp) { + return resp.json(); + }).then(function(obj) { + render(obj); + }).catch(function(err) { + console.log("Error", err); + }); } document.addEventListener("DOMContentLoaded", init); diff --git a/res/puzzle.html b/theme/puzzle.html similarity index 71% rename from res/puzzle.html rename to theme/puzzle.html index 5258268..2eab65a 100644 --- a/res/puzzle.html +++ b/theme/puzzle.html @@ -17,10 +17,10 @@ function init() { let base = "content/" + categoryName + "/" + puzzleId + "/"; let fn = base + "puzzle.json"; - fetch(fn) - .then(function(resp) { - return resp.json(); - }).then(function(obj) { + fetch(fn) + .then(function(resp) { + return resp.json(); + }).then(function(obj) { document.getElementById("authors").textContent = obj.authors.join(", "); for (let fn of obj.files) { let li = document.createElement("li"); @@ -31,14 +31,14 @@ function init() { document.getElementById("files").appendChild(li); } document.getElementById("puzzle").innerHTML = obj.body; - }).catch(function(err) { - console.log("Error", err); - }); - - document.querySelector("body > h1").innerText = categoryName + " " + points - document.querySelector("input[name=cat]").value = categoryName; - document.querySelector("input[name=points]").value = points; - + }).catch(function(err) { + console.log("Error", err); + }); + + document.querySelector("body > h1").innerText = categoryName + " " + points + document.querySelector("input[name=cat]").value = categoryName; + document.querySelector("input[name=points]").value = points; + function mutated(mutationsList, observer) { for (let mutation of mutationsList) { if (mutation.type == 'childList') { @@ -58,14 +58,14 @@ function init() { } } - let puzzle = document.getElementById("puzzle"); - let observerOptions = { - childList: true, - attributes: true, - subtree: true, - }; - window.observer = new MutationObserver(mutated); - observer.observe(puzzle, observerOptions); + let puzzle = document.getElementById("puzzle"); + let observerOptions = { + childList: true, + attributes: true, + subtree: true, + }; + window.observer = new MutationObserver(mutated); + observer.observe(puzzle, observerOptions); } document.addEventListener("DOMContentLoaded", init); @@ -77,13 +77,13 @@ document.addEventListener("DOMContentLoaded", init);

    Puzzle by

    -
    - - - Team ID:
    - Answer:
    - -
    +
    + + + Team ID:
    + Answer:
    + +