Merge branch 'golang' into mothv3

This commit is contained in:
Neale Pickett 2018-09-20 16:16:49 +00:00
commit f75e3882d9
40 changed files with 2117 additions and 612 deletions

View File

@ -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"]

View File

@ -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"]

8
Dockerfile.mothd Normal file
View File

@ -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" ]

156
README.md
View File

@ -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
Permissions 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.
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.

View File

@ -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 -.

View File

@ -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
View File

@ -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/

View File

@ -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

View File

@ -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')

View File

@ -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')

View File

@ -1,5 +0,0 @@
#!/bin/sh
# Starts a standalone server using tcpsvd and eris
tcpserver

View File

@ -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".

48
res/basic.css Normal file
View File

@ -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;
}

34
res/index.html Normal file
View File

@ -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>

81
res/puzzle-list.html Normal file
View File

@ -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>

58
res/puzzle.html Normal file
View File

@ -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>

148
res/scoreboard.html Normal file
View File

@ -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>

72
src/award.go Normal file
View File

@ -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
}

34
src/award_test.go Normal file
View File

@ -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")
}
}

272
src/handlers.go Normal file
View File

@ -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)
}

172
src/instance.go Normal file
View File

@ -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
}

1
src/instance_test.go Normal file
View File

@ -0,0 +1 @@
package main

238
src/maintenance.go Normal file
View File

@ -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...")
}
}
}

View File

@ -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 -.

191
src/mothball.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -1,4 +1,4 @@
package mothball package main
import ( import (
"archive/zip" "archive/zip"
@ -16,7 +16,7 @@ func TestMothball(t *testing.T) {
return return
} }
defer os.Remove(tf.Name()) defer os.Remove(tf.Name())
w := zip.NewWriter(tf) w := zip.NewWriter(tf)
f, err := w.Create("moo.txt") f, err := w.Create("moo.txt")
if err != nil { if err != nil {
@ -33,9 +33,9 @@ func TestMothball(t *testing.T) {
} }
w.Close() w.Close()
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
@ -46,7 +46,7 @@ func TestMothball(t *testing.T) {
t.Error(err) t.Error(err)
return return
} }
line := make([]byte, 200) line := make([]byte, 200)
n, err := cow.Read(line) n, err := cow.Read(line)
if (err != nil) && (err != io.EOF) { if (err != nil) && (err != io.EOF) {
@ -59,5 +59,5 @@ func TestMothball(t *testing.T) {
t.Error("Contents didn't match") t.Error("Contents didn't match")
return return
} }
} }

View File

@ -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

74
src/mothd.go Normal file
View File

@ -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)))
}

455
src/static.go Normal file
View File

@ -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)
}
}

View File

@ -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