diff --git a/Dockerfile.moth b/Dockerfile.moth
index 0e8b7e9..43e55e1 100644
--- a/Dockerfile.moth
+++ b/Dockerfile.moth
@@ -1,14 +1,8 @@
-FROM neale/eris
-
-RUN apk --no-cache add lua5.1 lua5.2 lua5.3
-RUN ln -s lua5.2 /usr/bin/lua
-
-# Install MOTH. This could be less obtuse.
-COPY www /moth/www/
-COPY bin /moth/bin/
-COPY src/moth-init /moth/init
-RUN ln -s ../state/puzzles.json /moth/www/puzzles.json && \
- ln -s ../state/points.json /moth/www/points.json
-
-CMD ["/moth/init"]
+FROM alpine AS builder
+RUN apk --no-cache add go libc-dev
+COPY src /src
+RUN go build -o /mothd /src/*.go
+FROM alpine
+COPY --from=builder /mothd /mothd
+ENTRYPOINT [ "/mothd" ]
diff --git a/Dockerfile.moth-compile b/Dockerfile.moth-compile
deleted file mode 100644
index cd1331f..0000000
--- a/Dockerfile.moth-compile
+++ /dev/null
@@ -1,7 +0,0 @@
-FROM alpine
-
-RUN apk --no-cache add python3 py3-pillow
-
-COPY . /moth/
-
-ENTRYPOINT ["python3", "/moth/tools/mothballer.py"]
diff --git a/Dockerfile.package-puzzles b/Dockerfile.package-puzzles
deleted file mode 100644
index f9f152f..0000000
--- a/Dockerfile.package-puzzles
+++ /dev/null
@@ -1,10 +0,0 @@
-FROM alpine
-
-RUN apk --no-cache add python3 py3-pillow
-
-COPY tools/package-puzzles.py /pp/
-COPY tools/moth.py /pp/
-COPY tools/mistune.py /pp/
-COPY tools/answer_words.txt /pp/
-
-ENTRYPOINT ["python3", "/pp/package-puzzles.py"]
diff --git a/README.md b/README.md
index b3c9fd6..2d73d75 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,9 @@ which in the past has been called
"HACK",
"Queen Of The Hill",
"Cyber Spark",
-and "Cyber Fire".
+"Cyber Fire",
+"Cyber Fire Puzzles",
+and "Cyber Fire Foundry".
Information about these events is at
http://dirtbags.net/contest/
@@ -18,96 +20,125 @@ It also tracks scores,
and comes with a JavaScript-based scoreboard to display team rankings.
+Running a Development Server
+============================
+
+ docker run --rm -it -p 8080:8080 dirtbags/moth-devel
+
+And point a browser to http://localhost:8080/ (or whatever host is running the server).
+
+When you're ready to create your own puzzles,
+read [the devel server documentation](docs/devel-server.md).
+
+Click the `[mb]` link by a puzzle category to compile and download a mothball that the production server can read.
+
+
+Running a Production Server
+===========================
+
+ docker run --rm -it -p 8080:8080 -v /path/to/moth:/moth dirtbags/moth
+
+You can be more fine-grained about directories, if you like.
+Inside the container, you need the following paths:
+
+* `/moth/state` (rw) Where state is stored. Read [the overview](docs/overview.md) to learn what's what in here.
+* `/moth/mothballs` (ro) Mothballs (puzzle bundles) as provided by the development server.
+* `/moth/resources` (ro) Overrides for built-in HTML/CSS resources.
+
+
+
+
+
Getting Started Developing
-------------------------------
-You'll want to start out with the Development Server.
+If you don't have a `puzzles` directory,
+you can copy the example puzzles as a starting point:
+
+ $ cp -r example-puzzles puzzles
+
+Then launch the development server:
+
+ $ python3 tools/devel-server.py
+
+Point a web browser at http://localhost:8080/
+and start hacking on things in your `puzzles` directory.
More on how the devel sever works in
[the devel server documentation](docs/devel-server.md)
-How everything works
----------------------------
-
-This section wound up being pretty long.
-Please check out [the overview](docs/overview.md)
-for details.
-
-
Running A Production Server
====================
-Please submit a merge request to improve this section ;)
+Run `dirtbags/moth` (Docker) or `mothd` (native).
+
+`mothd` assumes you're running a contest out of `/moth`.
+For Docker, you'll need to bind-mount your actual directories
+(`state`, `mothballs`, and optionally `resources`) into
+`/moth/`.
+
+You can override any path with an option,
+run `mothd -help` for usage.
-How to install it
---------------------
-
-It's made to be virtualized,
-so you can run multiple contests at once if you want.
-If you were to want to run it out of `/srv/moth`,
-do the following:
-
- $ mothinst=/srv/moth/mycontest
- $ mkdir -p $mothinst
- $ install.sh $mothinst
-
- Yay, you've got it installed.
-
-How to run a contest
-------------------------
-
-`mothd` runs through every contest on your server every few seconds,
-and does housekeeping tasks that make the contest "run".
-If you stop `mothd`, people can still play the contest,
-but their points won't show up on the scoreboard.
-
-A handy side-effect here is that if you need to meddle with the points log,
-you can just kill `mothd`,
-do you work,
-then bring `mothd` back up.
-
- $ cp src/mothd /srv/moth
- $ /srv/moth/mothd
-
-You're also going to need a web server if you want people to be able to play.
+State Directory
+===============
-How to run a web server
------------------------------
+Pausing scoring
+-------------------
-Your web server needs to serve up files for you contest out of
-`$mothinst/www`.
+Create the file `state/disabled`
+to pause scoring,
+and remove it to resume.
+You can use the Unix `touch` command to create the file:
-If you don't want to fuss around with setting up a full-featured web server,
-you can use `tcpserver` and `eris`,
-which is what we use to run our contests.
+ touch state/disabled
-`tcpserver` is part of the `uscpi-tcp` package in Ubuntu.
-You can also use busybox's `tcpsvd` (my preference, but a PITA on Ubuntu).
-
-`eris` can be obtained at https://github.com/nealey/eris
-
- $ mothinst=/srv/moth/mycontest
- $ $mothinst/bin/httpd
+When scoring is paused,
+participants can still submit answers,
+and the system will tell them whether the answer is correct.
+As soon as you unpause,
+all correctly-submitted answers will be scored.
-Installing Puzzle Categories
-------------------------------------
+Resetting an instance
+-------------------
-Puzzle categories are distributed in a different way than the server.
-After setting up (see above), just run
+Remove the file `state/initialized`,
+and the server will zap everything.
- $ /srv/koth/mycontest/bin/install-category /path/to/my/category
-
-Permissions
-----------------
+Setting up custom team IDs
+-------------------
+
+The file `state/teamids.txt` has all the team IDs,
+one per line.
+This defaults to all 4-digit natural numbers.
+You can edit it to be whatever strings you like.
+
+We sometimes to set `teamids.txt` to a bunch of random 8-digit hex values:
+
+ for i in $(seq 50); do od -x /dev/urandom | awk '{print $2 $3; exit;}'; done
+
+Remember that team IDs are essentially passwords.
+
+
+Mothball Directory
+==================
+
+Installing puzzle categories
+-------------------
+
+The development server will provide you with a `.mb` (mothball) file,
+when you click the `[mb]` link next to a category.
+
+Just drop that file into the `mothballs` directory,
+and the server will pick it up.
+
+If you remove a mothball,
+the category will vanish,
+but points scored in that category won't!
-It's up to you not to be a bonehead about permissions.
-Install sets it so the web user on your system can write to the files it needs to,
-but if you're using Apache,
-it plays games with user IDs when running CGI.
-You're going to have to figure out how to configure your preferred web server.
diff --git a/bin/httpd b/bin/httpd
deleted file mode 100755
index edeacbc..0000000
--- a/bin/httpd
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/bin/sh -e
-
-# Starts a standalone server using tcpsvd and eris
-
-echo "Figuring out web user..."
-for www in www www-data http tc _ _www; do
- id $www && break
-done
-if [ $www = _ ]; then
- echo "Unable to determine httpd user on this system. Dying."
- exit 1
-fi
-
-cd $(dirname $0)/../www
-tcpserver -RHI localhost -u $www -g $www 0 80 eris -c -.
-
diff --git a/bin/install-category b/bin/install-category
deleted file mode 100755
index 060524e..0000000
--- a/bin/install-category
+++ /dev/null
@@ -1,26 +0,0 @@
-#! /bin/sh -e
-
-package=$1
-if ! [ -n "$package" -a -f $package ]; then
- echo "Usage: $0 PACKAGE"
- exit 1
-fi
-shift
-
-
-cat=$(basename $package .zip)
-outdir=$(dirname $(dirname $0))/packages/$cat
-
-echo "Extracting to $outdir..."
-mkdir -p $outdir
-unzip -o -d $outdir $package
-
-echo "Fixing permissions..."
-chmod a+rx $outdir/*/ $outdir/*/*
-chmod -R a+r $outdir
-find $outdir/content -name \*.cgi -exec chmod a+rx {} \;
-
-if [ ! -h $outdir/../../www/$cat ]; then
- echo "Linking into web space..."
- ln -sf ../packages/$cat/content $outdir/../../www/$cat
-fi
diff --git a/bin/new b/bin/new
deleted file mode 100755
index e765e88..0000000
--- a/bin/new
+++ /dev/null
@@ -1,37 +0,0 @@
-#! /bin/sh
-
-newdir=$1
-if [ -z "$newdir" ]; then
- echo "Usage: $0 NEWDIR"
- exit 1
-fi
-
-KOTH_BASE=$(cd $(dirname $0)/.. && pwd)
-
-echo "Figuring out web user..."
-for www in www-data http _; do
- id $www && break
-done
-
-if [ $www = _ ]; then
- echo "Unable to determine httpd user on this system. Dying."
- exit 1
-fi
-
-mkdir -p $newdir
-cd $newdir
-
-for i in points.new points.tmp teams; do
- mkdir -p state/$i
- setfacl -m ${www}:rwx state/$i
-done
-
->> state/points.log
-
-if ! [ -f assigned.txt ]; then
- hd < /dev/urandom | awk '{print $3 $4 $5 $6;}' | head -n 100 > assigned.txt
-fi
-
-mkdir -p www
-cp -r $KOTH_BASE/html/* www/
-cp $KOTH_BASE/bin/*.cgi www/
diff --git a/bin/once b/bin/once
deleted file mode 100755
index 388ba21..0000000
--- a/bin/once
+++ /dev/null
@@ -1,95 +0,0 @@
-#! /bin/sh
-
-if [ -n "$1" ]; then
- cd $1
-else
- cd $(dirname $0)/..
-fi
-basedir=$(pwd)
-
-log () {
- echo "moth: $@" 1>&2
-}
-
-# Do nothing if `disabled` is present
-if [ -f state/disabled ]; then
- log "Instance disabled; doing nothing"
- exit
-fi
-
-# Are we stopping at a certain time?
-if [ -f state/until ]; then
- read -r until < state/until
- when=$(date -d "$until" +%s)
- now=$(date +%s)
- if [ $now -ge $when ]; then
- log "End time reached; doing nothing"
- exit
- fi
-fi
-
-# Reset to initial state?
-if [ ! -f state/initialized ]; then
- log "Resetting contest state"
-
- rm -rf state/teams state/points.new state/points.tmp
- mkdir -p state/teams state/points.new state/points.tmp
- chown www:www state/teams state/points.new state/points.tmp # Needs root. Use Docker.
- : > state/points.log
- echo 'Remove this file to obliterate teams and points' > state/initialized
-fi
-
-# Create some team names if needed
-if [ ! -f state/assigned.txt ]; then
- log "Generating team names"
- hd state/assigned.txt
-fi
-
-# Install new categories
-for pkg in puzzles/*; do
- cat=$(basename $pkg .zip)
- if [ ! -f packages/$cat/installed ] || [ $pkg -nt packages/$cat/installed ]; then
- log "Installing $pkg"
- bin/install-category $pkg
- : >packages/$cat/installed
- fi
-done
-
-# Helpful error message
-if [ $(ls packages | wc -l) -eq 0 ]; then
- log "error: No packages installed"
- exit
-fi
-
-# Create a list of currently-active categories
-: > state/categories.txt.new
-for dn in packages/*; do
- cat=${dn##packages/}
- echo "$cat" >> state/categories.txt.new
-done
-mv state/categories.txt.new state/categories.txt
-
-# Collect new points
-find state/points.new -type f | while read fn; do
- # Skip files opened by another process
- lsof $fn | grep -q $fn && continue
-
- # Skip partially written files
- [ $(wc -l < $fn) -gt 0 ] || continue
-
- # filter the file for unique awards
- sort -k 4 $fn | uniq -f 1 | sort -n >> state/points.log
-
- # Now kill the file
- rm -f $fn
-done
-
-# Generate new puzzles.json
-if bin/puzzles $basedir > state/puzzles.json.new; then
- mv state/puzzles.json.new state/puzzles.json
-fi
-
-# Generate new points.json
-if bin/points $basedir > state/points.json.new; then
- mv state/points.json.new state/points.json
-fi
diff --git a/bin/points b/bin/points
deleted file mode 100755
index 79a4e5d..0000000
--- a/bin/points
+++ /dev/null
@@ -1,45 +0,0 @@
-#! /usr/bin/lua5.3
-
-local basedir = arg[1]
-local statedir = basedir .. "/state"
-
-io.write('{\n "points": [\n')
-local teams = {}
-local teamnames = {}
-local nteams = 0
-local NR = 0
-
-for line in io.lines(statedir .. "/points.log") do
- local ts, hash, cat, points = line:match("(%d+) (%g+) (%g+) (%d+)")
- local teamno = teams[hash]
-
- if not teamno then
- teamno = nteams
- teams[hash] = teamno
- nteams = nteams + 1
-
- teamnames[hash] = io.lines(statedir .. "/teams/" .. hash)()
- end
-
- if NR > 0 then
- -- JSON sucks, barfs if you have a comma with nothing after it
- io.write(",\n")
- end
- NR = NR + 1
-
- io.write(' [' .. ts .. ', "' .. teamno .. '", "' .. cat .. '", ' .. points .. ']')
-end
-
-io.write('\n],\n "teams": {\n')
-
-NR = 0
-for hash,teamname in pairs(teamnames) do
- if NR > 0 then
- io.write(",\n")
- end
- NR = NR + 1
-
- teamno = teams[hash]
- io.write(' "' .. teamno .. '": "' .. teamname .. '"')
-end
-io.write('\n }\n}\n')
diff --git a/bin/puzzles b/bin/puzzles
deleted file mode 100755
index 0259174..0000000
--- a/bin/puzzles
+++ /dev/null
@@ -1,53 +0,0 @@
-#! /usr/bin/lua5.3
-
-local basedir = arg[1]
-local statedir = basedir .. "/state"
-
-local max_by_cat = {}
-for cat in io.lines(statedir .. "/categories.txt") do
- max_by_cat[cat] = 0
-end
-
-for line in io.lines(statedir .. "/points.log") do
- local ts, team, cat, points = line:match("^(%d+) (%g+) (%g+) (%d+)")
- points = tonumber(points) or 0
-
- -- Skip scores for removed categories
- if (max_by_cat[cat] ~= nil) then
- max_by_cat[cat] = math.max(max_by_cat[cat], points)
- end
-end
-
-
-local i = 0
-io.write('{\n')
-for cat, biggest in pairs(max_by_cat) do
- local points, dirname
- local j = 0
-
- if i > 0 then
- io.write(',\n')
- end
- i = i + 1
-
- io.write(' "' .. cat .. '": [\n')
- for line in io.lines(basedir .. "/packages/" .. cat .. "/map.txt") do
- points, dirname = line:match("^(%d+) (.*)")
- points = tonumber(points)
-
- if j > 0 then
- io.write(',\n')
- end
- j = j + 1
- io.write(' [' .. points .. ', "' .. dirname .. '"]')
- if (points > biggest) then
- break
- end
- end
- if (points == biggest) then
- io.write(',\n')
- io.write(' [0, ""]')
- end
- io.write('\n ]')
-end
-io.write('\n}\n')
diff --git a/bin/server-start b/bin/server-start
deleted file mode 100755
index a60fc73..0000000
--- a/bin/server-start
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/bin/sh
-
-# Starts a standalone server using tcpsvd and eris
-
-tcpserver
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..583fc7c
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,11 @@
+#! /bin/sh
+
+set -e
+
+version=$(date +%Y%m%d%H%M)
+
+for img in moth moth-devel; do
+ echo "==== $img"
+ docker build --build-arg http_proxy=$http_proxy --tag dirtbags/$img --tag dirtbags/$img:$version -f Dockerfile.$img .
+ [ "$1" = "--push" ] && docker push dirtbags/$img:$version && docker push dirtbags/$img
+done
diff --git a/bin/award b/contrib/award
similarity index 100%
rename from bin/award
rename to contrib/award
diff --git a/bin/mktokens b/contrib/mktokens
similarity index 100%
rename from bin/mktokens
rename to contrib/mktokens
diff --git a/tools/answer_words.txt b/devel/answer_words.txt
similarity index 100%
rename from tools/answer_words.txt
rename to devel/answer_words.txt
diff --git a/tools/devel-server.py b/devel/devel-server.py
similarity index 85%
rename from tools/devel-server.py
rename to devel/devel-server.py
index 830eb9f..354f899 100755
--- a/tools/devel-server.py
+++ b/devel/devel-server.py
@@ -1,7 +1,6 @@
#!/usr/bin/python3
import asyncio
-import cgitb
import glob
import html
from aiohttp import web
@@ -16,12 +15,11 @@ 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)).decode('ascii')
+ return bytes(random.choice(b'abcdef0123456789') for i in range(40))
class Page:
def __init__(self, title, depth=0):
@@ -74,17 +72,11 @@ 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("
")
return p.response(request)
@@ -145,7 +137,7 @@ async def handle_puzzle(request):
return p.response(request)
async def handle_puzzlefile(request):
- seed = request.query.get("seed", mkseed()).encode('ascii')
+ seed = request.query.get("seed", mkseed())
category = request.match_info.get("category")
points = int(request.match_info.get("points"))
filename = request.match_info.get("filename")
@@ -166,25 +158,6 @@ 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={}.zip".format(category)},
- content_type="application/octet_stream",
- )
- return resp
if __name__ == '__main__':
import argparse
@@ -219,6 +192,5 @@ 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/tools/mistune.py b/devel/mistune.py
similarity index 100%
rename from tools/mistune.py
rename to devel/mistune.py
diff --git a/tools/moth.py b/devel/moth.py
similarity index 100%
rename from tools/moth.py
rename to devel/moth.py
diff --git a/tools/mothd.service b/devel/mothd.service
similarity index 100%
rename from tools/mothd.service
rename to devel/mothd.service
diff --git a/tools/mothballer.py b/devel/package-puzzles.py
similarity index 88%
rename from tools/mothballer.py
rename to devel/package-puzzles.py
index 5b368ab..4a8da80 100755
--- a/tools/mothballer.py
+++ b/devel/package-puzzles.py
@@ -2,6 +2,7 @@
import argparse
import binascii
+import glob
import hashlib
import io
import json
@@ -9,12 +10,11 @@ import logging
import moth
import os
import shutil
+import string
+import sys
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
@@ -24,19 +24,17 @@ def write_kv_pairs(ziphandle, filename, kv):
"""
filehandle = io.StringIO()
for key in sorted(kv.keys()):
- if isinstance(kv[key], list):
+ 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('&', '&').replace('<', '<').replace('>', '>')
-
-
+
def generate_html(ziphandle, puzzle, puzzledir, category, points, authors, files):
html_content = io.StringIO()
file_content = io.StringIO()
@@ -53,7 +51,7 @@ def generate_html(ziphandle, puzzle, puzzledir, category, points, authors, files
''')
scripts = [''.format(s) for s in puzzle.scripts]
-
+
html_content.write(
'''
@@ -86,15 +84,20 @@ def generate_html(ziphandle, puzzle, puzzledir, category, points, authors, files
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.zip" % categoryname)
logging.info("Building {} from {}".format(zipfilename, categorydir))
@@ -102,36 +105,25 @@ def build_category(categorydir, outdir):
# open and gather some state
existing = zipfile.ZipFile(zipfilename, 'r')
try:
- category_seed = existing.open(SEEDFN).read().strip()
- except Exception:
+ category_seed = existing.open(seedfn).read().strip()
+ except:
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)
+ zf.writestr(seedfn, category_seed)
-
-# 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)
+ cat = moth.Category(categorydir, category_seed)
mapping = {}
answers = {}
summary = {}
for puzzle in cat:
logging.info("Processing point value {}".format(puzzle.points))
- hashmap = hashlib.sha1(seed)
+ 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
@@ -160,10 +152,10 @@ def package(categoryname, categorydir, seed):
# clean up
zf.close()
- zfraw.seek(0)
- return zfraw
-
+ shutil.move(zipfileraw.name, zipfilename)
+
+
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Build a category package')
parser.add_argument('outdir', help='Output directory')
@@ -174,3 +166,4 @@ if __name__ == '__main__':
for categorydir in args.categorydirs:
build_category(categorydir, args.outdir)
+
diff --git a/setup.cfg b/devel/setup.cfg
similarity index 100%
rename from setup.cfg
rename to devel/setup.cfg
diff --git a/tools/update-words.sh b/devel/update-words.sh
similarity index 100%
rename from tools/update-words.sh
rename to devel/update-words.sh
diff --git a/docs/overview.md b/docs/overview.md
index 94d2885..5242aed 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -16,8 +16,142 @@ indicating score within each category,
and overall ranking.
-How Scores are Calculated
--------------------------
+State Directory
+===============
+
+The state directory is written to by the server to preserve state.
+At no point is anything only in memory:
+if it's not on the filesystem,
+mothd doesn't think it exists.
+
+The state directory is also used to communicate actions to mothd.
+
+
+`initialized`
+-------------
+
+Remove this file to reset the state. This will blow away team assignments and the points log.
+
+
+`disabled`
+----------
+
+Create this file to pause collection of points and other maintenance.
+Contestants can still submit answers,
+but they won't show up on the scoreboard until you remove this file.
+
+This file does not normally exist.
+
+
+`until`
+-------
+
+Put an RFC3337 date/time stamp in here to have the server pause itself at a given time.
+Remember that time zones exist!
+I recommend always using Zulu time.
+
+This file does not normally exist.
+
+
+`teamids.txt`
+-------------
+
+A list of valid Team IDs, one per line.
+It defaults to all 4-digit natural numbers,
+but you can put whatever you want in here.
+
+
+`points.log`
+------------
+
+The log of awarded points:
+
+ EpochTime TeamId Category Points
+
+Do not write to this file, unless you have disabled the contest. You will lose points!
+
+
+`points.tmp`
+------------
+
+Drop points logs here.
+Filenames can be anything.
+
+When the file is complete and written out,
+move it into `points.new`,
+where a non-disabled event's maintenance loop will eventually move it into the main log.
+
+`points.new`
+------------
+
+Complete points logs should be atomically moved here.
+This is to avoid needing locks.
+[Read about Maildir](https://en.wikipedia.org/wiki/Maildir)
+if you care about the technical reasons we do things this way.
+
+
+Mothball Directory
+==================
+
+Put a mothball in this directory to open that category.
+Remove a mothball to disable that category.
+
+Overwriting a mothball with a newer version will be noticed by the server within one maintenance interval
+(20 seconds by default).
+Be sure to use the same compilation seed in the development server if you compile a new version!
+
+Removing a category does not remove points that have been scored in the category.
+
+
+Resources Directory
+===================
+
+
+Making it look better
+-------------------
+
+`mothd` provides some built-in HTML for rendering a complete contest,
+but it's rather bland.
+You can override everything by dropping a new file into the `resources` directory:
+
+* `basic.css` is used by the default HTML to pretty things up
+* `index.html` is the landing page, which asks to register a team
+* `puzzle.html` renders a puzzle from JSON
+* `puzzle-list.html` renders the list of active puzzles from JSON
+* `scoreboard.html` renders the current scoreboard from JSON
+* Any other file in the `resources` directory will be served up, too.
+
+If you don't want to read through the source code, I don't blame you.
+Run a `mothd` server and pull the various static resources into your `resources` directory,
+and then you can start hacking away at them.
+
+
+Making it look totally different
+---------------------
+
+Every handler can serve its answers up in JSON format,
+just add `application/json` to the `Accept` header of your request.
+
+This means you could completely ignore the file structure in the previous section,
+and write something like a web app that only loads static resources at startup.
+
+
+Changing scoring
+--------------
+
+Scoring is determined client-side in the scoreboard,
+from the points log.
+You can hack in whatever algorithm you like,
+and provide your own scoreboard(s).
+
+If you do hack in a new algorithm,
+please be a dear and email it to us.
+We'd love to see it!
+
+
+
+How Scores are Calculated by Default
+------------------------------------
The per-category score for team `t` is computed as:
@@ -38,138 +172,3 @@ Because we don't award extra points for quick responses,
teams always feel like they have the possibility to catch up if they are skilled enough.
-Requirements
--------------
-
-MOTH was written to run on a wide range of Linux systems.
-We are very careful not to require exotic extensions:
-you can run MOTH equally well on OpenWRT and Ubuntu Server.
-It might even run on BSD: if you've tried this, please email us!
-
-Its architecture also limits permissions,
-to make it easier to lock things down very tight.
-Since it writes to the filesystem slowly and atomically,
-it can be run from a USB flash drive formatted with VFAT.
-
-
-On the server, it requires:
-
-* Bourne shell (POSIX 1003.2: BASH is okay but not required)
-* Awk (POSIX 1003.2: gawk is okay but not required)
-* Lua 5.1
-
-
-On the client, it requires:
-
-* A modern web browser with JavaScript
-* Categories might add other requirements (like domain-specific tools to solve the puzzles)
-
-
-Filesystem Layout
-=================
-
-The system is set up to make it simple to run one or more contests on a single machine.
-
-I like to use `/srv/moth` as the base directory for all instances.
-So if I were running an instance called "hack",
-the instance directory would be `/srv/moth/hack`.
-
-There are five entries in each instance directory, described in detail below:
-
- /srv/moth/hack # (r-x) Instance directory
- /srv/moth/hack/assigned.txt # (r--) List of assigned team tokens
- /srv/moth/hack/bin/ # (r-x) Per-instance binaries
- /srv/moth/hack/categories/ # (r-x) Installed categories
- /srv/moth/hack/state/ # (rwx) Contest state
- /srv/moth/hack/www/ # (r-x) Web server documentroot
-
-
-
-`state/assigned.txt`
-----------------
-
-This is just a list of tokens that have been assigned.
-One token per line, and tokens can be anything you want.
-
-For my middle school events, I make tokens all possible 4-digit numbers,
-and tell kids to use any number they want: it makes it quicker to start.
-For more advanced events,
-this doesn't work as well because people start guessing other teams' numbers to confuse each other.
-So I use hex representations of random 32-bit ints.
-But you could use anything you want in here (for specifics on allowed characters, read the registration CGI).
-
-The registration CGI checks this list to see if a token has already assigned to a team name.
-Teams enter points by token,
-which lets them use any text they want for a team name.
-Since we don't read their team name anywhere else than the registration and scoreboard generator,
-it allows some assumptions about what kind of strings tokens can be,
-resulting in simpler code.
-
-
-`categories/`
---------------
-
-`categories/` contains read-only category packages.
-Within each subdirectory there is:
-
-* `map.txt` mapping point values to directory names
-* `answers.txt` a list of answers for each point value
-* `salt` used to generate directory names (so people can't guess them to skip ahead)
-* `summary.txt` a compliation of `00summary.txt` files for puzzles, to give you a quick reference point when someone says "I need help on js 40".
-* `puzzles` is all the HTML that needs to be served up for the category
-
-
-`bin/`
-------
-
-Contains all the binaries you'll need to run an event.
-These are probably just copies from the `base` package (where this README lives).
-They're copied over in case you need to hack on them during an event.
-
-`bin/once` is of particular interest:
-it gets run periodically to do everything, including:
-
-* Gather points from `points.new` and append them to the points log.
-* Generate a new `puzzles.html` listing all open puzzles.
-* Generate a new `points.json` for the scoreboard
-
-### Pausing `once`
-
-You can pause everything `bin/once` does by touching a file in the root directory
-called `disabled`.
-This doesn't stop the game:
-it just stops points collection and generation of the files listed above.
-
-This is extremely helpful when, inevitably,
-you need to hack the points log,
-or do other maintenance tasks.
-Most times you don't even need to announce that you're doing anything:
-people can keep playing the game and their points keep collecting,
-ready to be appended to the log when you're done and you re-enable `once`.
-
-
-`www/`
------------
-
-HTML root for an event.
-It is possible to make this read-only,
-after you've set up your packages.
-You will need to symlink a few things into the `state` directory, though.
-
-
-`state/`
----------
-
-Where all game state is stored.
-This is the only part of the contest directory setup that needs to be writable,
-and tarring it up preserves exactly the entire contest.
-
-Notable, it contains the mapping from team hash to name,
-and the points log.
-
-`points.log` is replayed by the scoreboard generator to calculate the current score for each team.
-
-New points are written to `points.new`, and picked up by `bin/once` to append to `points.log`.
-When `once` is disabled (by touching a file called `disabled` at the top level for a game),
-the various points-awarding things can keep writing files into `points.new`,
-with no need for locking or "bringing down the game for maintenance".
diff --git a/install.sh b/install.sh
deleted file mode 100755
index f9f9d91..0000000
--- a/install.sh
+++ /dev/null
@@ -1,82 +0,0 @@
-#! /bin/sh -e
-
-DESTDIR=$1
-
-if [ -z "$DESTDIR" ]; then
- echo "Usage: $0 DESTDIR"
- exit
-fi
-
-cd $(dirname $0)
-
-older () {
- [ -z "$1" ] && return 1
- target=$1; shift
- [ -f $target ] || return 0
- for i in "$@"; do
- [ $i -nt $target ] && return 0
- done
- return 1
-}
-
-copy () {
- src=$1
- target=$2/$src
- targetdir=$(dirname $target)
- if older $target $src; then
- echo "COPY $src"
- mkdir -p $targetdir
- cp $src $target
- fi
-}
-
-setup() {
- [ -d $DESTDIR/state ] && return
- echo "SETUP"
- for i in points.new points.tmp teams; do
- dir=$DESTDIR/state/$i
- mkdir -p $dir
- setfacl -m ${www}:rwx $dir
- done
- mkdir -p $DESTDIR/packages
- >> $DESTDIR/state/points.log
- if ! [ -f $DESTDIR/assigned.txt ]; then
- hd $DESTDIR/assigned.txt
- fi
-
- mkdir -p $DESTDIR/www
- ln -sf ../state/points.json $DESTDIR/www
- ln -sf ../state/puzzles.json $DESTDIR/www
-}
-
-
-echo "Figuring out web user..."
-for www in www-data http tc _ _www; do
- id $www && break
-done
-if [ $www = _ ]; then
- echo "Unable to determine httpd user on this system. Dying."
- exit 1
-fi
-
-mkdir -p $DESTDIR || exit 1
-
-setup
-git $SRCDIR ls-files | while read fn; do
- case "$fn" in
- example-puzzles/*|tools/*|docs/*|install.sh|setup.cfg|README.md|.gitignore|src/mothd)
- true # skip
- ;;
- www/*)
- copy $fn $DESTDIR/
- ;;
- bin/*)
- copy $fn $DESTDIR/
- ;;
- *)
- echo "??? $fn"
- ;;
- esac
-done
-
-echo "All done installing."
diff --git a/res/basic.css b/res/basic.css
new file mode 100644
index 0000000..a8a856e
--- /dev/null
+++ b/res/basic.css
@@ -0,0 +1,48 @@
+/* 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;
+}
+#scoreboard .category {
+ border: solid white 1px;
+ display: inline-block;
+}
+nav {
+ border: solid black 2px;
+}
+nav ul, .category ul {
+ padding: 1em;
+}
+nav li, .category li {
+ display: inline;
+ margin: 1em;
+}
diff --git a/res/index.html b/res/index.html
new file mode 100644
index 0000000..803cd7f
--- /dev/null
+++ b/res/index.html
@@ -0,0 +1,34 @@
+
+
+
+ Welcome
+
+
+
+
+
+
Welcome
+
+
Register your team
+
+
+
+
+ If someone on your team has already registered,
+ proceed to the
+ puzzles overview.
+