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("")
for i in sorted(glob.glob(os.path.join(request.app["puzzles_dir"], "*", ""))):
bn = os.path.basename(i.strip('/\\'))
- p.write('- puzzles/{}/
'.format(bn, bn))
+ p.write("- ")
+ p.write("[mb]".format(cat=bn, seed=seed))
+ p.write(" ")
+ p.write("{cat}".format(cat=bn, seed=seed))
+ p.write("
")
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:
+
+''')
+ for fn in files:
+ file_content.write(' - {efn}
\n'.format(fn=fn, efn=escape(fn)))
+ file_content.write(
+'''
+
+''')
+ scripts = [''.format(s) for s in puzzle.scripts]
+
+ html_content.write(
+'''
+
+
+
+
+ {category} {points}
+
+ {scripts}
+
+
+ {category} for {points} points
+
+{file_content}
+ 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
-
-
-
-
- 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",
- `
-
-
-
- `,
- )
-}
-
-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
-
+