mirror of https://github.com/dirtbags/moth.git
Merge branch 'golang' into mothv3
This commit is contained in:
commit
11939e7ce6
|
@ -1,14 +0,0 @@
|
||||||
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"]
|
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
FROM alpine
|
|
||||||
|
|
||||||
RUN apk --no-cache add python3 py3-pillow
|
|
||||||
|
|
||||||
COPY tools/package-puzzles.py tools/moth.py tools/mistune.py tools/answer_words.txt /moth/
|
|
||||||
|
|
||||||
ENTRYPOINT ["python3", "/moth/package-puzzles.py"]
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
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" ]
|
154
README.md
154
README.md
|
@ -8,7 +8,9 @@ which in the past has been called
|
||||||
"HACK",
|
"HACK",
|
||||||
"Queen Of The Hill",
|
"Queen Of The Hill",
|
||||||
"Cyber Spark",
|
"Cyber Spark",
|
||||||
and "Cyber Fire".
|
"Cyber Fire",
|
||||||
|
"Cyber Fire Puzzles",
|
||||||
|
and "Cyber Fire Foundry".
|
||||||
|
|
||||||
Information about these events is at
|
Information about these events is at
|
||||||
http://dirtbags.net/contest/
|
http://dirtbags.net/contest/
|
||||||
|
@ -18,12 +20,33 @@ It also tracks scores,
|
||||||
and comes with a JavaScript-based scoreboard to display team rankings.
|
and comes with a JavaScript-based scoreboard to display team rankings.
|
||||||
|
|
||||||
|
|
||||||
How everything works
|
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/mothd
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
This section wound up being pretty long.
|
|
||||||
Please check out [the overview](docs/overview.md)
|
|
||||||
for details.
|
|
||||||
|
|
||||||
|
|
||||||
Getting Started Developing
|
Getting Started Developing
|
||||||
|
@ -48,75 +71,74 @@ More on how the devel sever works in
|
||||||
Running A Production Server
|
Running A Production Server
|
||||||
====================
|
====================
|
||||||
|
|
||||||
XXX: Update this
|
Run `dirtbags/moth` (Docker) or `mothd` (native).
|
||||||
|
|
||||||
How to install it
|
`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/`.
|
||||||
|
|
||||||
It's made to be virtualized,
|
You can override any path with an option,
|
||||||
so you can run multiple contests at once if you want.
|
run `mothd -help` for usage.
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
How to run a web server
|
State Directory
|
||||||
-----------------------------
|
===============
|
||||||
|
|
||||||
Your web server needs to serve up files for you contest out of
|
|
||||||
`$mothinst/www`.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
`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://woozle.org/neale/g.cgi/net/eris/about/
|
|
||||||
|
|
||||||
$ mothinst=/srv/moth/mycontest
|
|
||||||
$ $mothinst/bin/httpd
|
|
||||||
|
|
||||||
|
|
||||||
Installing Puzzle Categories
|
Pausing scoring
|
||||||
------------------------------------
|
-------------------
|
||||||
|
|
||||||
Puzzle categories are distributed in a different way than the server.
|
Create the file `state/disabled`
|
||||||
After setting up (see above), just run
|
to pause scoring,
|
||||||
|
and remove it to resume.
|
||||||
|
You can use the Unix `touch` command to create the file:
|
||||||
|
|
||||||
$ /srv/koth/mycontest/bin/install-category /path/to/my/category
|
touch state/disabled
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
Permissions
|
Resetting an instance
|
||||||
----------------
|
-------------------
|
||||||
|
|
||||||
|
Remove the file `state/initialized`,
|
||||||
|
and the server will zap everything.
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
|
||||||
|
|
16
bin/httpd
16
bin/httpd
|
@ -1,16 +0,0 @@
|
||||||
#!/bin/sh -e
|
|
||||||
|
|
||||||
# Starts a standalone server using tcpsvd and eris
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
cd $(dirname $0)/../www
|
|
||||||
tcpserver -RHI localhost -u $www -g $www 0 80 eris -c -.
|
|
||||||
|
|
|
@ -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
|
|
37
bin/new
37
bin/new
|
@ -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/
|
|
95
bin/once
95
bin/once
|
@ -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 </dev/urandom | awk '{print $3 $4 $5 $6;}' | head -n 100 > 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
|
|
45
bin/points
45
bin/points
|
@ -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')
|
|
53
bin/puzzles
53
bin/puzzles
|
@ -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')
|
|
|
@ -1,5 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Starts a standalone server using tcpsvd and eris
|
|
||||||
|
|
||||||
tcpserver
|
|
273
docs/overview.md
273
docs/overview.md
|
@ -16,8 +16,142 @@ indicating score within each category,
|
||||||
and overall ranking.
|
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:
|
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.
|
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".
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Welcome</title>
|
||||||
|
<link rel="stylesheet" href="basic.css">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<link rel="icon" href="res/icon.svg" type="image/svg+xml"
|
||||||
|
><link rel="icon" href="res/icon.png" type="image/png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Welcome</h1>
|
||||||
|
<section>
|
||||||
|
<h2>Register your team</h2>
|
||||||
|
|
||||||
|
<form action="register" method="post">
|
||||||
|
Team ID: <input name="id"> <br>
|
||||||
|
Team name: <input name="name">
|
||||||
|
<input type="submit" value="Register">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If someone on your team has already registered,
|
||||||
|
proceed to the
|
||||||
|
<a href="puzzle-list.html">puzzles overview</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href="puzzle-list.html">Puzzles</a></li>
|
||||||
|
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,81 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Open Puzzles</title>
|
||||||
|
<link rel="stylesheet" href="basic.css">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<link rel="icon" href="res/icon.svg" type="image/svg+xml">
|
||||||
|
<link rel="icon" href="res/icon.png" type="image/png">
|
||||||
|
<script>
|
||||||
|
|
||||||
|
function render(obj) {
|
||||||
|
puzzlesElement = document.createElement('div');
|
||||||
|
let cats = [];
|
||||||
|
for (let cat in obj) {
|
||||||
|
cats.push(cat);
|
||||||
|
console.log(cat);
|
||||||
|
}
|
||||||
|
cats.sort();
|
||||||
|
|
||||||
|
for (let cat of cats) {
|
||||||
|
let puzzles = obj[cat];
|
||||||
|
|
||||||
|
let pdiv = document.createElement('div');
|
||||||
|
pdiv.className = 'category';
|
||||||
|
|
||||||
|
let h = document.createElement('h2');
|
||||||
|
pdiv.appendChild(h);
|
||||||
|
h.textContent = cat;
|
||||||
|
|
||||||
|
let l = document.createElement('ul');
|
||||||
|
pdiv.appendChild(l);
|
||||||
|
|
||||||
|
for (var puzzle of puzzles) {
|
||||||
|
var points = puzzle[0];
|
||||||
|
var id = puzzle[1];
|
||||||
|
|
||||||
|
var i = document.createElement('li');
|
||||||
|
l.appendChild(i);
|
||||||
|
|
||||||
|
if (points === 0) {
|
||||||
|
i.textContent = "✿";
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
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);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 class="Success">Open Puzzles</h1>
|
||||||
|
<section>
|
||||||
|
<div id="puzzles"></div>
|
||||||
|
</section>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href="puzzle-list.html">Puzzles</a></li>
|
||||||
|
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,58 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Puzzle</title>
|
||||||
|
<link rel="stylesheet" href="basic.css">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<link rel="icon" href="res/icon.svg" type="image/svg+xml">
|
||||||
|
<link rel="icon" href="res/icon.png" type="image/png">
|
||||||
|
<script>
|
||||||
|
function render(obj) {
|
||||||
|
let body = document.getElementById("body");
|
||||||
|
body.innerHTML = obj.body;
|
||||||
|
console.log("XXX: Munge relative URLs (src= and href=) in body")
|
||||||
|
}
|
||||||
|
function init() {
|
||||||
|
let params = new URLSearchParams(window.location.search);
|
||||||
|
let categoryName = params.get("cat");
|
||||||
|
let points = params.get("points");
|
||||||
|
let puzzleId = params.get("pid");
|
||||||
|
|
||||||
|
let fn = "content/" + categoryName + "/" + puzzleId + "/puzzle.json";
|
||||||
|
|
||||||
|
fetch(fn)
|
||||||
|
.then(function(resp) {
|
||||||
|
return resp.json();
|
||||||
|
}).then(function(obj) {
|
||||||
|
render(obj);
|
||||||
|
}).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;
|
||||||
|
}
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Puzzle</h1>
|
||||||
|
<section>
|
||||||
|
<div id="body">Loading...</div>
|
||||||
|
</section>
|
||||||
|
<form action="answer" method="post">
|
||||||
|
<input type="hidden" name="cat">
|
||||||
|
<input type="hidden" name="points">
|
||||||
|
Team ID: <input type="text" name="id"> <br>
|
||||||
|
Answer: <input type="text" name="answer"> <br>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href="puzzle-list.html">Puzzles</a></li>
|
||||||
|
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,148 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Scoreboard</title>
|
||||||
|
<link rel="stylesheet" href="basic.css">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<link rel="icon" href="res/icon.svg" type="image/svg+xml">
|
||||||
|
<link rel="icon" href="res/icon.png" type="image/png">
|
||||||
|
<script>
|
||||||
|
function loadJSON(url, callback) {
|
||||||
|
function loaded(e) {
|
||||||
|
callback(e.target.response);
|
||||||
|
}
|
||||||
|
var xhr = new XMLHttpRequest()
|
||||||
|
xhr.onload = loaded;
|
||||||
|
xhr.open("GET", url, true);
|
||||||
|
xhr.responseType = "json";
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreboard(element, continuous) {
|
||||||
|
function update(state) {
|
||||||
|
var teamnames = state["teams"];
|
||||||
|
var pointslog = state["points"];
|
||||||
|
var pointshistory = JSON.parse(localStorage.getItem("pointshistory")) || [];
|
||||||
|
if (pointshistory.length >= 20){
|
||||||
|
pointshistory.shift();
|
||||||
|
}
|
||||||
|
pointshistory.push(pointslog);
|
||||||
|
localStorage.setItem("pointshistory", JSON.stringify(pointshistory));
|
||||||
|
var highscore = {};
|
||||||
|
var teams = {};
|
||||||
|
|
||||||
|
// Dole out points
|
||||||
|
for (var i in pointslog) {
|
||||||
|
var entry = pointslog[i];
|
||||||
|
var timestamp = entry[0];
|
||||||
|
var teamhash = entry[1];
|
||||||
|
var category = entry[2];
|
||||||
|
var points = entry[3];
|
||||||
|
|
||||||
|
var team = teams[teamhash] || {__hash__: teamhash};
|
||||||
|
|
||||||
|
// Add points to team's points for that category
|
||||||
|
team[category] = (team[category] || 0) + points;
|
||||||
|
|
||||||
|
// Record highest score in a category
|
||||||
|
highscore[category] = Math.max(highscore[category] || 0, team[category]);
|
||||||
|
|
||||||
|
teams[teamhash] = team;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by team score
|
||||||
|
function teamScore(t) {
|
||||||
|
var score = 0;
|
||||||
|
|
||||||
|
for (var category in highscore) {
|
||||||
|
score += (t[category] || 0) / highscore[category];
|
||||||
|
}
|
||||||
|
// XXX: This function really shouldn't have side effects.
|
||||||
|
t.__score__ = score;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
function teamCompare(a, b) {
|
||||||
|
return teamScore(a) - teamScore(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
var winners = [];
|
||||||
|
for (var i in teams) {
|
||||||
|
winners.push(teams[i]);
|
||||||
|
}
|
||||||
|
if (winners.length == 0) {
|
||||||
|
// No teams!
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
winners.sort(teamCompare);
|
||||||
|
winners.reverse();
|
||||||
|
|
||||||
|
// Clear out the element we're about to populate
|
||||||
|
while (element.lastChild) {
|
||||||
|
element.removeChild(element.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate!
|
||||||
|
var topActualScore = winners[0].__score__;
|
||||||
|
|
||||||
|
// (100 / ncats) * (ncats / topActualScore);
|
||||||
|
var maxWidth = 100 / topActualScore;
|
||||||
|
for (var i in winners) {
|
||||||
|
var team = winners[i];
|
||||||
|
var row = document.createElement("div");
|
||||||
|
var ncat = 0;
|
||||||
|
for (var category in highscore) {
|
||||||
|
var catHigh = highscore[category];
|
||||||
|
var catTeam = team[category] || 0;
|
||||||
|
var catPct = catTeam / catHigh;
|
||||||
|
var width = maxWidth * catPct;
|
||||||
|
|
||||||
|
var bar = document.createElement("span");
|
||||||
|
bar.classList.add("category");
|
||||||
|
bar.classList.add("cat" + ncat);
|
||||||
|
bar.style.width = width + "%";
|
||||||
|
bar.textContent = category + ": " + catTeam;
|
||||||
|
bar.title = bar.textContent;
|
||||||
|
|
||||||
|
row.appendChild(bar);
|
||||||
|
ncat += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var te = document.createElement("span");
|
||||||
|
te.classList.add("teamname");
|
||||||
|
te.textContent = teamnames[team.__hash__];
|
||||||
|
row.appendChild(te);
|
||||||
|
|
||||||
|
element.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function once() {
|
||||||
|
loadJSON("points.json", update);
|
||||||
|
}
|
||||||
|
if (continuous) {
|
||||||
|
setInterval(once, 60000);
|
||||||
|
}
|
||||||
|
once();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var sb = document.getElementById("scoreboard");
|
||||||
|
scoreboard(sb, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 class="Success">Scoreboard</h1>
|
||||||
|
<section>
|
||||||
|
<div id="scoreboard"></div>
|
||||||
|
</section>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href="puzzle-list.html">Puzzles</a></li>
|
||||||
|
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,72 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Award struct {
|
||||||
|
When time.Time
|
||||||
|
TeamId string
|
||||||
|
Category string
|
||||||
|
Points int
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseAward(s string) (*Award, error) {
|
||||||
|
ret := Award{}
|
||||||
|
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
|
||||||
|
var whenEpoch int64
|
||||||
|
|
||||||
|
n, err := fmt.Sscanf(s, "%d %s %s %d", &whenEpoch, &ret.TeamId, &ret.Category, &ret.Points)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if n != 4 {
|
||||||
|
return nil, fmt.Errorf("Malformed award string: only parsed %d fields", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.When = time.Unix(whenEpoch, 0)
|
||||||
|
|
||||||
|
return &ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Award) String() string {
|
||||||
|
return fmt.Sprintf("%d %s %s %d", a.When.Unix(), a.TeamId, a.Category, a.Points)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Award) MarshalJSON() ([]byte, error) {
|
||||||
|
if a == nil {
|
||||||
|
return []byte("null"), nil
|
||||||
|
}
|
||||||
|
jTeamId, err := json.Marshal(a.TeamId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jCategory, err := json.Marshal(a.Category)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ret := fmt.Sprintf(
|
||||||
|
"[%d,%s,%s,%d]",
|
||||||
|
a.When.Unix(),
|
||||||
|
jTeamId,
|
||||||
|
jCategory,
|
||||||
|
a.Points,
|
||||||
|
)
|
||||||
|
return []byte(ret), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Award) Same(o *Award) bool {
|
||||||
|
switch {
|
||||||
|
case a.TeamId != o.TeamId:
|
||||||
|
return false
|
||||||
|
case a.Category != o.Category:
|
||||||
|
return false
|
||||||
|
case a.Points != o.Points:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAward(t *testing.T) {
|
||||||
|
entry := "1536958399 1a2b3c4d counting 1"
|
||||||
|
a, err := ParseAward(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.TeamId != "1a2b3c4d" {
|
||||||
|
t.Error("TeamID parsed wrong")
|
||||||
|
}
|
||||||
|
if a.Category != "counting" {
|
||||||
|
t.Error("Category parsed wrong")
|
||||||
|
}
|
||||||
|
if a.Points != 1 {
|
||||||
|
t.Error("Points parsed wrong")
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.String() != entry {
|
||||||
|
t.Error("String conversion wonky")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ParseAward("bad bad bad 1"); err == nil {
|
||||||
|
t.Error("Not throwing error on bad timestamp")
|
||||||
|
}
|
||||||
|
if _, err := ParseAward("1 bad bad bad"); err == nil {
|
||||||
|
t.Error("Not throwing error on bad points")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,272 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func respond(w http.ResponseWriter, req *http.Request, status Status, short string, format string, a ...interface{}) {
|
||||||
|
long := fmt.Sprintf(format, a...)
|
||||||
|
// This is a kludge. Do proper parsing when this causes problems.
|
||||||
|
accept := req.Header.Get("Accept")
|
||||||
|
if strings.Contains(accept, "application/json") {
|
||||||
|
ShowJSend(w, status, short, long)
|
||||||
|
} else {
|
||||||
|
ShowHtml(w, status, short, long)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasLine returns true if line appears in r.
|
||||||
|
// The entire line must match.
|
||||||
|
func hasLine(r io.Reader, line string) bool {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
for scanner.Scan() {
|
||||||
|
if scanner.Text() == line {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
|
teamname := req.FormValue("name")
|
||||||
|
teamid := req.FormValue("id")
|
||||||
|
|
||||||
|
// Keep foolish operators from shooting themselves in the foot
|
||||||
|
// You would have to add a pathname to your list of Team IDs to open this vulnerability,
|
||||||
|
// but I have learned not to overestimate people.
|
||||||
|
if strings.Contains(teamid, "../") {
|
||||||
|
teamid = "rodney"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teamid == "") || (teamname == "") {
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Invalid Entry",
|
||||||
|
"Either `id` or `name` was missing from this request.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
teamids, err := os.Open(ctx.StatePath("teamids.txt"))
|
||||||
|
if err != nil {
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Cannot read valid team IDs",
|
||||||
|
"An error was encountered trying to read valid teams IDs: %v", err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer teamids.Close()
|
||||||
|
if !hasLine(teamids, teamid) {
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Invalid Team ID",
|
||||||
|
"I don't have a record of that team ID. Maybe you used capital letters accidentally?",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(ctx.StatePath("teams", teamid), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Registration failed",
|
||||||
|
"Unable to register. Perhaps a teammate has already registered?",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
fmt.Fprintln(f, teamname)
|
||||||
|
respond(
|
||||||
|
w, req, Success,
|
||||||
|
"Team registered",
|
||||||
|
"Okay, your team has been named and you may begin using your team ID!",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) tokenHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
|
teamid := req.FormValue("id")
|
||||||
|
token := req.FormValue("token")
|
||||||
|
|
||||||
|
var category string
|
||||||
|
var points int
|
||||||
|
var fluff string
|
||||||
|
|
||||||
|
stoken := strings.Replace(token, ":", " ", 2)
|
||||||
|
n, err := fmt.Sscanf(stoken, "%s %d %s", &category, &points, &fluff)
|
||||||
|
if err != nil || n != 3 {
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Malformed token",
|
||||||
|
"That doesn't look like a token: %v.", err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category == "") || (points <= 0) {
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Weird token",
|
||||||
|
"That token doesn't make any sense.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := ctx.OpenCategoryFile(category, "tokens.txt")
|
||||||
|
if err != nil {
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Cannot list valid tokens",
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Make sure the token is in the list
|
||||||
|
if !hasLine(f, token) {
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Unrecognized token",
|
||||||
|
"I don't recognize that token. Did you type in the whole thing?",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.AwardPoints(teamid, category, points); err != nil {
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Error awarding points",
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(
|
||||||
|
w, req, Success,
|
||||||
|
"Points awarded",
|
||||||
|
"%d points for %s!", points, teamid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
|
teamid := req.FormValue("id")
|
||||||
|
category := req.FormValue("cat")
|
||||||
|
pointstr := req.FormValue("points")
|
||||||
|
answer := req.FormValue("answer")
|
||||||
|
|
||||||
|
points, err := strconv.Atoi(pointstr)
|
||||||
|
if err != nil {
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Cannot parse point value",
|
||||||
|
"This doesn't look like an integer: %s", pointstr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
haystack, err := ctx.OpenCategoryFile(category, "answers.txt")
|
||||||
|
if err != nil {
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Cannot list answers",
|
||||||
|
"Unable to read the list of answers for this category.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer haystack.Close()
|
||||||
|
|
||||||
|
// Look for the answer
|
||||||
|
needle := fmt.Sprintf("%d %s", points, answer)
|
||||||
|
if !hasLine(haystack, needle) {
|
||||||
|
respond(
|
||||||
|
w, req, Fail,
|
||||||
|
"Wrong answer",
|
||||||
|
"That is not the correct answer for %s %d.", category, points,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.AwardPoints(teamid, category, points); err != nil {
|
||||||
|
respond(
|
||||||
|
w, req, Error,
|
||||||
|
"Error awarding points",
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(
|
||||||
|
w, req, Success,
|
||||||
|
"Points awarded",
|
||||||
|
fmt.Sprintf("%d points for %s!", points, teamid),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(ctx.jPuzzleList)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) pointsHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(ctx.jPointsLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) contentHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
|
// Prevent directory traversal
|
||||||
|
if strings.Contains(req.URL.Path, "/.") {
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Be clever: use only the last three parts of the path. This may prove to be a bad idea.
|
||||||
|
parts := strings.Split(req.URL.Path, "/")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := parts[len(parts)-1]
|
||||||
|
puzzleId := parts[len(parts)-2]
|
||||||
|
categoryName := parts[len(parts)-3]
|
||||||
|
|
||||||
|
mb, ok := ctx.Categories[categoryName]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mbFilename := fmt.Sprintf("content/%s/%s", puzzleId, fileName)
|
||||||
|
mf, err := mb.Open(mbFilename)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer mf.Close()
|
||||||
|
|
||||||
|
http.ServeContent(w, req, fileName, mf.ModTime(), mf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) staticHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ServeStatic(w, req, ctx.ResourcesDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) BindHandlers(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc(ctx.Base+"/", ctx.staticHandler)
|
||||||
|
mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler)
|
||||||
|
mux.HandleFunc(ctx.Base+"/token", ctx.tokenHandler)
|
||||||
|
mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler)
|
||||||
|
mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler)
|
||||||
|
mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler)
|
||||||
|
mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler)
|
||||||
|
}
|
|
@ -0,0 +1,172 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Instance struct {
|
||||||
|
Base string
|
||||||
|
MothballDir string
|
||||||
|
StateDir string
|
||||||
|
ResourcesDir string
|
||||||
|
Categories map[string]*Mothball
|
||||||
|
update chan bool
|
||||||
|
jPuzzleList []byte
|
||||||
|
jPointsLog []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) {
|
||||||
|
ctx := &Instance{
|
||||||
|
Base: strings.TrimRight(base, "/"),
|
||||||
|
MothballDir: mothballDir,
|
||||||
|
StateDir: stateDir,
|
||||||
|
ResourcesDir: resourcesDir,
|
||||||
|
Categories: map[string]*Mothball{},
|
||||||
|
update: make(chan bool, 10),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll over and die if directories aren't even set up
|
||||||
|
if _, err := os.Stat(mothballDir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(stateDir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.MaybeInitialize()
|
||||||
|
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) MaybeInitialize() {
|
||||||
|
// Only do this if it hasn't already been done
|
||||||
|
if _, err := os.Stat(ctx.StatePath("initialized")); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Print("initialized file missing, re-initializing")
|
||||||
|
|
||||||
|
// Remove any extant control and state files
|
||||||
|
os.Remove(ctx.StatePath("until"))
|
||||||
|
os.Remove(ctx.StatePath("disabled"))
|
||||||
|
os.Remove(ctx.StatePath("points.log"))
|
||||||
|
os.RemoveAll(ctx.StatePath("points.tmp"))
|
||||||
|
os.RemoveAll(ctx.StatePath("points.new"))
|
||||||
|
os.RemoveAll(ctx.StatePath("teams"))
|
||||||
|
|
||||||
|
// Make sure various subdirectories exist
|
||||||
|
os.Mkdir(ctx.StatePath("points.tmp"), 0755)
|
||||||
|
os.Mkdir(ctx.StatePath("points.new"), 0755)
|
||||||
|
os.Mkdir(ctx.StatePath("teams"), 0755)
|
||||||
|
|
||||||
|
// Preseed available team ids if file doesn't exist
|
||||||
|
if f, err := os.OpenFile(ctx.StatePath("teamids.txt"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
|
||||||
|
defer f.Close()
|
||||||
|
for i := 0; i <= 9999; i += 1 {
|
||||||
|
fmt.Fprintf(f, "%04d\n", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initialized file that signals whether we're set up
|
||||||
|
f, err := os.OpenFile(ctx.StatePath("initialized"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
fmt.Fprintln(f, "Remove this file to reinitialize the contest")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx Instance) MothballPath(parts ...string) string {
|
||||||
|
tail := path.Join(parts...)
|
||||||
|
return path.Join(ctx.MothballDir, tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) StatePath(parts ...string) string {
|
||||||
|
tail := path.Join(parts...)
|
||||||
|
return path.Join(ctx.StateDir, tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) PointsLog() []*Award {
|
||||||
|
var ret []*Award
|
||||||
|
|
||||||
|
fn := ctx.StatePath("points.log")
|
||||||
|
f, err := os.Open(fn)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Unable to open %s: %s", fn, err)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
cur, err := ParseAward(line)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Skipping malformed award line %s: %s", line, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ret = append(ret, cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// awardPoints gives points to teamid in category.
|
||||||
|
// It first checks to make sure these are not duplicate points.
|
||||||
|
// This is not a perfect check, you can trigger a race condition here.
|
||||||
|
// It's just a courtesy to the user.
|
||||||
|
// The maintenance task makes sure we never have duplicate points in the log.
|
||||||
|
func (ctx *Instance) AwardPoints(teamid, category string, points int) error {
|
||||||
|
a := Award{
|
||||||
|
When: time.Now(),
|
||||||
|
TeamId: teamid,
|
||||||
|
Category: category,
|
||||||
|
Points: points,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range ctx.PointsLog() {
|
||||||
|
if a.Same(e) {
|
||||||
|
return fmt.Errorf("Points already awarded to this team in this category")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn := fmt.Sprintf("%s-%s-%d", teamid, category, points)
|
||||||
|
tmpfn := ctx.StatePath("points.tmp", fn)
|
||||||
|
newfn := ctx.StatePath("points.new", fn)
|
||||||
|
|
||||||
|
if err := ioutil.WriteFile(tmpfn, []byte(a.String()), 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmpfn, newfn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.update <- true
|
||||||
|
log.Printf("Award %s %s %d", teamid, category, points)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) OpenCategoryFile(category string, parts ...string) (io.ReadCloser, error) {
|
||||||
|
mb, ok := ctx.Categories[category]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("No such category: %s", category)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := path.Join(parts...)
|
||||||
|
f, err := mb.Open(filename)
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) TeamName(teamId string) (string, error) {
|
||||||
|
teamNameBytes, err := ioutil.ReadFile(ctx.StatePath("teams", teamId))
|
||||||
|
teamName := strings.TrimSpace(string(teamNameBytes))
|
||||||
|
return teamName, err
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
package main
|
|
@ -0,0 +1,238 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PuzzleMap struct {
|
||||||
|
Points int
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PuzzleMap) MarshalJSON() ([]byte, error) {
|
||||||
|
if pm == nil {
|
||||||
|
return []byte("null"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jPath, err := json.Marshal(pm.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ret := fmt.Sprintf("[%d,%s]", pm.Points, string(jPath))
|
||||||
|
return []byte(ret), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) generatePuzzleList() error {
|
||||||
|
maxByCategory := map[string]int{}
|
||||||
|
for _, a := range ctx.PointsLog() {
|
||||||
|
if a.Points > maxByCategory[a.Category] {
|
||||||
|
maxByCategory[a.Category] = a.Points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ret := map[string][]PuzzleMap{}
|
||||||
|
for catName, mb := range ctx.Categories {
|
||||||
|
mf, err := mb.Open("map.txt")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer mf.Close()
|
||||||
|
|
||||||
|
pm := make([]PuzzleMap, 0, 30)
|
||||||
|
completed := true
|
||||||
|
scanner := bufio.NewScanner(mf)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
|
||||||
|
var pointval int
|
||||||
|
var dir string
|
||||||
|
|
||||||
|
n, err := fmt.Sscanf(line, "%d %s", &pointval, &dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if n != 2 {
|
||||||
|
return fmt.Errorf("Parsing map for %s: short read", catName)
|
||||||
|
}
|
||||||
|
|
||||||
|
pm = append(pm, PuzzleMap{pointval, dir})
|
||||||
|
|
||||||
|
if pointval > maxByCategory[catName] {
|
||||||
|
completed = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if completed {
|
||||||
|
pm = append(pm, PuzzleMap{0, ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
ret[catName] = pm
|
||||||
|
}
|
||||||
|
|
||||||
|
jpl, err := json.Marshal(ret)
|
||||||
|
if err == nil {
|
||||||
|
ctx.jPuzzleList = jpl
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *Instance) generatePointsLog() error {
|
||||||
|
var ret struct {
|
||||||
|
Teams map[string]string `json:"teams"`
|
||||||
|
Points []*Award `json:"points"`
|
||||||
|
}
|
||||||
|
ret.Teams = map[string]string{}
|
||||||
|
ret.Points = ctx.PointsLog()
|
||||||
|
|
||||||
|
teamNumbersById := map[string]int{}
|
||||||
|
for nr, a := range ret.Points {
|
||||||
|
teamNumber, ok := teamNumbersById[a.TeamId]
|
||||||
|
if !ok {
|
||||||
|
teamName, err := ctx.TeamName(a.TeamId)
|
||||||
|
if err != nil {
|
||||||
|
teamName = "[unregistered]"
|
||||||
|
}
|
||||||
|
teamNumber = nr
|
||||||
|
teamNumbersById[a.TeamId] = teamNumber
|
||||||
|
ret.Teams[strconv.FormatInt(int64(teamNumber), 16)] = teamName
|
||||||
|
}
|
||||||
|
a.TeamId = strconv.FormatInt(int64(teamNumber), 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
jpl, err := json.Marshal(ret)
|
||||||
|
if err == nil {
|
||||||
|
ctx.jPointsLog = jpl
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// maintenance runs
|
||||||
|
func (ctx *Instance) tidy() {
|
||||||
|
// Do they want to reset everything?
|
||||||
|
ctx.MaybeInitialize()
|
||||||
|
|
||||||
|
// Skip if we've expired
|
||||||
|
untilspec, err := ioutil.ReadFile(ctx.StatePath("until"))
|
||||||
|
if err == nil {
|
||||||
|
until, err := time.Parse(time.RFC3339, string(untilspec))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Unparseable date in until file: %v", until)
|
||||||
|
} else {
|
||||||
|
if until.Before(time.Now()) {
|
||||||
|
log.Print("until file time reached, suspending maintenance")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh all current categories
|
||||||
|
for categoryName, mb := range ctx.Categories {
|
||||||
|
if err := mb.Refresh(); err != nil {
|
||||||
|
// Backing file vanished: remove this category
|
||||||
|
log.Printf("Removing category: %s: %s", categoryName, err)
|
||||||
|
mb.Close()
|
||||||
|
delete(ctx.Categories, categoryName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any new categories?
|
||||||
|
files, err := ioutil.ReadDir(ctx.MothballPath())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error listing mothballs: %s", err)
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
filename := f.Name()
|
||||||
|
filepath := ctx.MothballPath(filename)
|
||||||
|
if !strings.HasSuffix(filename, ".mb") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
categoryName := strings.TrimSuffix(filename, ".mb")
|
||||||
|
|
||||||
|
if _, ok := ctx.Categories[categoryName]; !ok {
|
||||||
|
mb, err := OpenMothball(filepath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error opening %s: %s", filepath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("New category: %s", filename)
|
||||||
|
ctx.Categories[categoryName] = mb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectPoints gathers up files in points.new/ and appends their contents to points.log,
|
||||||
|
// removing each points.new/ file as it goes.
|
||||||
|
func (ctx *Instance) collectPoints() {
|
||||||
|
logf, err := os.OpenFile(ctx.StatePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Can't append to points log: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer logf.Close()
|
||||||
|
|
||||||
|
files, err := ioutil.ReadDir(ctx.StatePath("points.new"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error reading packages: %s", err)
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
filename := ctx.StatePath("points.new", f.Name())
|
||||||
|
s, err := ioutil.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Can't read points file %s: %s", filename, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
award, err := ParseAward(string(s))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Can't parse award file %s: %s", filename, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicate := false
|
||||||
|
for _, e := range ctx.PointsLog() {
|
||||||
|
if award.Same(e) {
|
||||||
|
duplicate = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if duplicate {
|
||||||
|
log.Printf("Skipping duplicate points: %s", award.String())
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(logf, "%s\n", award.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
logf.Sync()
|
||||||
|
if err := os.Remove(filename); err != nil {
|
||||||
|
log.Printf("Unable to remove %s: %s", filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maintenance is the goroutine that runs a periodic maintenance task
|
||||||
|
func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) {
|
||||||
|
for {
|
||||||
|
// Skip if we've been disabled
|
||||||
|
if _, err := os.Stat(ctx.StatePath("disabled")); err == nil {
|
||||||
|
log.Print("disabled file found, suspending maintenance")
|
||||||
|
} else {
|
||||||
|
ctx.tidy()
|
||||||
|
ctx.collectPoints()
|
||||||
|
ctx.generatePuzzleList()
|
||||||
|
ctx.generatePointsLog()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.update:
|
||||||
|
// log.Print("Forced update")
|
||||||
|
case <-time.After(maintenanceInterval):
|
||||||
|
// log.Print("Housekeeping...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +0,0 @@
|
||||||
#! /bin/sh
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
/moth/bin/once
|
|
||||||
sleep 20
|
|
||||||
done &
|
|
||||||
|
|
||||||
cd /moth/www
|
|
||||||
s6-tcpserver -u $(id -u www) -g $(id -g www) 0.0.0.0 80 /usr/bin/eris -c -d -.
|
|
|
@ -0,0 +1,191 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Mothball struct {
|
||||||
|
zf *zip.ReadCloser
|
||||||
|
filename string
|
||||||
|
mtime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type MothballFile struct {
|
||||||
|
f io.ReadCloser
|
||||||
|
pos int64
|
||||||
|
zf *zip.File
|
||||||
|
io.Reader
|
||||||
|
io.Seeker
|
||||||
|
io.Closer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMothballFile(zf *zip.File) (*MothballFile, error) {
|
||||||
|
mf := &MothballFile{
|
||||||
|
zf: zf,
|
||||||
|
pos: 0,
|
||||||
|
f: nil,
|
||||||
|
}
|
||||||
|
if err := mf.reopen(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mf *MothballFile) reopen() error {
|
||||||
|
if mf.f != nil {
|
||||||
|
if err := mf.f.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := mf.zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mf.f = f
|
||||||
|
mf.pos = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mf *MothballFile) ModTime() time.Time {
|
||||||
|
return mf.zf.Modified
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mf *MothballFile) Read(p []byte) (int, error) {
|
||||||
|
n, err := mf.f.Read(p)
|
||||||
|
mf.pos += int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mf *MothballFile) Seek(offset int64, whence int) (int64, error) {
|
||||||
|
var pos int64
|
||||||
|
switch whence {
|
||||||
|
case io.SeekStart:
|
||||||
|
pos = offset
|
||||||
|
case io.SeekCurrent:
|
||||||
|
pos = mf.pos + int64(offset)
|
||||||
|
case io.SeekEnd:
|
||||||
|
pos = int64(mf.zf.UncompressedSize64) - int64(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pos < 0 {
|
||||||
|
return mf.pos, fmt.Errorf("Tried to seek %d before start of file", pos)
|
||||||
|
}
|
||||||
|
if pos >= int64(mf.zf.UncompressedSize64) {
|
||||||
|
// We don't need to decompress anything, we're at the end of the file
|
||||||
|
mf.f.Close()
|
||||||
|
mf.f = ioutil.NopCloser(strings.NewReader(""))
|
||||||
|
mf.pos = int64(mf.zf.UncompressedSize64)
|
||||||
|
return mf.pos, nil
|
||||||
|
}
|
||||||
|
if pos < mf.pos {
|
||||||
|
if err := mf.reopen(); err != nil {
|
||||||
|
return mf.pos, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
for pos > mf.pos {
|
||||||
|
l := pos - mf.pos
|
||||||
|
if l > int64(cap(buf)) {
|
||||||
|
l = int64(cap(buf)) - 1
|
||||||
|
}
|
||||||
|
p := buf[0:int(l)]
|
||||||
|
n, err := mf.Read(p)
|
||||||
|
if err != nil {
|
||||||
|
return mf.pos, err
|
||||||
|
} else if n <= 0 {
|
||||||
|
return mf.pos, fmt.Errorf("Short read (%d bytes)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mf.pos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mf *MothballFile) Close() error {
|
||||||
|
return mf.f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenMothball(filename string) (*Mothball, error) {
|
||||||
|
var m Mothball
|
||||||
|
|
||||||
|
m.filename = filename
|
||||||
|
|
||||||
|
err := m.Refresh()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mothball) Close() error {
|
||||||
|
return m.zf.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mothball) Refresh() error {
|
||||||
|
info, err := os.Stat(m.filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mtime := info.ModTime()
|
||||||
|
|
||||||
|
if !mtime.After(m.mtime) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
zf, err := zip.OpenReader(m.filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.zf != nil {
|
||||||
|
m.zf.Close()
|
||||||
|
}
|
||||||
|
m.zf = zf
|
||||||
|
m.mtime = mtime
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mothball) get(filename string) (*zip.File, error) {
|
||||||
|
for _, f := range m.zf.File {
|
||||||
|
if filename == f.Name {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("File not found: %s %s", m.filename, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mothball) Header(filename string) (*zip.FileHeader, error) {
|
||||||
|
f, err := m.get(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &f.FileHeader, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mothball) Open(filename string) (*MothballFile, error) {
|
||||||
|
f, err := m.get(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mf, err := NewMothballFile(f)
|
||||||
|
return mf, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mothball) ReadFile(filename string) ([]byte, error) {
|
||||||
|
f, err := m.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
bytes, err := ioutil.ReadAll(f)
|
||||||
|
return bytes, err
|
||||||
|
}
|
|
@ -1,67 +0,0 @@
|
||||||
package mothball
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Mothball struct {
|
|
||||||
zf *zip.ReadCloser
|
|
||||||
filename string
|
|
||||||
mtime time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func Open(filename string) (*Mothball, error) {
|
|
||||||
var m Mothball
|
|
||||||
|
|
||||||
m.filename = filename
|
|
||||||
|
|
||||||
err := m.Refresh()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Mothball) Close() (error) {
|
|
||||||
return m.zf.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Mothball) Refresh() (error) {
|
|
||||||
info, err := os.Stat(m.filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
mtime := info.ModTime()
|
|
||||||
|
|
||||||
if mtime == m.mtime {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
zf, err := zip.OpenReader(m.filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.zf != nil {
|
|
||||||
m.zf.Close()
|
|
||||||
}
|
|
||||||
m.zf = zf
|
|
||||||
m.mtime = mtime
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Mothball) Open(filename string) (io.ReadCloser, error) {
|
|
||||||
for _, f := range m.zf.File {
|
|
||||||
if filename == f.Name {
|
|
||||||
ret, err := f.Open()
|
|
||||||
return ret, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("File not found: %s in %s", filename, m.filename)
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
package mothball
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
@ -35,7 +35,7 @@ func TestMothball(t *testing.T) {
|
||||||
tf.Close()
|
tf.Close()
|
||||||
|
|
||||||
// Now read it in
|
// Now read it in
|
||||||
mb, err := Open(tf.Name())
|
mb, err := OpenMothball(tf.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
return
|
return
|
14
src/mothd
14
src/mothd
|
@ -1,14 +0,0 @@
|
||||||
#! /bin/sh
|
|
||||||
|
|
||||||
cd ${1:-$(dirname $0)}
|
|
||||||
KOTH_BASE=$(pwd)
|
|
||||||
|
|
||||||
echo "Running koth instances in $KOTH_BASE"
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
for i in $KOTH_BASE/*/assigned.txt; do
|
|
||||||
dir=${i%/*}
|
|
||||||
$dir/bin/once
|
|
||||||
done
|
|
||||||
sleep 5
|
|
||||||
done
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func logRequest(handler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("HTTP %s %s %s\n", r.RemoteAddr, r.Method, r.URL)
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setup() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
base := flag.String(
|
||||||
|
"base",
|
||||||
|
"/",
|
||||||
|
"Base URL of this instance",
|
||||||
|
)
|
||||||
|
mothballDir := flag.String(
|
||||||
|
"mothballs",
|
||||||
|
"/moth/mothballs",
|
||||||
|
"Path to read mothballs",
|
||||||
|
)
|
||||||
|
stateDir := flag.String(
|
||||||
|
"state",
|
||||||
|
"/moth/state",
|
||||||
|
"Path to write state",
|
||||||
|
)
|
||||||
|
resourcesDir := flag.String(
|
||||||
|
"resources",
|
||||||
|
"/moth/resources",
|
||||||
|
"Path to static resources (HTML, images, css, ...)",
|
||||||
|
)
|
||||||
|
maintenanceInterval := flag.Duration(
|
||||||
|
"maint",
|
||||||
|
20*time.Second,
|
||||||
|
"Maintenance interval",
|
||||||
|
)
|
||||||
|
listen := flag.String(
|
||||||
|
"listen",
|
||||||
|
":8080",
|
||||||
|
"[host]:port to bind and listen",
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if err := setup(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, err := NewInstance(*base, *mothballDir, *stateDir, *resourcesDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx.BindHandlers(http.DefaultServeMux)
|
||||||
|
|
||||||
|
// Add some MIME extensions
|
||||||
|
// Doing this avoids decompressing a mothball entry twice per request
|
||||||
|
mime.AddExtensionType(".json", "application/json")
|
||||||
|
mime.AddExtensionType(".zip", "application/zip")
|
||||||
|
|
||||||
|
go ctx.Maintenance(*maintenanceInterval)
|
||||||
|
|
||||||
|
log.Printf("Listening on %s", *listen)
|
||||||
|
log.Fatal(http.ListenAndServe(*listen, logRequest(http.DefaultServeMux)))
|
||||||
|
}
|
|
@ -0,0 +1,455 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Success = iota
|
||||||
|
Fail
|
||||||
|
Error
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShowJSend renders a JSend response to w
|
||||||
|
func ShowJSend(w http.ResponseWriter, status Status, short string, description string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK) // RFC2616 makes it pretty clear that 4xx codes are for the user-agent
|
||||||
|
|
||||||
|
statusStr := ""
|
||||||
|
switch status {
|
||||||
|
case Success:
|
||||||
|
statusStr = "success"
|
||||||
|
case Fail:
|
||||||
|
statusStr = "fail"
|
||||||
|
default:
|
||||||
|
statusStr = "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
jshort, _ := json.Marshal(short)
|
||||||
|
jdesc, _ := json.Marshal(description)
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
`{"status":"%s","data":{"short":%s,"description":%s}}"`,
|
||||||
|
statusStr, jshort, jdesc,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
statusStr := ""
|
||||||
|
switch status {
|
||||||
|
case Success:
|
||||||
|
statusStr = "Success"
|
||||||
|
case Fail:
|
||||||
|
statusStr = "Fail"
|
||||||
|
default:
|
||||||
|
statusStr = "Error"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "<!DOCTYPE html>")
|
||||||
|
fmt.Fprintf(w, "<html><head>")
|
||||||
|
fmt.Fprintf(w, "<title>%s</title>", title)
|
||||||
|
fmt.Fprintf(w, "<link rel=\"stylesheet\" href=\"basic.css\">")
|
||||||
|
fmt.Fprintf(w, "<meta name=\"viewport\" content=\"width=device-width\">")
|
||||||
|
fmt.Fprintf(w, "<link rel=\"icon\" href=\"res/icon.svg\" type=\"image/svg+xml\">")
|
||||||
|
fmt.Fprintf(w, "<link rel=\"icon\" href=\"res/icon.png\" type=\"image/png\">")
|
||||||
|
fmt.Fprintf(w, "</head><body><h1 class=\"%s\">%s</h1>", statusStr, title)
|
||||||
|
fmt.Fprintf(w, "<section>%s</section>", body)
|
||||||
|
fmt.Fprintf(w, "<nav>")
|
||||||
|
fmt.Fprintf(w, "<ul>")
|
||||||
|
fmt.Fprintf(w, "<li><a href=\"puzzle-list.html\">Puzzles</a></li>")
|
||||||
|
fmt.Fprintf(w, "<li><a href=\"scoreboard.html\">Scoreboard</a></li>")
|
||||||
|
fmt.Fprintf(w, "</ul>")
|
||||||
|
fmt.Fprintf(w, "</nav>")
|
||||||
|
fmt.Fprintf(w, "</body></html>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticIndex serves up a basic landing page
|
||||||
|
func staticIndex(w http.ResponseWriter) {
|
||||||
|
ShowHtml(
|
||||||
|
w, Success,
|
||||||
|
"Welcome",
|
||||||
|
`
|
||||||
|
<h2>Register your team</h2>
|
||||||
|
|
||||||
|
<form action="register" action="post">
|
||||||
|
Team ID: <input name="id"> <br>
|
||||||
|
Team name: <input name="name">
|
||||||
|
<input type="submit" value="Register">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If someone on your team has already registered,
|
||||||
|
proceed to the
|
||||||
|
<a href="puzzles.html">puzzles overview</a>.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticScoreboard(w http.ResponseWriter) {
|
||||||
|
ShowHtml(
|
||||||
|
w, Success,
|
||||||
|
"Scoreboard",
|
||||||
|
`
|
||||||
|
<div id="scoreboard"></div>
|
||||||
|
<script>
|
||||||
|
function loadJSON(url, callback) {
|
||||||
|
function loaded(e) {
|
||||||
|
callback(e.target.response);
|
||||||
|
}
|
||||||
|
var xhr = new XMLHttpRequest()
|
||||||
|
xhr.onload = loaded;
|
||||||
|
xhr.open("GET", url, true);
|
||||||
|
xhr.responseType = "json";
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreboard(element, continuous) {
|
||||||
|
function update(state) {
|
||||||
|
var teamnames = state["teams"];
|
||||||
|
var pointslog = state["points"];
|
||||||
|
var pointshistory = JSON.parse(localStorage.getItem("pointshistory")) || [];
|
||||||
|
if (pointshistory.length >= 20){
|
||||||
|
pointshistory.shift();
|
||||||
|
}
|
||||||
|
pointshistory.push(pointslog);
|
||||||
|
localStorage.setItem("pointshistory", JSON.stringify(pointshistory));
|
||||||
|
var highscore = {};
|
||||||
|
var teams = {};
|
||||||
|
|
||||||
|
// Dole out points
|
||||||
|
for (var i in pointslog) {
|
||||||
|
var entry = pointslog[i];
|
||||||
|
var timestamp = entry[0];
|
||||||
|
var teamhash = entry[1];
|
||||||
|
var category = entry[2];
|
||||||
|
var points = entry[3];
|
||||||
|
|
||||||
|
var team = teams[teamhash] || {__hash__: teamhash};
|
||||||
|
|
||||||
|
// Add points to team's points for that category
|
||||||
|
team[category] = (team[category] || 0) + points;
|
||||||
|
|
||||||
|
// Record highest score in a category
|
||||||
|
highscore[category] = Math.max(highscore[category] || 0, team[category]);
|
||||||
|
|
||||||
|
teams[teamhash] = team;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by team score
|
||||||
|
function teamScore(t) {
|
||||||
|
var score = 0;
|
||||||
|
|
||||||
|
for (var category in highscore) {
|
||||||
|
score += (t[category] || 0) / highscore[category];
|
||||||
|
}
|
||||||
|
// XXX: This function really shouldn't have side effects.
|
||||||
|
t.__score__ = score;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
function teamCompare(a, b) {
|
||||||
|
return teamScore(a) - teamScore(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
var winners = [];
|
||||||
|
for (var i in teams) {
|
||||||
|
winners.push(teams[i]);
|
||||||
|
}
|
||||||
|
if (winners.length == 0) {
|
||||||
|
// No teams!
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
winners.sort(teamCompare);
|
||||||
|
winners.reverse();
|
||||||
|
|
||||||
|
// Clear out the element we're about to populate
|
||||||
|
while (element.lastChild) {
|
||||||
|
element.removeChild(element.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate!
|
||||||
|
var topActualScore = winners[0].__score__;
|
||||||
|
|
||||||
|
// (100 / ncats) * (ncats / topActualScore);
|
||||||
|
var maxWidth = 100 / topActualScore;
|
||||||
|
for (var i in winners) {
|
||||||
|
var team = winners[i];
|
||||||
|
var row = document.createElement("div");
|
||||||
|
var ncat = 0;
|
||||||
|
for (var category in highscore) {
|
||||||
|
var catHigh = highscore[category];
|
||||||
|
var catTeam = team[category] || 0;
|
||||||
|
var catPct = catTeam / catHigh;
|
||||||
|
var width = maxWidth * catPct;
|
||||||
|
|
||||||
|
var bar = document.createElement("span");
|
||||||
|
bar.classList.add("cat" + ncat);
|
||||||
|
bar.style.width = width + "%";
|
||||||
|
bar.textContent = category + ": " + catTeam;
|
||||||
|
bar.title = bar.textContent;
|
||||||
|
|
||||||
|
row.appendChild(bar);
|
||||||
|
ncat += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var te = document.createElement("span");
|
||||||
|
te.classList.add("teamname");
|
||||||
|
te.textContent = teamnames[team.__hash__];
|
||||||
|
row.appendChild(te);
|
||||||
|
|
||||||
|
element.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function once() {
|
||||||
|
loadJSON("points.json", update);
|
||||||
|
}
|
||||||
|
if (continuous) {
|
||||||
|
setInterval(once, 60000);
|
||||||
|
}
|
||||||
|
once();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var sb = document.getElementById("scoreboard");
|
||||||
|
scoreboard(sb, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
</script>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticPuzzleList(w http.ResponseWriter) {
|
||||||
|
ShowHtml(
|
||||||
|
w, Success,
|
||||||
|
"Open Puzzles",
|
||||||
|
`
|
||||||
|
<section>
|
||||||
|
<div id="puzzles"></div>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
function render(obj) {
|
||||||
|
puzzlesElement = document.createElement('div');
|
||||||
|
let cats = [];
|
||||||
|
for (let cat in obj) {
|
||||||
|
cats.push(cat);
|
||||||
|
console.log(cat);
|
||||||
|
}
|
||||||
|
cats.sort();
|
||||||
|
|
||||||
|
for (let cat of cats) {
|
||||||
|
let puzzles = obj[cat];
|
||||||
|
|
||||||
|
let pdiv = document.createElement('div');
|
||||||
|
pdiv.className = 'category';
|
||||||
|
|
||||||
|
let h = document.createElement('h2');
|
||||||
|
pdiv.appendChild(h);
|
||||||
|
h.textContent = cat;
|
||||||
|
|
||||||
|
let l = document.createElement('ul');
|
||||||
|
pdiv.appendChild(l);
|
||||||
|
|
||||||
|
for (var puzzle of puzzles) {
|
||||||
|
var points = puzzle[0];
|
||||||
|
var id = puzzle[1];
|
||||||
|
|
||||||
|
var i = document.createElement('li');
|
||||||
|
l.appendChild(i);
|
||||||
|
|
||||||
|
if (points === 0) {
|
||||||
|
i.textContent = "✿";
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
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);
|
||||||
|
</script>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticPuzzle(w http.ResponseWriter) {
|
||||||
|
ShowHtml(
|
||||||
|
w, Success,
|
||||||
|
"Open Puzzles",
|
||||||
|
`
|
||||||
|
<section>
|
||||||
|
<div id="body">Loading...</div>
|
||||||
|
</section>
|
||||||
|
<form action="answer" method="post">
|
||||||
|
<input type="hidden" name="cat">
|
||||||
|
<input type="hidden" name="points">
|
||||||
|
Team ID: <input type="text" name="id"> <br>
|
||||||
|
Answer: <input type="text" name="answer"> <br>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
function render(obj) {
|
||||||
|
let body = document.getElementById("body");
|
||||||
|
body.innerHTML = obj.body;
|
||||||
|
console.log("XXX: Munge relative URLs (src= and href=) in body")
|
||||||
|
}
|
||||||
|
function init() {
|
||||||
|
let params = new URLSearchParams(window.location.search);
|
||||||
|
let categoryName = params.get("cat");
|
||||||
|
let points = params.get("points");
|
||||||
|
let puzzleId = params.get("pid");
|
||||||
|
|
||||||
|
let fn = "content/" + categoryName + "/" + puzzleId + "/puzzle.json";
|
||||||
|
|
||||||
|
fetch(fn)
|
||||||
|
.then(function(resp) {
|
||||||
|
return resp.json();
|
||||||
|
}).then(function(obj) {
|
||||||
|
render(obj);
|
||||||
|
}).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;
|
||||||
|
}
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
</script>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
14
tools/mothd
14
tools/mothd
|
@ -1,14 +0,0 @@
|
||||||
#! /bin/sh
|
|
||||||
|
|
||||||
cd ${1:-$(dirname $0)}
|
|
||||||
KOTH_BASE=$(pwd)
|
|
||||||
|
|
||||||
echo "Running koth instances in $KOTH_BASE"
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
for i in $KOTH_BASE/*/assigned.txt; do
|
|
||||||
dir=${i%/*}
|
|
||||||
$dir/bin/once
|
|
||||||
done
|
|
||||||
sleep 5
|
|
||||||
done
|
|
Loading…
Reference in New Issue