diff --git a/Dockerfile.moth b/Dockerfile.moth deleted file mode 100644 index 0e8b7e9..0000000 --- a/Dockerfile.moth +++ /dev/null @@ -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"] - diff --git a/Dockerfile.moth-compile b/Dockerfile.moth-compile deleted file mode 100644 index 5680e4f..0000000 --- a/Dockerfile.moth-compile +++ /dev/null @@ -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"] diff --git a/Dockerfile.mothd b/Dockerfile.mothd new file mode 100644 index 0000000..43e55e1 --- /dev/null +++ b/Dockerfile.mothd @@ -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" ] diff --git a/README.md b/README.md index 3c73a6d..00f9d95 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ which in the past has been called "HACK", "Queen Of The Hill", "Cyber Spark", -and "Cyber Fire". +"Cyber Fire", +"Cyber Fire Puzzles", +and "Cyber Fire Foundry". Information about these events is at http://dirtbags.net/contest/ @@ -18,12 +20,33 @@ It also tracks scores, 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 @@ -48,75 +71,74 @@ More on how the devel sever works in 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, -so you can run multiple contests at once if you want. -If you were to want to run it out of `/srv/moth`, -do the following: - - $ mothinst=/srv/moth/mycontest - $ mkdir -p $mothinst - $ install.sh $mothinst - - Yay, you've got it installed. - -How to run a contest ------------------------- - -`mothd` runs through every contest on your server every few seconds, -and does housekeeping tasks that make the contest "run". -If you stop `mothd`, people can still play the contest, -but their points won't show up on the scoreboard. - -A handy side-effect here is that if you need to meddle with the points log, -you can just kill `mothd`, -do you work, -then bring `mothd` back up. - - $ cp src/mothd /srv/moth - $ /srv/moth/mothd - -You're also going to need a web server if you want people to be able to play. +You can override any path with an option, +run `mothd -help` for usage. -How to run a web server ------------------------------ - -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 +State Directory +=============== -Installing Puzzle Categories ------------------------------------- +Pausing scoring +------------------- -Puzzle categories are distributed in a different way than the server. -After setting up (see above), just run +Create the file `state/disabled` +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. diff --git a/bin/httpd b/bin/httpd deleted file mode 100755 index 854aa5b..0000000 --- a/bin/httpd +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -e - -# Starts a standalone server using tcpsvd and eris - -echo "Figuring out web user..." -for www in www-data http tc _ _www; do - id $www && break -done -if [ $www = _ ]; then - echo "Unable to determine httpd user on this system. Dying." - exit 1 -fi - -cd $(dirname $0)/../www -tcpserver -RHI localhost -u $www -g $www 0 80 eris -c -. - diff --git a/bin/install-category b/bin/install-category deleted file mode 100755 index 060524e..0000000 --- a/bin/install-category +++ /dev/null @@ -1,26 +0,0 @@ -#! /bin/sh -e - -package=$1 -if ! [ -n "$package" -a -f $package ]; then - echo "Usage: $0 PACKAGE" - exit 1 -fi -shift - - -cat=$(basename $package .zip) -outdir=$(dirname $(dirname $0))/packages/$cat - -echo "Extracting to $outdir..." -mkdir -p $outdir -unzip -o -d $outdir $package - -echo "Fixing permissions..." -chmod a+rx $outdir/*/ $outdir/*/* -chmod -R a+r $outdir -find $outdir/content -name \*.cgi -exec chmod a+rx {} \; - -if [ ! -h $outdir/../../www/$cat ]; then - echo "Linking into web space..." - ln -sf ../packages/$cat/content $outdir/../../www/$cat -fi diff --git a/bin/new b/bin/new deleted file mode 100755 index e765e88..0000000 --- a/bin/new +++ /dev/null @@ -1,37 +0,0 @@ -#! /bin/sh - -newdir=$1 -if [ -z "$newdir" ]; then - echo "Usage: $0 NEWDIR" - exit 1 -fi - -KOTH_BASE=$(cd $(dirname $0)/.. && pwd) - -echo "Figuring out web user..." -for www in www-data http _; do - id $www && break -done - -if [ $www = _ ]; then - echo "Unable to determine httpd user on this system. Dying." - exit 1 -fi - -mkdir -p $newdir -cd $newdir - -for i in points.new points.tmp teams; do - mkdir -p state/$i - setfacl -m ${www}:rwx state/$i -done - ->> state/points.log - -if ! [ -f assigned.txt ]; then - hd < /dev/urandom | awk '{print $3 $4 $5 $6;}' | head -n 100 > assigned.txt -fi - -mkdir -p www -cp -r $KOTH_BASE/html/* www/ -cp $KOTH_BASE/bin/*.cgi www/ diff --git a/bin/once b/bin/once deleted file mode 100755 index 388ba21..0000000 --- a/bin/once +++ /dev/null @@ -1,95 +0,0 @@ -#! /bin/sh - -if [ -n "$1" ]; then - cd $1 -else - cd $(dirname $0)/.. -fi -basedir=$(pwd) - -log () { - echo "moth: $@" 1>&2 -} - -# Do nothing if `disabled` is present -if [ -f state/disabled ]; then - log "Instance disabled; doing nothing" - exit -fi - -# Are we stopping at a certain time? -if [ -f state/until ]; then - read -r until < state/until - when=$(date -d "$until" +%s) - now=$(date +%s) - if [ $now -ge $when ]; then - log "End time reached; doing nothing" - exit - fi -fi - -# Reset to initial state? -if [ ! -f state/initialized ]; then - log "Resetting contest state" - - rm -rf state/teams state/points.new state/points.tmp - mkdir -p state/teams state/points.new state/points.tmp - chown www:www state/teams state/points.new state/points.tmp # Needs root. Use Docker. - : > state/points.log - echo 'Remove this file to obliterate teams and points' > state/initialized -fi - -# Create some team names if needed -if [ ! -f state/assigned.txt ]; then - log "Generating team names" - hd state/assigned.txt -fi - -# Install new categories -for pkg in puzzles/*; do - cat=$(basename $pkg .zip) - if [ ! -f packages/$cat/installed ] || [ $pkg -nt packages/$cat/installed ]; then - log "Installing $pkg" - bin/install-category $pkg - : >packages/$cat/installed - fi -done - -# Helpful error message -if [ $(ls packages | wc -l) -eq 0 ]; then - log "error: No packages installed" - exit -fi - -# Create a list of currently-active categories -: > state/categories.txt.new -for dn in packages/*; do - cat=${dn##packages/} - echo "$cat" >> state/categories.txt.new -done -mv state/categories.txt.new state/categories.txt - -# Collect new points -find state/points.new -type f | while read fn; do - # Skip files opened by another process - lsof $fn | grep -q $fn && continue - - # Skip partially written files - [ $(wc -l < $fn) -gt 0 ] || continue - - # filter the file for unique awards - sort -k 4 $fn | uniq -f 1 | sort -n >> state/points.log - - # Now kill the file - rm -f $fn -done - -# Generate new puzzles.json -if bin/puzzles $basedir > state/puzzles.json.new; then - mv state/puzzles.json.new state/puzzles.json -fi - -# Generate new points.json -if bin/points $basedir > state/points.json.new; then - mv state/points.json.new state/points.json -fi diff --git a/bin/points b/bin/points deleted file mode 100755 index 79a4e5d..0000000 --- a/bin/points +++ /dev/null @@ -1,45 +0,0 @@ -#! /usr/bin/lua5.3 - -local basedir = arg[1] -local statedir = basedir .. "/state" - -io.write('{\n "points": [\n') -local teams = {} -local teamnames = {} -local nteams = 0 -local NR = 0 - -for line in io.lines(statedir .. "/points.log") do - local ts, hash, cat, points = line:match("(%d+) (%g+) (%g+) (%d+)") - local teamno = teams[hash] - - if not teamno then - teamno = nteams - teams[hash] = teamno - nteams = nteams + 1 - - teamnames[hash] = io.lines(statedir .. "/teams/" .. hash)() - end - - if NR > 0 then - -- JSON sucks, barfs if you have a comma with nothing after it - io.write(",\n") - end - NR = NR + 1 - - io.write(' [' .. ts .. ', "' .. teamno .. '", "' .. cat .. '", ' .. points .. ']') -end - -io.write('\n],\n "teams": {\n') - -NR = 0 -for hash,teamname in pairs(teamnames) do - if NR > 0 then - io.write(",\n") - end - NR = NR + 1 - - teamno = teams[hash] - io.write(' "' .. teamno .. '": "' .. teamname .. '"') -end -io.write('\n }\n}\n') diff --git a/bin/puzzles b/bin/puzzles deleted file mode 100755 index 0259174..0000000 --- a/bin/puzzles +++ /dev/null @@ -1,53 +0,0 @@ -#! /usr/bin/lua5.3 - -local basedir = arg[1] -local statedir = basedir .. "/state" - -local max_by_cat = {} -for cat in io.lines(statedir .. "/categories.txt") do - max_by_cat[cat] = 0 -end - -for line in io.lines(statedir .. "/points.log") do - local ts, team, cat, points = line:match("^(%d+) (%g+) (%g+) (%d+)") - points = tonumber(points) or 0 - - -- Skip scores for removed categories - if (max_by_cat[cat] ~= nil) then - max_by_cat[cat] = math.max(max_by_cat[cat], points) - end -end - - -local i = 0 -io.write('{\n') -for cat, biggest in pairs(max_by_cat) do - local points, dirname - local j = 0 - - if i > 0 then - io.write(',\n') - end - i = i + 1 - - io.write(' "' .. cat .. '": [\n') - for line in io.lines(basedir .. "/packages/" .. cat .. "/map.txt") do - points, dirname = line:match("^(%d+) (.*)") - points = tonumber(points) - - if j > 0 then - io.write(',\n') - end - j = j + 1 - io.write(' [' .. points .. ', "' .. dirname .. '"]') - if (points > biggest) then - break - end - end - if (points == biggest) then - io.write(',\n') - io.write(' [0, ""]') - end - io.write('\n ]') -end -io.write('\n}\n') diff --git a/bin/server-start b/bin/server-start deleted file mode 100755 index a60fc73..0000000 --- a/bin/server-start +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh - -# Starts a standalone server using tcpsvd and eris - -tcpserver diff --git a/bin/award b/contrib/award similarity index 100% rename from bin/award rename to contrib/award diff --git a/bin/mktokens b/contrib/mktokens similarity index 100% rename from bin/mktokens rename to contrib/mktokens diff --git a/tools/answer_words.txt b/devel/answer_words.txt similarity index 100% rename from tools/answer_words.txt rename to devel/answer_words.txt diff --git a/tools/devel-server.py b/devel/devel-server.py similarity index 100% rename from tools/devel-server.py rename to devel/devel-server.py diff --git a/tools/mistune.py b/devel/mistune.py similarity index 100% rename from tools/mistune.py rename to devel/mistune.py diff --git a/tools/moth.py b/devel/moth.py similarity index 100% rename from tools/moth.py rename to devel/moth.py diff --git a/tools/mothd.service b/devel/mothd.service similarity index 100% rename from tools/mothd.service rename to devel/mothd.service diff --git a/tools/package-puzzles.py b/devel/package-puzzles.py similarity index 100% rename from tools/package-puzzles.py rename to devel/package-puzzles.py diff --git a/setup.cfg b/devel/setup.cfg similarity index 100% rename from setup.cfg rename to devel/setup.cfg diff --git a/docs/overview.md b/docs/overview.md index 94d2885..5242aed 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -16,8 +16,142 @@ indicating score within each category, and overall ranking. -How Scores are Calculated -------------------------- +State Directory +=============== + +The state directory is written to by the server to preserve state. +At no point is anything only in memory: +if it's not on the filesystem, +mothd doesn't think it exists. + +The state directory is also used to communicate actions to mothd. + + +`initialized` +------------- + +Remove this file to reset the state. This will blow away team assignments and the points log. + + +`disabled` +---------- + +Create this file to pause collection of points and other maintenance. +Contestants can still submit answers, +but they won't show up on the scoreboard until you remove this file. + +This file does not normally exist. + + +`until` +------- + +Put an RFC3337 date/time stamp in here to have the server pause itself at a given time. +Remember that time zones exist! +I recommend always using Zulu time. + +This file does not normally exist. + + +`teamids.txt` +------------- + +A list of valid Team IDs, one per line. +It defaults to all 4-digit natural numbers, +but you can put whatever you want in here. + + +`points.log` +------------ + +The log of awarded points: + + EpochTime TeamId Category Points + +Do not write to this file, unless you have disabled the contest. You will lose points! + + +`points.tmp` +------------ + +Drop points logs here. +Filenames can be anything. + +When the file is complete and written out, +move it into `points.new`, +where a non-disabled event's maintenance loop will eventually move it into the main log. + +`points.new` +------------ + +Complete points logs should be atomically moved here. +This is to avoid needing locks. +[Read about Maildir](https://en.wikipedia.org/wiki/Maildir) +if you care about the technical reasons we do things this way. + + +Mothball Directory +================== + +Put a mothball in this directory to open that category. +Remove a mothball to disable that category. + +Overwriting a mothball with a newer version will be noticed by the server within one maintenance interval +(20 seconds by default). +Be sure to use the same compilation seed in the development server if you compile a new version! + +Removing a category does not remove points that have been scored in the category. + + +Resources Directory +=================== + + +Making it look better +------------------- + +`mothd` provides some built-in HTML for rendering a complete contest, +but it's rather bland. +You can override everything by dropping a new file into the `resources` directory: + +* `basic.css` is used by the default HTML to pretty things up +* `index.html` is the landing page, which asks to register a team +* `puzzle.html` renders a puzzle from JSON +* `puzzle-list.html` renders the list of active puzzles from JSON +* `scoreboard.html` renders the current scoreboard from JSON +* Any other file in the `resources` directory will be served up, too. + +If you don't want to read through the source code, I don't blame you. +Run a `mothd` server and pull the various static resources into your `resources` directory, +and then you can start hacking away at them. + + +Making it look totally different +--------------------- + +Every handler can serve its answers up in JSON format, +just add `application/json` to the `Accept` header of your request. + +This means you could completely ignore the file structure in the previous section, +and write something like a web app that only loads static resources at startup. + + +Changing scoring +-------------- + +Scoring is determined client-side in the scoreboard, +from the points log. +You can hack in whatever algorithm you like, +and provide your own scoreboard(s). + +If you do hack in a new algorithm, +please be a dear and email it to us. +We'd love to see it! + + + +How Scores are Calculated by Default +------------------------------------ The per-category score for team `t` is computed as: @@ -38,138 +172,3 @@ Because we don't award extra points for quick responses, teams always feel like they have the possibility to catch up if they are skilled enough. -Requirements -------------- - -MOTH was written to run on a wide range of Linux systems. -We are very careful not to require exotic extensions: -you can run MOTH equally well on OpenWRT and Ubuntu Server. -It might even run on BSD: if you've tried this, please email us! - -Its architecture also limits permissions, -to make it easier to lock things down very tight. -Since it writes to the filesystem slowly and atomically, -it can be run from a USB flash drive formatted with VFAT. - - -On the server, it requires: - -* Bourne shell (POSIX 1003.2: BASH is okay but not required) -* Awk (POSIX 1003.2: gawk is okay but not required) -* Lua 5.1 - - -On the client, it requires: - -* A modern web browser with JavaScript -* Categories might add other requirements (like domain-specific tools to solve the puzzles) - - -Filesystem Layout -================= - -The system is set up to make it simple to run one or more contests on a single machine. - -I like to use `/srv/moth` as the base directory for all instances. -So if I were running an instance called "hack", -the instance directory would be `/srv/moth/hack`. - -There are five entries in each instance directory, described in detail below: - - /srv/moth/hack # (r-x) Instance directory - /srv/moth/hack/assigned.txt # (r--) List of assigned team tokens - /srv/moth/hack/bin/ # (r-x) Per-instance binaries - /srv/moth/hack/categories/ # (r-x) Installed categories - /srv/moth/hack/state/ # (rwx) Contest state - /srv/moth/hack/www/ # (r-x) Web server documentroot - - - -`state/assigned.txt` ----------------- - -This is just a list of tokens that have been assigned. -One token per line, and tokens can be anything you want. - -For my middle school events, I make tokens all possible 4-digit numbers, -and tell kids to use any number they want: it makes it quicker to start. -For more advanced events, -this doesn't work as well because people start guessing other teams' numbers to confuse each other. -So I use hex representations of random 32-bit ints. -But you could use anything you want in here (for specifics on allowed characters, read the registration CGI). - -The registration CGI checks this list to see if a token has already assigned to a team name. -Teams enter points by token, -which lets them use any text they want for a team name. -Since we don't read their team name anywhere else than the registration and scoreboard generator, -it allows some assumptions about what kind of strings tokens can be, -resulting in simpler code. - - -`categories/` --------------- - -`categories/` contains read-only category packages. -Within each subdirectory there is: - -* `map.txt` mapping point values to directory names -* `answers.txt` a list of answers for each point value -* `salt` used to generate directory names (so people can't guess them to skip ahead) -* `summary.txt` a compliation of `00summary.txt` files for puzzles, to give you a quick reference point when someone says "I need help on js 40". -* `puzzles` is all the HTML that needs to be served up for the category - - -`bin/` ------- - -Contains all the binaries you'll need to run an event. -These are probably just copies from the `base` package (where this README lives). -They're copied over in case you need to hack on them during an event. - -`bin/once` is of particular interest: -it gets run periodically to do everything, including: - -* Gather points from `points.new` and append them to the points log. -* Generate a new `puzzles.html` listing all open puzzles. -* Generate a new `points.json` for the scoreboard - -### Pausing `once` - -You can pause everything `bin/once` does by touching a file in the root directory -called `disabled`. -This doesn't stop the game: -it just stops points collection and generation of the files listed above. - -This is extremely helpful when, inevitably, -you need to hack the points log, -or do other maintenance tasks. -Most times you don't even need to announce that you're doing anything: -people can keep playing the game and their points keep collecting, -ready to be appended to the log when you're done and you re-enable `once`. - - -`www/` ------------ - -HTML root for an event. -It is possible to make this read-only, -after you've set up your packages. -You will need to symlink a few things into the `state` directory, though. - - -`state/` ---------- - -Where all game state is stored. -This is the only part of the contest directory setup that needs to be writable, -and tarring it up preserves exactly the entire contest. - -Notable, it contains the mapping from team hash to name, -and the points log. - -`points.log` is replayed by the scoreboard generator to calculate the current score for each team. - -New points are written to `points.new`, and picked up by `bin/once` to append to `points.log`. -When `once` is disabled (by touching a file called `disabled` at the top level for a game), -the various points-awarding things can keep writing files into `points.new`, -with no need for locking or "bringing down the game for maintenance". diff --git a/res/basic.css b/res/basic.css new file mode 100644 index 0000000..a8a856e --- /dev/null +++ b/res/basic.css @@ -0,0 +1,48 @@ +/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */ +body { + font-family: sans-serif; + max-width: 40em; + background: #282a33; + color: #f6efdc; +} +a:any-link { + color: #8b969a; +} +h1 { + background: #5e576b; + color: #9e98a8; +} +.Fail, .Error { + background: #3a3119; + color: #ffcc98; +} +.Fail:before { + content: "Fail: "; +} +.Error:before { + content: "Error: "; +} +p { + margin: 1em 0em; +} +form, pre { + margin: 1em; +} +input { + padding: 0.6em; + margin: 0.2em; +} +#scoreboard .category { + border: solid white 1px; + display: inline-block; +} +nav { + border: solid black 2px; +} +nav ul, .category ul { + padding: 1em; +} +nav li, .category li { + display: inline; + margin: 1em; +} diff --git a/res/index.html b/res/index.html new file mode 100644 index 0000000..803cd7f --- /dev/null +++ b/res/index.html @@ -0,0 +1,34 @@ + + + + Welcome + + + + + +

Welcome

+
+

Register your team

+ +
+ Team ID:
+ Team name: + +
+ +

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

+
+ + + diff --git a/res/puzzle-list.html b/res/puzzle-list.html new file mode 100644 index 0000000..32f79dc --- /dev/null +++ b/res/puzzle-list.html @@ -0,0 +1,81 @@ + + + + Open Puzzles + + + + + + + +

Open Puzzles

+
+
+
+ + + diff --git a/res/puzzle.html b/res/puzzle.html new file mode 100644 index 0000000..2f73ceb --- /dev/null +++ b/res/puzzle.html @@ -0,0 +1,58 @@ + + + + Puzzle + + + + + + + +

Puzzle

+
+
Loading...
+
+
+ + + Team ID:
+ Answer:
+ +
+ + + diff --git a/res/scoreboard.html b/res/scoreboard.html new file mode 100644 index 0000000..a50d9f1 --- /dev/null +++ b/res/scoreboard.html @@ -0,0 +1,148 @@ + + + + Scoreboard + + + + + + + +

Scoreboard

+
+
+
+ + + diff --git a/src/award.go b/src/award.go new file mode 100644 index 0000000..4a8ba75 --- /dev/null +++ b/src/award.go @@ -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 +} diff --git a/src/award_test.go b/src/award_test.go new file mode 100644 index 0000000..2875557 --- /dev/null +++ b/src/award_test.go @@ -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") + } +} diff --git a/src/handlers.go b/src/handlers.go new file mode 100644 index 0000000..3b0ec91 --- /dev/null +++ b/src/handlers.go @@ -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) +} diff --git a/src/instance.go b/src/instance.go new file mode 100644 index 0000000..1c18de1 --- /dev/null +++ b/src/instance.go @@ -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 +} diff --git a/src/instance_test.go b/src/instance_test.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/src/instance_test.go @@ -0,0 +1 @@ +package main diff --git a/src/maintenance.go b/src/maintenance.go new file mode 100644 index 0000000..e6d7be3 --- /dev/null +++ b/src/maintenance.go @@ -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...") + } + } +} diff --git a/src/moth-init b/src/moth-init deleted file mode 100755 index e0cd4d1..0000000 --- a/src/moth-init +++ /dev/null @@ -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 -. diff --git a/src/mothball.go b/src/mothball.go new file mode 100644 index 0000000..149dbf5 --- /dev/null +++ b/src/mothball.go @@ -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 +} diff --git a/src/mothball/mothball.go b/src/mothball/mothball.go deleted file mode 100644 index 1f63776..0000000 --- a/src/mothball/mothball.go +++ /dev/null @@ -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) -} diff --git a/src/mothball/mothball_test.go b/src/mothball_test.go similarity index 94% rename from src/mothball/mothball_test.go rename to src/mothball_test.go index f740e27..8115809 100644 --- a/src/mothball/mothball_test.go +++ b/src/mothball_test.go @@ -1,4 +1,4 @@ -package mothball +package main import ( "archive/zip" @@ -16,7 +16,7 @@ func TestMothball(t *testing.T) { return } defer os.Remove(tf.Name()) - + w := zip.NewWriter(tf) f, err := w.Create("moo.txt") if err != nil { @@ -33,9 +33,9 @@ func TestMothball(t *testing.T) { } w.Close() tf.Close() - + // Now read it in - mb, err := Open(tf.Name()) + mb, err := OpenMothball(tf.Name()) if err != nil { t.Error(err) return @@ -46,7 +46,7 @@ func TestMothball(t *testing.T) { t.Error(err) return } - + line := make([]byte, 200) n, err := cow.Read(line) if (err != nil) && (err != io.EOF) { @@ -59,5 +59,5 @@ func TestMothball(t *testing.T) { t.Error("Contents didn't match") return } - + } diff --git a/src/mothd b/src/mothd deleted file mode 100755 index ffad247..0000000 --- a/src/mothd +++ /dev/null @@ -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 diff --git a/src/mothd.go b/src/mothd.go new file mode 100644 index 0000000..acfda71 --- /dev/null +++ b/src/mothd.go @@ -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))) +} diff --git a/src/static.go b/src/static.go new file mode 100644 index 0000000..f7adbdd --- /dev/null +++ b/src/static.go @@ -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, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "%s", title) + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "

%s

", statusStr, title) + fmt.Fprintf(w, "
%s
", body) + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") +} + +// 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", + ` +

Register your team

+ +
+ Team ID:
+ Team name: + +
+ +

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

+ `, + ) +} + +func staticScoreboard(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Scoreboard", + ` +
+ + `, + ) +} + +func staticPuzzleList(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Open Puzzles", + ` +
+
+
+ + `, + ) +} + +func staticPuzzle(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Open Puzzles", + ` +
+
Loading...
+
+
+ + + Team ID:
+ Answer:
+ +
+ + `, + ) +} + +func tryServeFile(w http.ResponseWriter, req *http.Request, path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + d, err := f.Stat() + if err != nil { + return false + } + + http.ServeContent(w, req, path, d.ModTime(), f) + return true +} + +func ServeStatic(w http.ResponseWriter, req *http.Request, resourcesDir string) { + path := req.URL.Path + if strings.Contains(path, "..") { + http.Error(w, "Invalid URL path", http.StatusBadRequest) + return + } + if path == "/" { + path = "/index.html" + } + + fpath := filepath.Join(resourcesDir, path) + if tryServeFile(w, req, fpath) { + return + } + + switch path { + case "/basic.css": + staticStylesheet(w) + case "/index.html": + staticIndex(w) + case "/scoreboard.html": + staticScoreboard(w) + case "/puzzle-list.html": + staticPuzzleList(w) + case "/puzzle.html": + staticPuzzle(w) + default: + http.NotFound(w, req) + } +} diff --git a/tools/mothd b/tools/mothd deleted file mode 100755 index ffad247..0000000 --- a/tools/mothd +++ /dev/null @@ -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