Merge branch 'v4'

This commit is contained in:
Neale Pickett 2020-09-17 19:05:39 -06:00
commit a22df7a253
107 changed files with 5209 additions and 13761 deletions

View File

@ -1,12 +0,0 @@
name: moth-devel Docker build
on: [push]
jobs:
build-devel:
name: Build moth-devel
runs-on: ubuntu-latest
steps:
- name: Retrieve code
uses: actions/checkout@v1
- name: Build mothd
run: docker build -f Dockerfile.moth-devel .

View File

@ -1,12 +0,0 @@
name: Mothd Docker build
on: [push]
jobs:
build-mothd:
name: Build mothd
runs-on: ubuntu-latest
steps:
- name: Retrieve code
uses: actions/checkout@v1
- name: Build mothd
run: docker build -f Dockerfile.moth .

54
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: Publish
on:
release:
types: [published]
jobs:
publish:
name: Publish Container Image
runs-on: ubuntu-latest
steps:
- name: Retrieve code
uses: actions/checkout@v1
- name: Push moth to GitHub Packages
uses: docker/build-push-action@v2
with:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
target: moth
file: build/package/Containerfile
registry: docker.pkg.github.com
repository: dirtbags/moth/moth
tag_with_ref: true
- name: Push moth-devel to GitHub Packages
uses: docker/build-push-action@v2
with:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
target: moth-devel
file: build/package/Containerfile
registry: docker.pkg.github.com
repository: dirtbags/moth/moth-devel
tag_with_ref: true
- name: Push moth to Docker Hub
uses: docker/build-push-action@v2
with:
username: neale
password: ${{ secrets.DOCKER_TOKEN }}
target: moth
file: build/packages/Containerfile
repository: dirtbags/moth
tag_with_ref: true
- name: Push moth-devel to Docker Hub
uses: docker/build-push-action@v2
with:
username: neale
password: ${{ secrets.DOCKER_TOKEN }}
target: moth-devel
file: build/packages/Containerfile
repository: dirtbags/moth-devel
tag_with_ref: true

16
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Tests
on: [push]
jobs:
test-mothd:
name: Test mothd
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: 1.13
- name: Retrieve code
uses: actions/checkout@v1
- name: Test
run: go test ./...

4
.gitignore vendored
View File

@ -4,7 +4,5 @@
*.o
.idea
./bin/
build/
cache/
target/
puzzles
__debug_bin

30
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,30 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${fileDirname}",
"env": {},
"args": []
},
{
"name": "MOTHd",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/mothd",
"env": {},
"args": [
"--state", "/tmp/state",
"--puzzles", "${workspaceFolder}/example-puzzles",
"--theme", "${workspaceFolder}/theme",
]
}
]
}

View File

@ -4,7 +4,46 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v4.0.0] - Unreleased
### Changed
- Major rewrite/refactor of `mothd`
- Clear separation of roles: State, Puzzles, and Theme
- Sqlite, Redis, or S3 should fit in easily now
- Will allow "dynamic" puzzles now, we just need a flag to enable it
- Server no longer provides unlocked content
- Puzzle URLs are now just `/content/${cat}/${points}/`
- Changes to `state` directory
- Most files now have a bit of (English) documentation at the beginning
- `state/until` is now `state/hours` and can specify multiple begin/end hours
- `state/disabled` is now `state/enabled`
- Mothball structure has changed
- Mothballs no longer contain `map.txt`
- Mothballs no longer obfuscate content paths
- Clients now expect unlocked puzzles to just be `map[string][]int`
- New `/state` API endpoint
- Provides *all* server state: event log, team mapping, messages, configuration
### Added
- New `transpile` CLI command
- Provides `mothball` action to create mothballs
- Lets you test a few development server things, if you want
### Deprecated
### Removed
- Development server is gone now; use `mothd` directly with a flag to transpile on the fly
### Fixed
### Security
## [Unreleased]
### Changed
- Endpoints `/points.json`, `/puzzles.json`, and `/messages.html` (optional theme file) combine into `/state`
- No more `__devel__` category for dev server: this is now `.config.devel` in the `/state` endpoint
- Development server no longer serves a static `/` with links: it now redirects you to a randomly-generated seed URL
- Default theme modifications to handle all this
- Default theme now automatically "logs you in" with Team ID if it's getting state from the devel server
## [v3.5.1] - 2020-03-16
### Fixed

View File

@ -1,12 +0,0 @@
FROM golang:1.12.0-alpine AS builder
COPY src /go/src/github.com/dirtbags/moth/src
WORKDIR /go/src/github.com/dirtbags/moth/src
RUN go get .
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o /mothd *.go
FROM scratch
COPY --from=builder /mothd /mothd
COPY theme /theme
COPY LICENSE.md /LICENSE
ENTRYPOINT [ "/mothd" ]

View File

@ -1,23 +0,0 @@
FROM python:3.7.2-alpine3.8
RUN apk --no-cache add \
freetype-dev \
gcc \
musl-dev \
jpeg-dev \
zlib-dev \
&& \
pip3 install \
scapy==2.4.2 \
pillow==5.4.1 \
PyYAML==5.1.1
COPY devel /app/
COPY example-puzzles /puzzles/
COPY theme /theme/
COPY LICENSE.md /LICENSE
COPY VERSION /VERSION
ENTRYPOINT [ "python3", "/app/devel-server.py" ]
CMD [ "--bind", "0.0.0.0:8080", "--puzzles", "/puzzles", "--theme", "/theme" ]

View File

@ -1,24 +1,24 @@
Copyright © 2015-2016 Neale Pickett <neale@woozle.org>
> Permission is hereby granted, free of charge, to any person
> obtaining a copy of this software and associated documentation files
> (the "Software"), to deal in the Software without restriction,
> including without limitation the rights to use, copy, modify, merge,
> publish, distribute, sublicense, and/or sell copies of the Software,
> and to permit persons to whom the Software is furnished to do so,
> subject to the following conditions:
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation files
(the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
> The above copyright notice and this permission notice shall be
> included in all copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
> The software is provided "as is", without warranty of any kind,
> express or implied, including but not limited to the warranties of
> merchantability, fitness for a particular purpose and
> noninfringement. In no event shall the authors or copyright holders
> be liable for any claim, damages or other liability, whether in an
> action of contract, tort or otherwise, arising from, out of or in
> connection with the software or the use or other dealings in the
> software.
The software is provided "as is", without warranty of any kind,
express or implied, including but not limited to the warranties of
merchantability, fitness for a particular purpose and
noninfringement. In no event shall the authors or copyright holders
be liable for any claim, damages or other liability, whether in an
action of contract, tort or otherwise, arising from, out of or in
connection with the software or the use or other dealings in the
software.
Font Licenses

163
README.md
View File

@ -1,16 +1,11 @@
Dirtbags Monarch Of The Hill Server
=====================
Master:
![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=master)
![](https://github.com/dirtbags/moth/workflows/moth-devel%20Docker%20build/badge.svg?branch=master)
![Build badge](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=master)
![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)
Devel:
![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=devel)
![](https://github.com/dirtbags/moth/workflows/moth-devel%20Docker%20build/badge.svg?branch=devel)
This is a set of thingies to run our Monarch-Of-The-Hill contest,
which in the past has been called
Monarch Of The Hill (MOTH) is a puzzle server.
We (the authors) have used it for instructional and contest events called
"Tracer FIRE",
"Project 2",
"HACK",
@ -23,152 +18,38 @@ and "Cyber Fire Foundry".
Information about these events is at
http://dirtbags.net/contest/
This software serves up puzzles in a manner similar to Jeopardy.
It also tracks scores,
and comes with a JavaScript-based scoreboard to display team rankings.
A few things make MOTH different than other Capture The Flag server projects:
* Once any team opens a puzzle, all teams can work on it (high fives to DC949/Orange County for this idea)
* No penalties for wrong answers
* No time-based point deductions (if you're faster, you get to answer more puzzles)
* No internal notion of ranking or score: it only stores an event log, and scoreboards parse it however they want
* All puzzles must be compiled to static content before it can be served up
* The server does very little: most functionality is in client-side JavaScript
You can read more about why we made these decisions in [philosophy](docs/philosophy.md).
Running a Development Server
============================
To use example puzzles
docker run --rm -it -p 8080:8080 dirtbags/moth-devel
or, to use your own puzzles
docker run --rm -it -p 8080:8080 -v /path/to/puzzles:/puzzles:ro dirtbags/moth-devel
And point a browser to http://localhost:8080/ (or whatever host is running the server).
The development server includes a number of Python libraries that we have found useful in writing puzzles.
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.
Documentation
==========
* [Development](docs/development.md): The development server lets you create and test categories, and compile mothballs.
* [Getting Started](docs/getting-started.md): This guide will get you started with a production server.
* [Administration](docs/administration.md): How to set hours, and change setup.
Running a Production Server
===========================
docker run --rm -it -p 8080:8080 -v /path/to/moth/state:/state -v /path/to/moth/balls:/mothballs:ro dirtbags/moth
docker run --rm -it -p 8080:8080 -v /path/to/moth/state:/state -v /path/to/moth/mothballs:/mothballs:ro dirtbags/moth
You can be more fine-grained about directories, if you like.
Inside the container, you need the following paths:
* `/state` (rw) Where state is stored. Read [the overview](docs/overview.md) to learn what's what in here.
* `/state` (rw) Where state is stored. Read [the overview](doc/overview.md) to learn what's what in here.
* `/mothballs` (ro) Mothballs (puzzle bundles) as provided by the development server.
* `/resources` (ro) Overrides for built-in HTML/CSS resources.
* `/theme` (ro) Overrides for the built-in theme.
Getting Started Developing
-------------------------------
If you don't have a `puzzles` directory,
you can copy the example puzzles as a starting point:
$ cp -r example-puzzles puzzles
Then launch the development server:
$ python3 devel/devel-server.py
Point a web browser at http://localhost:8080/
and start hacking on things in your `puzzles` directory.
More on how the devel sever works in
[the devel server documentation](docs/devel-server.md)
Running A Production Server
====================
Run `dirtbags/moth` (Docker) or `mothd` (native).
`mothd` assumes you're running a contest out of `/moth`.
For Docker, you'll need to bind-mount your actual directories
(`state`, `mothballs`, and optionally `resources`) into
`/moth/`.
You can override any path with an option,
run `mothd -help` for usage.
State Directory
===============
Pausing scoring
-------------------
Create the file `state/disabled`
to pause scoring,
and remove it to resume.
You can use the Unix `touch` command to create the file:
touch state/disabled
When scoring is paused,
participants can still submit answers,
and the system will tell them whether the answer is correct.
As soon as you unpause,
all correctly-submitted answers will be scored.
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.
Enabling offline/PWA mode
-------------------
If the file `state/export_manifest` is found, the server will expose the
endpoint `/current_manifest.json?id=<teamId>`. This endpoint will return
a list of all files, including static theme content and JSON and content
for currently-unlocked puzzles. This is used by the native PWA
implementation and `Cache` button on the index page to cache all of the
content necessary to display currently-open puzzles while offline.
Grading will be unavailable while offline. Some puzzles may not function
as expected while offline. A valid team ID must be provided.
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!
Contributing to MOTH
==================

View File

@ -1 +1,5 @@
* Figure out how to log JSend short text in addition to HTTP code
* We've got logic in state.go and httpd.go that is neither httpd nor state specific.
Pull this into some other file that means "here are the brains of the server".
* Get Bo's answer pattern anchors working again
* Are we logging every transaction now?

View File

@ -1 +0,0 @@
3.5.1

View File

@ -1,14 +0,0 @@
#! /bin/sh
set -e
read version < VERSION
cd $(dirname $0)
for img in moth moth-devel; do
echo "==== $img"
sudo docker build --build-arg http_proxy=$http_proxy --build-arg https_proxy=$https_proxy --tag dirtbags/$img --tag dirtbags/$img:$version -f Dockerfile.$img .
[ "$1" = "-push" ] && docker push dirtbags/$img:$version && docker push dirtbags/$img:latest
done
exit 0

View File

@ -0,0 +1,37 @@
FROM golang:1.13 AS builder
COPY go.* /src/
COPY pkg /src/pkg/
COPY cmd /src/cmd/
COPY theme /target/theme/
COPY example-puzzles /target/puzzles/
COPY LICENSE.md /target/
WORKDIR /src/
RUN CGO_ENABLED=0 GOOS=linux go install -i -a -ldflags '-extldflags "-static"' ./...
# I can't put these in /target/bin: doing so would cause the devel server to overwrite Ubuntu's /bin
RUN mkdir -p /target/bin/
RUN cp /go/bin/* /target/
##########
FROM builder AS tester
RUN go test ./...
##########
FROM scratch AS moth
COPY --from=builder /target /
ENTRYPOINT [ "/mothd" ]
##########
FROM ubuntu AS moth-devel
RUN apt-get -y update && apt-get -y install \
build-essential \
bsdgames \
figlet toilet \
python3 \
python3-pil \
lua5.3
COPY --from=builder /bin/* /
CMD [ "/mothd", "-puzzles", "/puzzles" ]

22
build/package/build.sh Executable file
View File

@ -0,0 +1,22 @@
#! /bin/sh
set -e
cd $(dirname $0)/../..
PODMAN=$(command -v podman || echo docker)
VERSION=$(cat CHANGELOG.md | awk -F '[][]' '/^## \[/ {print $2; exit}')
for target in moth moth-devel; do
tag=dirtbags/$target:$VERSION
echo "==== Building $tag"
$PODMAN build \
--build-arg http_proxy --build-arg https_proxy --build-arg no_proxy \
--tag dirtbags/$target \
--tag dirtbags/$target:$VERSION \
--target $target \
-f build/package/Containerfile .
[ "$1" = "-push" ] && docker push dirtbags/$target:$VERSION && docker push dirtbags/$img:latest
done
exit 0

181
cmd/mothd/httpd.go Normal file
View File

@ -0,0 +1,181 @@
package main
import (
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/dirtbags/moth/pkg/jsend"
)
// HTTPServer is a MOTH HTTP server
type HTTPServer struct {
*http.ServeMux
server *MothServer
base string
}
// NewHTTPServer creates a MOTH HTTP server, with handler functions registered
func NewHTTPServer(base string, server *MothServer) *HTTPServer {
base = strings.TrimRight(base, "/")
h := &HTTPServer{
ServeMux: http.NewServeMux(),
server: server,
base: base,
}
h.HandleMothFunc("/", h.ThemeHandler)
h.HandleMothFunc("/state", h.StateHandler)
h.HandleMothFunc("/register", h.RegisterHandler)
h.HandleMothFunc("/answer", h.AnswerHandler)
h.HandleMothFunc("/content/", h.ContentHandler)
if server.Config.Devel {
h.HandleMothFunc("/mothballer/", h.MothballerHandler)
}
return h
}
// HandleMothFunc binds a new handler function which creates a new MothServer with every request
func (h *HTTPServer) HandleMothFunc(
pattern string,
mothHandler func(MothRequestHandler, http.ResponseWriter, *http.Request),
) {
handler := func(w http.ResponseWriter, req *http.Request) {
participantID := req.FormValue("pid")
teamID := req.FormValue("id")
mh := h.server.NewHandler(participantID, teamID)
mothHandler(mh, w, req)
}
h.HandleFunc(h.base+pattern, handler)
}
// ServeHTTP provides the http.Handler interface
func (h *HTTPServer) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
w := StatusResponseWriter{
statusCode: new(int),
ResponseWriter: wOrig,
}
h.ServeMux.ServeHTTP(w, r)
log.Printf(
"%s %s %s %d\n",
r.RemoteAddr,
r.Method,
r.URL,
*w.statusCode,
)
}
// StatusResponseWriter provides a ResponseWriter that remembers what the status code was
type StatusResponseWriter struct {
statusCode *int
http.ResponseWriter
}
// WriteHeader sends an HTTP response header with the provided status code
func (w StatusResponseWriter) WriteHeader(statusCode int) {
*w.statusCode = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
// Run binds to the provided bindStr, and serves incoming requests until failure
func (h *HTTPServer) Run(bindStr string) {
log.Printf("Listening on %s", bindStr)
log.Fatal(http.ListenAndServe(bindStr, h))
}
// ThemeHandler serves up static content from the theme directory
func (h *HTTPServer) ThemeHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
if path == "/" {
path = "/index.html"
}
f, mtime, err := mh.ThemeOpen(path)
if err != nil {
http.NotFound(w, req)
return
}
defer f.Close()
http.ServeContent(w, req, path, mtime, f)
}
// StateHandler returns the full JSON-encoded state of the event
func (h *HTTPServer) StateHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
jsend.JSONWrite(w, mh.ExportState())
}
// RegisterHandler handles attempts to register a team
func (h *HTTPServer) RegisterHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
teamName := req.FormValue("name")
if err := mh.Register(teamName); err != nil {
jsend.Sendf(w, jsend.Fail, "not registered", err.Error())
} else {
jsend.Sendf(w, jsend.Success, "registered", "Team ID registered")
}
}
// AnswerHandler checks answer correctness and awards points
func (h *HTTPServer) AnswerHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
cat := req.FormValue("cat")
pointstr := req.FormValue("points")
answer := req.FormValue("answer")
points, _ := strconv.Atoi(pointstr)
if err := mh.CheckAnswer(cat, points, answer); err != nil {
jsend.Sendf(w, jsend.Fail, "not accepted", err.Error())
} else {
jsend.Sendf(w, jsend.Success, "accepted", "%d points awarded in %s", points, cat)
}
}
// ContentHandler returns static content from a given puzzle
func (h *HTTPServer) ContentHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
parts := strings.SplitN(req.URL.Path[len(h.base)+1:], "/", 4)
if len(parts) < 4 {
http.NotFound(w, req)
return
}
// parts[0] == "content"
cat := parts[1]
pointsStr := parts[2]
filename := parts[3]
if filename == "" {
filename = "puzzle.json"
}
points, _ := strconv.Atoi(pointsStr)
mf, mtime, err := mh.PuzzlesOpen(cat, points, filename)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
defer mf.Close()
http.ServeContent(w, req, filename, mtime, mf)
}
// MothballerHandler returns a mothball
func (h *HTTPServer) MothballerHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
parts := strings.SplitN(req.URL.Path[len(h.base)+1:], "/", 2)
if len(parts) < 2 {
http.NotFound(w, req)
return
}
// parts[0] == "mothballer"
filename := parts[1]
cat := strings.TrimSuffix(filename, ".mb")
mothball, err := mh.Mothball(cat)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.ServeContent(w, req, filename, time.Now(), mothball)
}

163
cmd/mothd/httpd_test.go Normal file
View File

@ -0,0 +1,163 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/spf13/afero"
)
const TestParticipantID = "shipox"
func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest.ResponseRecorder {
vals := url.Values{}
vals.Set("pid", TestParticipantID)
vals.Set("id", TestTeamID)
if args != nil {
for k, v := range args {
vals.Set(k, v)
}
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
"GET",
fmt.Sprintf("%s?%s", path, vals.Encode()),
bytes.NewReader([]byte{}),
)
hs.ServeHTTP(recorder, request)
return recorder
}
func TestHttpd(t *testing.T) {
hs := NewHTTPServer("/", NewTestServer())
if r := hs.TestRequest("/", nil); r.Result().StatusCode != 200 {
t.Error(r.Result())
}
if r := hs.TestRequest("/index.html", nil); r.Result().StatusCode != 200 {
t.Error(r.Result())
}
if r := hs.TestRequest("/rolodex.html", nil); r.Result().StatusCode != 404 {
t.Error(r.Result())
}
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{"self":""},"PointsLog":[],"Puzzles":{"pategory":[1]}}` {
t.Error("Unexpected state")
}
if r := hs.TestRequest("/register", map[string]string{"id": "bad team id", "name": "GoTeam"}); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if r.Body.String() != `{"status":"fail","data":{"short":"not registered","description":"Team ID not found in list of valid Team IDs"}}` {
t.Error("Register bad team ID failed")
}
if r := hs.TestRequest("/register", map[string]string{"name": "GoTeam"}); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if r.Body.String() != `{"status":"success","data":{"short":"registered","description":"Team ID registered"}}` {
t.Error("Register failed")
}
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{"self":"GoTeam"},"PointsLog":[],"Puzzles":{"pategory":[1]}}` {
t.Error("Unexpected state", r.Body.String())
}
if r := hs.TestRequest("/content/pategory", nil); r.Result().StatusCode != 404 {
t.Error(r.Result())
}
if r := hs.TestRequest("/content/pategory/1/not-here", nil); r.Result().StatusCode != 404 {
t.Error(r.Result())
}
if r := hs.TestRequest("/content/pategory/2/moo.txt", nil); r.Result().StatusCode != 404 {
t.Error(r.Result())
}
if r := hs.TestRequest("/content/pategory/1/", nil); r.Result().StatusCode != 200 {
t.Error(r.Result())
}
if r := hs.TestRequest("/content/pategory/1/moo.txt", nil); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if r.Body.String() != `moo` {
t.Error("Unexpected body", r.Body.String())
}
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "moo"}); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"Incorrect answer"}}` {
t.Error("Unexpected body", r.Body.String())
}
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if r.Body.String() != `{"status":"success","data":{"short":"accepted","description":"1 points awarded in pategory"}}` {
t.Error("Unexpected body", r.Body.String())
}
time.Sleep(TestMaintenanceInterval)
state := StateExport{}
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if err := json.Unmarshal(r.Body.Bytes(), &state); err != nil {
t.Error(err)
} else if len(state.PointsLog) != 1 {
t.Error("Points log wrong length")
} else if len(state.Puzzles["pategory"]) != 2 {
t.Error("Didn't unlock next puzzle")
}
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"Error awarding points: Points already awarded to this team in this category"}}` {
t.Error("Unexpected body", r.Body.String())
}
}
func TestDevelMemHttpd(t *testing.T) {
srv := NewTestServer()
{
hs := NewHTTPServer("/", srv)
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 404 {
t.Error("Should have gotten a 404 for mothballer in prod mode")
}
}
{
srv.Config.Devel = true
hs := NewHTTPServer("/", srv)
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 500 {
t.Log(r.Body.String())
t.Log(r.Result())
t.Error("Should have given us an internal server error, since category is a mothball")
}
}
}
func TestDevelFsHttps(t *testing.T) {
fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata")
transpilerProvider := NewTranspilerProvider(fs)
srv := NewMothServer(Configuration{Devel: true}, NewTestTheme(), NewTestState(), transpilerProvider)
hs := NewHTTPServer("/", srv)
if r := hs.TestRequest("/mothballer/cat0.mb", nil); r.Result().StatusCode != 200 {
t.Log(r.Body.String())
t.Log(r.Result())
t.Error("Didn't get a Mothball")
}
}

91
cmd/mothd/main.go Normal file
View File

@ -0,0 +1,91 @@
package main
import (
"flag"
"fmt"
"mime"
"os"
"time"
"github.com/spf13/afero"
)
func main() {
themePath := flag.String(
"theme",
"theme",
"Path to theme files",
)
statePath := flag.String(
"state",
"state",
"Path to state files",
)
mothballPath := flag.String(
"mothballs",
"mothballs",
"Path to mothball files",
)
puzzlePath := flag.String(
"puzzles",
"",
"Path to puzzles tree (enables development mode)",
)
refreshInterval := flag.Duration(
"refresh",
2*time.Second,
"Duration between maintenance tasks",
)
bindStr := flag.String(
"bind",
":8080",
"Bind [host]:port for HTTP service",
)
base := flag.String(
"base",
"/",
"Base URL of this instance",
)
seed := flag.String(
"seed",
"",
"Random seed to use, overrides $SEED",
)
flag.Parse()
// Set random seed
if *seed == "" {
*seed = os.Getenv("SEED")
}
if *seed == "" {
*seed = fmt.Sprintf("%d%d", os.Getpid(), time.Now().Unix())
}
os.Setenv("SEED", *seed)
osfs := afero.NewOsFs()
theme := NewTheme(afero.NewBasePathFs(osfs, *themePath))
state := NewState(afero.NewBasePathFs(osfs, *statePath))
config := Configuration{}
var provider PuzzleProvider
provider = NewMothballs(afero.NewBasePathFs(osfs, *mothballPath))
if *puzzlePath != "" {
provider = NewTranspilerProvider(afero.NewBasePathFs(osfs, *puzzlePath))
config.Devel = true
}
// 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 theme.Maintain(*refreshInterval)
go state.Maintain(*refreshInterval)
go provider.Maintain(*refreshInterval)
server := NewMothServer(config, theme, state, provider)
httpd := NewHTTPServer(*base, server)
httpd.Run(*bindStr)
}

187
cmd/mothd/mothballs.go Normal file
View File

@ -0,0 +1,187 @@
package main
import (
"archive/zip"
"bufio"
"bytes"
"fmt"
"io"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/spf13/afero"
"github.com/spf13/afero/zipfs"
)
type zipCategory struct {
afero.Fs
io.Closer
}
// Mothballs provides a collection of active mothball files (puzzle categories)
type Mothballs struct {
afero.Fs
categories map[string]zipCategory
categoryLock *sync.RWMutex
}
// NewMothballs returns a new Mothballs structure backed by the provided directory
func NewMothballs(fs afero.Fs) *Mothballs {
return &Mothballs{
Fs: fs,
categories: make(map[string]zipCategory),
categoryLock: new(sync.RWMutex),
}
}
func (m *Mothballs) getCat(cat string) (zipCategory, bool) {
m.categoryLock.RLock()
defer m.categoryLock.RUnlock()
ret, ok := m.categories[cat]
return ret, ok
}
// Open returns a ReadSeekCloser corresponding to the filename in a puzzle's category and points
func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) {
zc, ok := m.getCat(cat)
if !ok {
return nil, time.Time{}, fmt.Errorf("No such category: %s", cat)
}
f, err := zc.Open(fmt.Sprintf("content/%d/%s", points, filename))
if err != nil {
return nil, time.Time{}, err
}
fInfo, err := f.Stat()
return f, fInfo.ModTime(), err
}
// Inventory returns the list of current categories
func (m *Mothballs) Inventory() []Category {
m.categoryLock.RLock()
defer m.categoryLock.RUnlock()
categories := make([]Category, 0, 20)
for cat, zfs := range m.categories {
pointsList := make([]int, 0, 20)
pf, err := zfs.Open("puzzles.txt")
if err != nil {
// No puzzles = no category
continue
}
scanner := bufio.NewScanner(pf)
for scanner.Scan() {
line := scanner.Text()
if pointval, err := strconv.Atoi(line); err != nil {
log.Printf("Reading points for %s: %s", cat, err.Error())
} else {
pointsList = append(pointsList, pointval)
}
}
sort.Ints(pointsList)
categories = append(categories, Category{cat, pointsList})
}
return categories
}
// CheckAnswer returns an error if the provided answer is in any way incorrect for the given category and points
func (m *Mothballs) CheckAnswer(cat string, points int, answer string) (bool, error) {
zfs, ok := m.getCat(cat)
if !ok {
return false, fmt.Errorf("No such category: %s", cat)
}
af, err := zfs.Open("answers.txt")
if err != nil {
return false, fmt.Errorf("No answers.txt file")
}
defer af.Close()
needle := fmt.Sprintf("%d %s", points, answer)
scanner := bufio.NewScanner(af)
for scanner.Scan() {
if scanner.Text() == needle {
return true, nil
}
}
return false, nil
}
// refresh refreshes internal state.
// It looks for changes to the directory listing, and caches any new mothballs.
func (m *Mothballs) refresh() {
m.categoryLock.Lock()
defer m.categoryLock.Unlock()
// Any new categories?
files, err := afero.ReadDir(m.Fs, "/")
if err != nil {
log.Println("Error listing mothballs:", err)
return
}
found := make(map[string]bool)
for _, f := range files {
filename := f.Name()
if !strings.HasSuffix(filename, ".mb") {
continue
}
categoryName := strings.TrimSuffix(filename, ".mb")
found[categoryName] = true
if _, ok := m.categories[categoryName]; !ok {
f, err := m.Fs.Open(filename)
if err != nil {
log.Println(err)
continue
}
fi, err := f.Stat()
if err != nil {
f.Close()
log.Println(err)
continue
}
zrc, err := zip.NewReader(f, fi.Size())
if err != nil {
f.Close()
log.Println(err)
continue
}
m.categories[categoryName] = zipCategory{
Fs: zipfs.New(zrc),
Closer: f,
}
log.Println("Adding category:", categoryName)
}
}
// Delete anything in the list that wasn't found
for categoryName, zc := range m.categories {
if !found[categoryName] {
zc.Close()
delete(m.categories, categoryName)
log.Println("Removing category:", categoryName)
}
}
}
// Mothball just returns an error
func (m *Mothballs) Mothball(cat string) (*bytes.Reader, error) {
return nil, fmt.Errorf("Can't repackage a compiled mothball")
}
// Maintain performs housekeeping for Mothballs.
func (m *Mothballs) Maintain(updateInterval time.Duration) {
m.refresh()
for range time.NewTicker(updateInterval).C {
m.refresh()
}
}

107
cmd/mothd/mothballs_test.go Normal file
View File

@ -0,0 +1,107 @@
package main
import (
"archive/zip"
"fmt"
"testing"
"github.com/spf13/afero"
)
var testFiles = []struct {
Name, Body string
}{
{"puzzles.txt", "1\n3\n2\n"},
{"answers.txt", "1 answer123\n1 answer456\n2 wat\n"},
{"content/1/puzzle.json", `{"name": "moo"}`},
{"content/1/moo.txt", `moo`},
{"content/2/puzzle.json", `{}`},
{"content/2/moo.txt", `moo`},
{"content/3/puzzle.json", `{}`},
{"content/3/moo.txt", `moo`},
}
func (m *Mothballs) createMothball(cat string) {
f, _ := m.Create(fmt.Sprintf("%s.mb", cat))
defer f.Close()
w := zip.NewWriter(f)
defer w.Close()
for _, file := range testFiles {
of, _ := w.Create(file.Name)
of.Write([]byte(file.Body))
}
}
func NewTestMothballs() *Mothballs {
m := NewMothballs(new(afero.MemMapFs))
m.createMothball("pategory")
m.refresh()
return m
}
func TestMothballs(t *testing.T) {
m := NewTestMothballs()
if _, ok := m.categories["pategory"]; !ok {
t.Error("Didn't create a new category")
}
inv := m.Inventory()
if len(inv) != 1 {
t.Error("Wrong inventory size:", inv)
}
for _, cat := range inv {
switch cat.Name {
case "pategory":
if len(cat.Puzzles) != 3 {
t.Error("Puzzles list wrong length")
}
if cat.Puzzles[1] != 2 {
t.Error("Puzzles list not sorted")
}
}
for _, points := range cat.Puzzles {
f, _, err := m.Open(cat.Name, points, "puzzle.json")
if err != nil {
t.Error(cat.Name, err)
continue
}
f.Close()
}
}
if f, _, err := m.Open("nealegory", 1, "puzzle.json"); err == nil {
f.Close()
t.Error("You can't open a puzzle in a nealegory, that doesn't even rhyme!")
}
if f, _, err := m.Open("pategory", 1, "bozo"); err == nil {
f.Close()
t.Error("This file shouldn't exist")
}
if ok, _ := m.CheckAnswer("pategory", 1, "answer"); ok {
t.Error("Wrong answer marked right")
}
if _, err := m.CheckAnswer("pategory", 1, "answer123"); err != nil {
t.Error("Right answer marked wrong", err)
}
if _, err := m.CheckAnswer("pategory", 1, "answer456"); err != nil {
t.Error("Right answer marked wrong", err)
}
if ok, err := m.CheckAnswer("nealegory", 1, "moo"); ok {
t.Error("Checking answer in non-existent category should fail")
} else if err.Error() != "No such category: nealegory" {
t.Error("Wrong error message")
}
m.createMothball("test2")
m.Fs.Remove("pategory.mb")
m.refresh()
inv = m.Inventory()
if len(inv) != 1 {
t.Error("Deleted mothball is still around", inv)
}
}

View File

@ -0,0 +1,133 @@
// Provides a Puzzle interface that runs a command for each request
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
)
// ProviderCommand specifies a command to run for the puzzle API
type ProviderCommand struct {
Path string
Args []string
}
// Inventory runs with "action=inventory", and parses the output into a category list.
func (pc ProviderCommand) Inventory() (inv []Category) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, pc.Path, pc.Args...)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "ACTION=inventory")
stdout, err := cmd.Output()
if err != nil {
log.Print(err)
return
}
for _, line := range strings.Split(string(stdout), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, " ")
if len(parts) < 2 {
log.Println("Skipping misformatted line:", line)
continue
}
name := parts[0]
puzzles := make([]int, 0, 10)
for _, pointsString := range parts[1:] {
points, err := strconv.Atoi(pointsString)
if err != nil {
log.Println(err)
continue
}
puzzles = append(puzzles, points)
}
sort.Ints(puzzles)
inv = append(inv, Category{name, puzzles})
}
return
}
// NullReadSeekCloser wraps a no-op Close method around an io.ReadSeeker.
type NullReadSeekCloser struct {
io.ReadSeeker
}
// Close does nothing.
func (f NullReadSeekCloser) Close() error {
return nil
}
// Open passes its arguments to the command with "action=open".
func (pc ProviderCommand) Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, pc.Path, pc.Args...)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "ACTION=open")
cmd.Env = append(cmd.Env, "CAT="+cat)
cmd.Env = append(cmd.Env, "POINTS="+strconv.Itoa(points))
cmd.Env = append(cmd.Env, "FILENAME="+path)
stdoutBytes, err := cmd.Output()
stdout := NullReadSeekCloser{bytes.NewReader(stdoutBytes)}
now := time.Now()
return stdout, now, err
}
// CheckAnswer passes its arguments to the command with "action=answer".
// If the command exits successfully and sends "correct" to stdout,
// nil is returned.
func (pc ProviderCommand) CheckAnswer(cat string, points int, answer string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, pc.Path, pc.Args...)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "ACTION=answer")
cmd.Env = append(cmd.Env, "CAT="+cat)
cmd.Env = append(cmd.Env, "POINTS="+strconv.Itoa(points))
cmd.Env = append(cmd.Env, "ANSWER="+answer)
stdout, err := cmd.Output()
if ee, ok := err.(*exec.ExitError); ok {
log.Printf("%s: %s", pc.Path, string(ee.Stderr))
return false, err
} else if err != nil {
return false, err
}
result := strings.TrimSpace(string(stdout))
if result != "correct" {
if result == "" {
result = "Nothing written to stdout"
}
return false, nil
}
return true, nil
}
// Mothball just returns an error
func (pc ProviderCommand) Mothball(cat string) (*bytes.Reader, error) {
return nil, fmt.Errorf("Can't package a command-generated category")
}
// Maintain does nothing: a command puzzle ProviderCommand has no housekeeping
func (pc ProviderCommand) Maintain(updateInterval time.Duration) {
}

View File

@ -0,0 +1,70 @@
package main
import (
"io/ioutil"
"os/exec"
"testing"
)
func TestProviderCommand(t *testing.T) {
pc := ProviderCommand{
Path: "testdata/testpiler.sh",
}
inv := pc.Inventory()
if len(inv) != 2 {
t.Errorf("Wrong length for inventory")
}
for _, cat := range inv {
switch cat.Name {
case "pategory":
if len(cat.Puzzles) != 8 {
t.Errorf("pategory wrong number of puzzles: %d", len(cat.Puzzles))
}
if cat.Puzzles[5] != 10 {
t.Errorf("pategory puzzles[5] wrong value: %d", cat.Puzzles[5])
}
case "nealegory":
if len(cat.Puzzles) != 3 {
t.Errorf("nealegoy wrong number of puzzles: %d", len(cat.Puzzles))
}
if cat.Puzzles[2] != 3 {
t.Errorf("out of order point values were not sorted")
}
}
}
if ok, err := pc.CheckAnswer("pategory", 1, "answer"); !ok {
t.Errorf("Correct answer for pategory: %v", err)
}
if ok, _ := pc.CheckAnswer("pategory", 1, "wrong"); ok {
t.Errorf("Wrong answer for pategory judged correct")
}
if _, err := pc.CheckAnswer("pategory", 2, "answer"); err == nil {
t.Errorf("Internal error not returned")
} else if ee, ok := err.(*exec.ExitError); ok {
if string(ee.Stderr) != "Internal error\n" {
t.Errorf("Unexpected error returned: %#v", string(ee.Stderr))
}
} else if err.Error() != "moo" {
t.Error(err)
}
if f, _, err := pc.Open("pategory", 1, "moo.txt"); err != nil {
t.Error(err)
} else if buf, err := ioutil.ReadAll(f); err != nil {
f.Close()
t.Error(err)
} else if string(buf) != "Moo.\n" {
f.Close()
t.Errorf("Wrong contents: %#v", string(buf))
} else {
f.Close()
}
if f, _, err := pc.Open("pategory", 1, "not.there"); err == nil {
f.Close()
t.Errorf("Non-existent file didn't return error: %#v", f)
}
}

246
cmd/mothd/server.go Normal file
View File

@ -0,0 +1,246 @@
package main
import (
"bytes"
"fmt"
"io"
"strconv"
"time"
"github.com/dirtbags/moth/pkg/award"
)
// Category represents a puzzle category.
type Category struct {
Name string
Puzzles []int
}
// ReadSeekCloser defines a struct that can read, seek, and close.
type ReadSeekCloser interface {
io.Reader
io.Seeker
io.Closer
}
// Configuration stores information about server configuration.
type Configuration struct {
Devel bool
}
// StateExport is given to clients requesting the current state.
type StateExport struct {
Config Configuration
Messages string
TeamNames map[string]string
PointsLog award.List
Puzzles map[string][]int
}
// PuzzleProvider defines what's required to provide puzzles.
type PuzzleProvider interface {
Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error)
Inventory() []Category
CheckAnswer(cat string, points int, answer string) (bool, error)
Mothball(cat string) (*bytes.Reader, error)
Maintainer
}
// ThemeProvider defines what's required to provide a theme.
type ThemeProvider interface {
Open(path string) (ReadSeekCloser, time.Time, error)
Maintainer
}
// StateProvider defines what's required to provide MOTH state.
type StateProvider interface {
Messages() string
PointsLog() award.List
TeamName(teamID string) (string, error)
SetTeamName(teamID, teamName string) error
AwardPoints(teamID string, cat string, points int) error
LogEvent(msg string)
Maintainer
}
// Maintainer is something that can be maintained.
type Maintainer interface {
// Maintain is the maintenance loop.
// It will only be called once, when execution begins.
// It's okay to just exit if there's no maintenance to be done.
Maintain(updateInterval time.Duration)
}
// MothServer gathers together the providers that make up a MOTH server.
type MothServer struct {
PuzzleProviders []PuzzleProvider
Theme ThemeProvider
State StateProvider
Config Configuration
}
// NewMothServer returns a new MothServer.
func NewMothServer(config Configuration, theme ThemeProvider, state StateProvider, puzzleProviders ...PuzzleProvider) *MothServer {
return &MothServer{
Config: config,
PuzzleProviders: puzzleProviders,
Theme: theme,
State: state,
}
}
// NewHandler returns a new http.RequestHandler for the provided teamID.
func (s *MothServer) NewHandler(participantID, teamID string) MothRequestHandler {
return MothRequestHandler{
MothServer: s,
participantID: participantID,
teamID: teamID,
}
}
// MothRequestHandler provides http.RequestHandler for a MothServer.
type MothRequestHandler struct {
*MothServer
participantID string
teamID string
}
// PuzzlesOpen opens a file associated with a puzzle.
// BUG(neale): Multiple providers with the same category name are not detected or handled well.
func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r ReadSeekCloser, ts time.Time, err error) {
export := mh.ExportState()
found := false
for _, p := range export.Puzzles[cat] {
if p == points {
found = true
}
}
if !found {
return nil, time.Time{}, fmt.Errorf("Category not found")
}
// Try every provider until someone doesn't return an error
for _, provider := range mh.PuzzleProviders {
r, ts, err = provider.Open(cat, points, path)
if err != nil {
return r, ts, err
}
}
return
}
// CheckAnswer returns an error if answer is not a correct answer for puzzle points in category cat
func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string) error {
correct := false
for _, provider := range mh.PuzzleProviders {
if ok, err := provider.CheckAnswer(cat, points, answer); err != nil {
return err
} else if ok {
correct = true
}
}
if !correct {
return fmt.Errorf("Incorrect answer")
}
msg := fmt.Sprintf("GOOD %s %s %s %d", mh.participantID, mh.teamID, cat, points)
mh.State.LogEvent(msg)
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
return fmt.Errorf("Error awarding points: %s", err)
}
return nil
}
// ThemeOpen opens a file from a theme.
func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, error) {
return mh.Theme.Open(path)
}
// Register associates a team name with a team ID.
func (mh *MothRequestHandler) Register(teamName string) error {
// BUG(neale): Register returns an error if a team is already registered; it may make more sense to return success
if teamName == "" {
return fmt.Errorf("Empty team name")
}
return mh.State.SetTeamName(mh.teamID, teamName)
}
// ExportState anonymizes team IDs and returns StateExport.
// If a teamID has been specified for this MothRequestHandler,
// the anonymized team name for this teamID has the special value "self".
// If not, the puzzles list is empty.
func (mh *MothRequestHandler) ExportState() *StateExport {
export := StateExport{}
export.Config = mh.Config
teamName, _ := mh.State.TeamName(mh.teamID)
export.Messages = mh.State.Messages()
export.TeamNames = map[string]string{"self": teamName}
// Anonymize team IDs in points log, and write out team names
pointsLog := mh.State.PointsLog()
exportIDs := map[string]string{mh.teamID: "self"}
maxSolved := map[string]int{}
export.PointsLog = make(award.List, len(pointsLog))
for logno, awd := range pointsLog {
if id, ok := exportIDs[awd.TeamID]; ok {
awd.TeamID = id
} else {
exportID := strconv.Itoa(logno)
name, _ := mh.State.TeamName(awd.TeamID)
awd.TeamID = exportID
exportIDs[awd.TeamID] = awd.TeamID
export.TeamNames[exportID] = name
}
export.PointsLog[logno] = awd
// Record the highest-value unlocked puzzle in each category
if awd.Points > maxSolved[awd.Category] {
maxSolved[awd.Category] = awd.Points
}
}
export.Puzzles = make(map[string][]int)
if _, ok := export.TeamNames["self"]; ok {
// We used to hand this out to everyone,
// but then we got a bad reputation on some secretive blacklist,
// and now the Navy can't register for events.
for _, provider := range mh.PuzzleProviders {
for _, category := range provider.Inventory() {
// Append sentry (end of puzzles)
allPuzzles := append(category.Puzzles, 0)
max := maxSolved[category.Name]
puzzles := make([]int, 0, len(allPuzzles))
for i, val := range allPuzzles {
puzzles = allPuzzles[:i+1]
if !mh.Config.Devel && (val > max) {
break
}
}
export.Puzzles[category.Name] = puzzles
}
}
}
return &export
}
// Mothball generates a mothball for the given category.
func (mh *MothRequestHandler) Mothball(cat string) (r *bytes.Reader, err error) {
if !mh.Config.Devel {
return nil, fmt.Errorf("Cannot mothball in production mode")
}
for _, provider := range mh.PuzzleProviders {
if r, err = provider.Mothball(cat); err == nil {
return r, nil
}
}
return nil, err
}

102
cmd/mothd/server_test.go Normal file
View File

@ -0,0 +1,102 @@
package main
import (
"io/ioutil"
"testing"
"time"
"github.com/spf13/afero"
)
const TestMaintenanceInterval = time.Millisecond * 1
const TestTeamID = "teamID"
func NewTestServer() *MothServer {
puzzles := NewTestMothballs()
go puzzles.Maintain(TestMaintenanceInterval)
state := NewTestState()
afero.WriteFile(state, "teamids.txt", []byte("teamID\n"), 0644)
afero.WriteFile(state, "messages.html", []byte("messages.html"), 0644)
go state.Maintain(TestMaintenanceInterval)
theme := NewTestTheme()
afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644)
go theme.Maintain(TestMaintenanceInterval)
return NewMothServer(Configuration{}, theme, state, puzzles)
}
func TestServer(t *testing.T) {
teamName := "OurTeam"
participantID := "participantID"
teamID := TestTeamID
server := NewTestServer()
handler := server.NewHandler(participantID, teamID)
if err := handler.Register(teamName); err != nil {
t.Error(err)
}
if r, _, err := handler.ThemeOpen("/index.html"); err != nil {
t.Error(err)
} else if contents, err := ioutil.ReadAll(r); err != nil {
t.Error(err)
} else if string(contents) != "index.html" {
t.Error("index.html wrong contents", contents)
}
es := handler.ExportState()
if es.Config.Devel {
t.Error("Marked as development server", es.Config)
}
if len(es.Puzzles) != 1 {
t.Error("Puzzle categories wrong length")
}
if es.Messages != "messages.html" {
t.Error("Messages has wrong contents")
}
if len(es.PointsLog) != 0 {
t.Error("Points log not empty")
}
if len(es.TeamNames) != 1 {
t.Error("Wrong number of team names")
}
if es.TeamNames["self"] != teamName {
t.Error("TeamNames['self'] wrong")
}
if r, _, err := handler.PuzzlesOpen("pategory", 1, "moo.txt"); err != nil {
t.Error(err)
} else if contents, err := ioutil.ReadAll(r); err != nil {
r.Close()
t.Error(err)
} else if string(contents) != "moo" {
r.Close()
t.Error("moo.txt has wrong contents", contents)
} else {
r.Close()
}
if r, _, err := handler.PuzzlesOpen("pategory", 2, "puzzles.json"); err == nil {
t.Error("Opening locked puzzle shouldn't work")
r.Close()
}
if r, _, err := handler.PuzzlesOpen("pategory", 20, "puzzles.json"); err == nil {
t.Error("Opening non-existent puzzle shouldn't work")
r.Close()
}
if err := handler.CheckAnswer("pategory", 1, "answer123"); err != nil {
t.Error("Right answer marked wrong", err)
}
time.Sleep(TestMaintenanceInterval)
es = handler.ExportState()
if len(es.PointsLog) != 1 {
t.Error("I didn't get my points!")
}
// BUG(neale): We aren't currently testing the various ways to disable the server
}

405
cmd/mothd/state.go Normal file
View File

@ -0,0 +1,405 @@
package main
import (
"bufio"
"fmt"
"log"
"math/rand"
"os"
"path/filepath"
"strings"
"time"
"github.com/dirtbags/moth/pkg/award"
"github.com/spf13/afero"
)
// DistinguishableChars are visually unambiguous glyphs.
// People with mediocre handwriting could write these down unambiguously,
// and they can be entered without holding down shift.
const DistinguishableChars = "34678abcdefhikmnpqrtwxy="
// RFC3339Space is a time layout which replaces 'T' with a space.
// This is also a valid RFC3339 format.
const RFC3339Space = "2006-01-02 15:04:05Z07:00"
// State defines the current state of a MOTH instance.
// We use the filesystem for synchronization between threads.
// The only thing State methods need to know is the path to the state directory.
type State struct {
afero.Fs
// Enabled tracks whether the current State system is processing updates
Enabled bool
refreshNow chan bool
eventStream chan string
eventWriter afero.File
}
// NewState returns a new State struct backed by the given Fs
func NewState(fs afero.Fs) *State {
s := &State{
Fs: fs,
Enabled: true,
refreshNow: make(chan bool, 5),
eventStream: make(chan string, 80),
}
if err := s.reopenEventLog(); err != nil {
log.Fatal(err)
}
return s
}
// updateEnabled checks a few things to see if this state directory is "enabled".
func (s *State) updateEnabled() {
nextEnabled := true
why := "`state/enabled` present, `state/hours.txt` missing"
if untilFile, err := s.Open("hours.txt"); err == nil {
defer untilFile.Close()
why = "`state/hours.txt` present"
scanner := bufio.NewScanner(untilFile)
for scanner.Scan() {
line := scanner.Text()
if len(line) < 1 {
continue
}
thisEnabled := true
switch line[0] {
case '+':
thisEnabled = true
line = line[1:]
case '-':
thisEnabled = false
line = line[1:]
case '#':
continue
default:
log.Println("Misformatted line in hours.txt file")
}
line = strings.TrimSpace(line)
until, err := time.Parse(time.RFC3339, line)
if err != nil {
until, err = time.Parse(RFC3339Space, line)
}
if err != nil {
log.Println("Suspended: Unparseable until date:", line)
continue
}
if until.Before(time.Now()) {
nextEnabled = thisEnabled
}
}
}
if _, err := s.Stat("enabled"); os.IsNotExist(err) {
dirs, _ := afero.ReadDir(s, ".")
for _, dir := range dirs {
log.Println(dir.Name())
}
log.Print(s, err)
nextEnabled = false
why = "`state/enabled` missing"
}
if nextEnabled != s.Enabled {
s.Enabled = nextEnabled
log.Printf("Setting enabled=%v: %s", s.Enabled, why)
}
}
// TeamName returns team name given a team ID.
func (s *State) TeamName(teamID string) (string, error) {
teamFs := afero.NewBasePathFs(s.Fs, "teams")
teamNameBytes, err := afero.ReadFile(teamFs, teamID)
if os.IsNotExist(err) {
return "", fmt.Errorf("Unregistered team ID: %s", teamID)
} else if err != nil {
return "", fmt.Errorf("Unregistered team ID: %s (%s)", teamID, err)
}
teamName := strings.TrimSpace(string(teamNameBytes))
return teamName, nil
}
// SetTeamName writes out team name.
// This can only be done once.
func (s *State) SetTeamName(teamID, teamName string) error {
idsFile, err := s.Open("teamids.txt")
if err != nil {
return fmt.Errorf("Team IDs file does not exist")
}
defer idsFile.Close()
found := false
scanner := bufio.NewScanner(idsFile)
for scanner.Scan() {
if scanner.Text() == teamID {
found = true
break
}
}
if !found {
return fmt.Errorf("Team ID not found in list of valid Team IDs")
}
teamFilename := filepath.Join("teams", teamID)
teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_EXCL, 0644)
if os.IsExist(err) {
return fmt.Errorf("Team ID is already registered")
} else if err != nil {
return err
}
defer teamFile.Close()
fmt.Fprintln(teamFile, teamName)
return nil
}
// PointsLog retrieves the current points log.
func (s *State) PointsLog() award.List {
f, err := s.Open("points.log")
if err != nil {
log.Println(err)
return nil
}
defer f.Close()
pointsLog := make(award.List, 0, 200)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
log.Println(line)
cur, err := award.Parse(line)
if err != nil {
log.Printf("Skipping malformed award line %s: %s", line, err)
continue
}
pointsLog = append(pointsLog, cur)
}
return pointsLog
}
// Messages retrieves the current messages.
func (s *State) Messages() string {
bMessages, _ := afero.ReadFile(s, "messages.html")
return string(bMessages)
}
// 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 update task makes sure we never have duplicate points in the log.
func (s *State) AwardPoints(teamID, category string, points int) error {
a := award.T{
When: time.Now().Unix(),
TeamID: teamID,
Category: category,
Points: points,
}
_, err := s.TeamName(teamID)
if err != nil {
return err
}
for _, e := range s.PointsLog() {
if a.Equal(e) {
return fmt.Errorf("Points already awarded to this team in this category")
}
}
fn := fmt.Sprintf("%s-%s-%d", teamID, category, points)
tmpfn := filepath.Join("points.tmp", fn)
newfn := filepath.Join("points.new", fn)
if err := afero.WriteFile(s, tmpfn, []byte(a.String()), 0644); err != nil {
return err
}
if err := s.Rename(tmpfn, newfn); err != nil {
return err
}
// State should be updated immediately
s.refreshNow <- true
return nil
}
// collectPoints gathers up files in points.new/ and appends their contents to points.log,
// removing each points.new/ file as it goes.
func (s *State) collectPoints() {
files, err := afero.ReadDir(s, "points.new")
if err != nil {
log.Print(err)
return
}
for _, f := range files {
filename := filepath.Join("points.new", f.Name())
awardstr, err := afero.ReadFile(s, filename)
if err != nil {
log.Print("Opening new points: ", err)
continue
}
awd, err := award.Parse(string(awardstr))
if err != nil {
log.Print("Can't parse award file ", filename, ": ", err)
continue
}
duplicate := false
for _, e := range s.PointsLog() {
if awd.Equal(e) {
duplicate = true
break
}
}
if duplicate {
log.Print("Skipping duplicate points: ", awd.String())
} else {
log.Print("Award: ", awd.String())
logf, err := s.OpenFile("points.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Print("Can't append to points log: ", err)
return
}
fmt.Fprintln(logf, awd.String())
logf.Close()
}
if err := s.Remove(filename); err != nil {
log.Print("Unable to remove new points file: ", err)
}
}
}
func (s *State) maybeInitialize() {
// Are we supposed to re-initialize?
if _, err := s.Stat("initialized"); !os.IsNotExist(err) {
return
}
now := time.Now().UTC().Format(time.RFC3339)
log.Print("initialized file missing, re-initializing")
// Remove any extant control and state files
s.Remove("enabled")
s.Remove("hours.txt")
s.Remove("points.log")
s.Remove("messages.html")
s.Remove("mothd.log")
s.RemoveAll("points.tmp")
s.RemoveAll("points.new")
s.RemoveAll("teams")
// Open log file
if err := s.reopenEventLog(); err != nil {
log.Fatal(err)
}
// Make sure various subdirectories exist
s.Mkdir("points.tmp", 0755)
s.Mkdir("points.new", 0755)
s.Mkdir("teams", 0755)
// Preseed available team ids if file doesn't exist
if f, err := s.OpenFile("teamids.txt", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
id := make([]byte, 8)
for i := 0; i < 100; i++ {
for i := range id {
char := rand.Intn(len(DistinguishableChars))
id[i] = DistinguishableChars[char]
}
fmt.Fprintln(f, string(id))
}
f.Close()
}
// Create some files
if f, err := s.Create("initialized"); err == nil {
fmt.Fprintln(f, "initialized: remove to re-initialize the contest.")
fmt.Fprintln(f)
fmt.Fprintln(f, "This instance was initaliazed at", now)
f.Close()
}
if f, err := s.Create("enabled"); err == nil {
fmt.Fprintln(f, "enabled: remove or rename to suspend the contest.")
f.Close()
}
if f, err := s.Create("hours.txt"); err == nil {
fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# Enable: + timestamp")
fmt.Fprintln(f, "# Disable: - timestamp")
fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# You can have multiple start/stop times.")
fmt.Fprintln(f, "# Whatever time is the most recent, wins.")
fmt.Fprintln(f, "# Times in the future are ignored.")
fmt.Fprintln(f)
fmt.Fprintln(f, "+", now)
fmt.Fprintln(f, "- 3019-10-31T00:00:00Z")
f.Close()
}
if f, err := s.Create("messages.html"); err == nil {
fmt.Fprintln(f, "<!-- messages.html: put client broadcast messages here. -->")
f.Close()
}
if f, err := s.Create("points.log"); err == nil {
f.Close()
}
}
// LogEvent writes msg to the event log
func (s *State) LogEvent(msg string) {
s.eventStream <- msg
}
func (s *State) reopenEventLog() error {
if s.eventWriter != nil {
if err := s.eventWriter.Close(); err != nil {
// We're going to soldier on if Close returns error
log.Print(err)
}
}
eventWriter, err := s.OpenFile("event.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
s.eventWriter = eventWriter
return nil
}
func (s *State) refresh() {
s.maybeInitialize()
s.updateEnabled()
if s.Enabled {
s.collectPoints()
}
}
// Maintain performs housekeeping on a State struct.
func (s *State) Maintain(updateInterval time.Duration) {
ticker := time.NewTicker(updateInterval)
s.refresh()
for {
select {
case msg := <-s.eventStream:
fmt.Fprintln(s.eventWriter, time.Now().Unix(), msg)
s.eventWriter.Sync()
case <-ticker.C:
s.refresh()
case <-s.refreshNow:
s.refresh()
}
}
}

251
cmd/mothd/state_test.go Normal file
View File

@ -0,0 +1,251 @@
package main
import (
"bytes"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/spf13/afero"
)
func NewTestState() *State {
s := NewState(new(afero.MemMapFs))
s.refresh()
return s
}
func TestState(t *testing.T) {
s := NewTestState()
mustExist := func(path string) {
_, err := s.Fs.Stat(path)
if os.IsNotExist(err) {
t.Errorf("File %s does not exist", path)
}
}
pl := s.PointsLog()
if len(pl) != 0 {
t.Errorf("Empty points log is not empty")
}
mustExist("initialized")
mustExist("enabled")
mustExist("hours.txt")
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
if err != nil {
t.Errorf("Reading teamids.txt: %v", err)
}
teamIDs := bytes.Split(teamIDsBuf, []byte("\n"))
if (len(teamIDs) != 101) || (len(teamIDs[100]) > 0) {
t.Errorf("There weren't 100 teamIDs, there were %d", len(teamIDs))
}
teamID := string(teamIDs[0])
if _, err := s.TeamName(teamID); err == nil {
t.Errorf("Bad team ID lookup didn't return error")
}
if err := s.SetTeamName("bad team ID", "bad team name"); err == nil {
t.Errorf("Setting bad team ID didn't raise an error")
}
if err := s.SetTeamName(teamID, "My Team"); err != nil {
t.Errorf("Setting team name: %v", err)
}
if err := s.SetTeamName(teamID, "wat"); err == nil {
t.Errorf("Registering team a second time didn't fail")
}
category := "poot"
points := 3928
if err := s.AwardPoints(teamID, category, points); err != nil {
t.Error(err)
}
if err := s.AwardPoints(teamID, category, points); err != nil {
t.Error("Two awards before refresh:", err)
}
// Flex duplicate detection with different timestamp
if f, err := s.Create("points.new/moo"); err != nil {
t.Error("Creating duplicate points file:", err)
} else {
fmt.Fprintln(f, time.Now().Unix()+1, teamID, category, points)
f.Close()
}
s.refresh()
if err := s.AwardPoints(teamID, category, points); err == nil {
t.Error("Duplicate points award didn't fail")
}
pl = s.PointsLog()
if len(pl) != 1 {
t.Errorf("After awarding points, points log has length %d", len(pl))
} else if (pl[0].TeamID != teamID) || (pl[0].Category != category) || (pl[0].Points != points) {
t.Errorf("Incorrect logged award %v", pl)
}
afero.WriteFile(s, "points.log", []byte("intentional parse error\n"), 0644)
if len(s.PointsLog()) != 0 {
t.Errorf("Intentional parse error breaks pointslog")
}
if err := s.AwardPoints(teamID, category, points); err != nil {
t.Error(err)
}
s.refresh()
if len(s.PointsLog()) != 1 {
t.Error("Intentional parse error screws up all parsing")
}
s.Fs.Remove("initialized")
s.refresh()
pl = s.PointsLog()
if len(pl) != 0 {
t.Errorf("After reinitialization, points log has length %d", len(pl))
}
}
func TestStateEvents(t *testing.T) {
s := NewTestState()
s.LogEvent("moo")
s.LogEvent("moo 2")
if msg := <-s.eventStream; msg != "moo" {
t.Error("Wrong message from event stream", msg)
}
if msg := <-s.eventStream; msg != "moo 2" {
t.Error("Formatted event is wrong:", msg)
}
}
func TestStateDisabled(t *testing.T) {
s := NewTestState()
s.refresh()
if !s.Enabled {
t.Error("Brand new state is disabled")
}
hoursFile, err := s.Create("hours.txt")
if err != nil {
t.Error(err)
}
defer hoursFile.Close()
fmt.Fprintln(hoursFile, "- 1970-01-01T01:01:01Z")
hoursFile.Sync()
s.refresh()
if s.Enabled {
t.Error("Disabling 1970-01-01")
}
fmt.Fprintln(hoursFile, "+ 1970-01-01 01:01:01+05:00")
hoursFile.Sync()
s.refresh()
if !s.Enabled {
t.Error("Enabling 1970-01-02")
}
fmt.Fprintln(hoursFile, "")
fmt.Fprintln(hoursFile, "# Comment")
hoursFile.Sync()
s.refresh()
if !s.Enabled {
t.Error("Comments")
}
fmt.Fprintln(hoursFile, "intentional parse error")
hoursFile.Sync()
s.refresh()
if !s.Enabled {
t.Error("intentional parse error")
}
fmt.Fprintln(hoursFile, "- 1980-01-01T01:01:01Z")
hoursFile.Sync()
s.refresh()
if s.Enabled {
t.Error("Disabling 1980-01-01")
}
if err := s.Remove("hours.txt"); err != nil {
t.Error(err)
}
s.refresh()
if !s.Enabled {
t.Error("Removing `hours.txt` disabled event")
}
if err := s.Remove("enabled"); err != nil {
t.Error(err)
}
s.refresh()
if s.Enabled {
t.Error("Removing `enabled` didn't disable")
}
s.Remove("initialized")
s.refresh()
if !s.Enabled {
t.Error("Re-initalizing didn't start event")
}
}
func TestStateMaintainer(t *testing.T) {
updateInterval := 10 * time.Millisecond
s := NewTestState()
go s.Maintain(updateInterval)
if _, err := s.Stat("initialized"); err != nil {
t.Error(err)
}
teamIDLines, err := afero.ReadFile(s, "teamids.txt")
if err != nil {
t.Error(err)
}
teamIDList := strings.Split(string(teamIDLines), "\n")
if len(teamIDList) != 101 {
t.Error("TeamIDList length is", len(teamIDList))
}
teamID := teamIDList[0]
if len(teamID) < 6 {
t.Error("Team ID too short:", teamID)
}
s.LogEvent("Hello!")
if len(s.PointsLog()) != 0 {
t.Error("Points log is not empty")
}
if err := s.SetTeamName(teamID, "The Patricks"); err != nil {
t.Error(err)
}
if err := s.AwardPoints(teamID, "pategory", 31337); err != nil {
t.Error(err)
}
time.Sleep(updateInterval)
pl := s.PointsLog()
if len(pl) != 1 {
t.Error("Points log should have one entry")
}
if (pl[0].Category != "pategory") || (pl[0].TeamID != teamID) {
t.Error("Wrong points event was recorded")
}
time.Sleep(updateInterval)
eventLog, err := afero.ReadFile(s.Fs, "event.log")
if err != nil {
t.Error(err)
} else if len(eventLog) != 18 {
t.Error("Wrong event log length:", len(eventLog))
}
}

3
cmd/mothd/testdata/cat0/1/puzzle.md vendored Normal file
View File

@ -0,0 +1,3 @@
author: neale
Hello, world.

40
cmd/mothd/testdata/testpiler.sh vendored Executable file
View File

@ -0,0 +1,40 @@
#! /bin/sh -e
fail () {
echo "$@" 1>&2
exit 1
}
case "$ACTION:$CAT:$POINTS" in
inventory::)
cat <<EOT
{
"pategory": [1, 2, 3, 4, 5, 10, 20, 300],
"nealegory": [1, 3, 2]
}
EOT
;;
open:*:*)
case "$CAT:$POINTS:$FILENAME" in
*:*:moo.txt)
echo "Moo."
;;
*)
fail "Cannot open: $FILENAME"
;;
esac
;;
answer:pategory:1)
if [ "$ANSWER" = "answer" ]; then
echo "correct"
else
echo "Sorry, wrong answer."
fi
;;
answer:pategory:2)
fail "Internal error"
;;
*)
fail "ERROR: Unknown action: $action"
;;
esac

40
cmd/mothd/theme.go Normal file
View File

@ -0,0 +1,40 @@
package main
import (
"time"
"github.com/spf13/afero"
)
// Theme defines a filesystem-backed ThemeProvider.
type Theme struct {
afero.Fs
}
// NewTheme returns a new Theme, backed by Fs.
func NewTheme(fs afero.Fs) *Theme {
return &Theme{
Fs: fs,
}
}
// Open returns a new opened file.
func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) {
f, err := t.Fs.Open(name)
if err != nil {
return nil, time.Time{}, err
}
fi, err := f.Stat()
if err != nil {
f.Close()
return nil, time.Time{}, err
}
return f, fi.ModTime(), nil
}
// Maintain performs housekeeping for a Theme.
func (t *Theme) Maintain(i time.Duration) {
// No periodic tasks for a theme
}

39
cmd/mothd/theme_test.go Normal file
View File

@ -0,0 +1,39 @@
package main
import (
"io/ioutil"
"testing"
"github.com/spf13/afero"
)
func NewTestTheme() *Theme {
return NewTheme(new(afero.MemMapFs))
}
func TestTheme(t *testing.T) {
s := NewTestTheme()
filename := "/index.html"
index := "this is the index"
afero.WriteFile(s.Fs, filename, []byte(index), 0644)
fileInfo, err := s.Fs.Stat(filename)
if err != nil {
t.Error(err)
}
if f, timestamp, err := s.Open("/index.html"); err != nil {
t.Error(err)
} else if buf, err := ioutil.ReadAll(f); err != nil {
t.Error(err)
} else if string(buf) != index {
t.Error("Read wrong value from index")
} else if !timestamp.Equal(fileInfo.ModTime()) {
t.Error("Timestamp compared wrong")
}
if f, _, err := s.Open("nofile"); err == nil {
f.Close()
t.Error("Opening non-existent file didn't return an error")
}
}

81
cmd/mothd/transpiler.go Normal file
View File

@ -0,0 +1,81 @@
package main
import (
"bytes"
"encoding/json"
"io"
"log"
"time"
"github.com/dirtbags/moth/pkg/transpile"
"github.com/spf13/afero"
)
// NewTranspilerProvider returns a new TranspilerProvider.
func NewTranspilerProvider(fs afero.Fs) TranspilerProvider {
return TranspilerProvider{fs}
}
// TranspilerProvider provides puzzles generated from source files on disk
type TranspilerProvider struct {
fs afero.Fs
}
// Inventory returns a Category list for this provider.
func (p TranspilerProvider) Inventory() []Category {
ret := make([]Category, 0)
inv, err := transpile.FsInventory(p.fs)
if err != nil {
log.Print(err)
return ret
}
for name, points := range inv {
ret = append(ret, Category{name, points})
}
return ret
}
type nopCloser struct {
io.ReadSeeker
}
func (c nopCloser) Close() error {
return nil
}
// Open returns a file associated with the given category and point value.
func (p TranspilerProvider) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) {
c := transpile.NewFsCategory(p.fs, cat)
switch filename {
case "", "puzzle.json":
p, err := c.Puzzle(points)
if err != nil {
return nopCloser{new(bytes.Reader)}, time.Time{}, err
}
jp, err := json.Marshal(p)
if err != nil {
return nopCloser{new(bytes.Reader)}, time.Time{}, err
}
return nopCloser{bytes.NewReader(jp)}, time.Now(), nil
default:
r, err := c.Open(points, filename)
return r, time.Now(), err
}
}
// CheckAnswer checks whether an answer si correct.
func (p TranspilerProvider) CheckAnswer(cat string, points int, answer string) (bool, error) {
c := transpile.NewFsCategory(p.fs, cat)
return c.Answer(points, answer), nil
}
// Mothball packages up a category into a mothball.
func (p TranspilerProvider) Mothball(cat string) (*bytes.Reader, error) {
c := transpile.NewFsCategory(p.fs, cat)
return transpile.Mothball(c)
}
// Maintain performs housekeeping.
func (p TranspilerProvider) Maintain(updateInterval time.Duration) {
// Nothing to do here.
}

View File

@ -0,0 +1,19 @@
package main
import (
"testing"
"github.com/spf13/afero"
)
func TestTranspiler(t *testing.T) {
fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata")
p := NewTranspilerProvider(fs)
inv := p.Inventory()
if len(inv) != 1 {
t.Error("Wrong inventory:", inv)
} else if len(inv[0].Puzzles) != 1 {
t.Error("Wrong inventory:", inv)
}
}

182
cmd/transpile/main.go Normal file
View File

@ -0,0 +1,182 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"os"
"sort"
"github.com/dirtbags/moth/pkg/transpile"
"github.com/spf13/afero"
)
// T represents the state of things
type T struct {
Stdout io.Writer
Stderr io.Writer
Args []string
BaseFs afero.Fs
fs afero.Fs
filename string
answer string
}
// Command is a function invoked by the user
type Command func() error
func nothing() error {
return nil
}
func usage(w io.Writer) {
fmt.Fprintln(w, "Usage: transpile COMMAND [flags]")
fmt.Fprintln(w, "")
fmt.Fprintln(w, " mothball: Compile a mothball")
fmt.Fprintln(w, " inventory: Show category inventory")
fmt.Fprintln(w, " open: Open a file for a puzzle")
fmt.Fprintln(w, " answer: Check correctness of an answer")
}
// ParseArgs parses arguments and runs the appropriate action.
func (t *T) ParseArgs() (Command, error) {
var cmd Command
if len(t.Args) == 1 {
usage(t.Stderr)
return nothing, nil
}
flags := flag.NewFlagSet(t.Args[1], flag.ContinueOnError)
directory := flags.String("dir", "", "Work directory")
switch t.Args[1] {
case "mothball":
cmd = t.DumpMothball
case "inventory":
cmd = t.PrintInventory
case "open":
flags.StringVar(&t.filename, "file", "puzzle.json", "Filename to open")
cmd = t.DumpFile
case "answer":
flags.StringVar(&t.answer, "answer", "", "Answer to check")
cmd = t.CheckAnswer
case "help":
usage(t.Stderr)
return nothing, nil
default:
usage(t.Stderr)
return nothing, fmt.Errorf("%s is not a valid command", t.Args[1])
}
flags.SetOutput(t.Stderr)
if err := flags.Parse(t.Args[2:]); err != nil {
return nothing, err
}
if *directory != "" {
log.Println(*directory)
t.fs = afero.NewBasePathFs(t.BaseFs, *directory)
} else {
t.fs = t.BaseFs
}
return cmd, nil
}
// PrintInventory prints a puzzle inventory to stdout
func (t *T) PrintInventory() error {
inv, err := transpile.FsInventory(t.fs)
if err != nil {
return err
}
cats := make([]string, 0, len(inv))
for cat := range inv {
cats = append(cats, cat)
}
sort.Strings(cats)
for _, cat := range cats {
puzzles := inv[cat]
fmt.Fprint(t.Stdout, cat)
for _, p := range puzzles {
fmt.Fprint(t.Stdout, " ", p)
}
fmt.Fprintln(t.Stdout)
}
return nil
}
// DumpFile writes a file to the writer.
// BUG(neale): The "open" and "answer" actions don't work on categories with an "mkcategory" executable.
func (t *T) DumpFile() error {
puzzle := transpile.NewFsPuzzle(t.fs)
switch t.filename {
case "puzzle.json", "":
p, err := puzzle.Puzzle()
if err != nil {
return err
}
jp, err := json.Marshal(p)
if err != nil {
return err
}
t.Stdout.Write(jp)
default:
f, err := puzzle.Open(t.filename)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(t.Stdout, f); err != nil {
return err
}
}
return nil
}
// DumpMothball writes a mothball to the writer.
func (t *T) DumpMothball() error {
c := transpile.NewFsCategory(t.fs, "")
mb, err := transpile.Mothball(c)
if err != nil {
return err
}
if _, err := io.Copy(t.Stdout, mb); err != nil {
return err
}
return nil
}
// CheckAnswer prints whether an answer is correct.
func (t *T) CheckAnswer() error {
c := transpile.NewFsPuzzle(t.fs)
if c.Answer(t.answer) {
fmt.Fprintln(t.Stdout, "correct")
} else {
fmt.Fprintln(t.Stdout, "wrong")
}
return nil
}
func main() {
// XXX: Convert puzzle.py to standalone thingies
t := &T{
Stdout: os.Stdout,
Stderr: os.Stderr,
Args: os.Args,
BaseFs: afero.NewOsFs(),
}
cmd, err := t.ParseArgs()
if err != nil {
log.Fatal(err)
}
if err := cmd(); err != nil {
log.Fatal(err)
}
}

View File

@ -0,0 +1,98 @@
package main
import (
"bytes"
"encoding/json"
"testing"
"github.com/dirtbags/moth/pkg/transpile"
"github.com/spf13/afero"
)
var testMothYaml = []byte(`---
answers:
- YAML answer
pre:
authors:
- Arthur
- Buster
- DW
attachments:
- filename: moo.txt
---
YAML body
`)
func newTestFs() afero.Fs {
fs := afero.NewMemMapFs()
afero.WriteFile(fs, "cat0/1/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/1/moo.txt", []byte("Moo."), 0644)
afero.WriteFile(fs, "cat0/2/puzzle.moth", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/3/puzzle.moth", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/4/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/5/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/10/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "unbroken/1/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "unbroken/1/moo.txt", []byte("Moo."), 0644)
afero.WriteFile(fs, "unbroken/2/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "unbroken/2/moo.txt", []byte("Moo."), 0644)
return fs
}
func (tp T) Run(args ...string) error {
tp.Args = append([]string{"transpile"}, args...)
command, err := tp.ParseArgs()
if err != nil {
return err
}
return command()
}
func TestEverything(t *testing.T) {
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
tp := T{
Stdout: stdout,
Stderr: stderr,
BaseFs: newTestFs(),
}
if err := tp.Run("inventory"); err != nil {
t.Error(err)
}
if stdout.String() != "cat0 1 2 3 4 5 10\nunbroken 1 2\n" {
t.Errorf("Bad inventory: %#v", stdout.String())
}
stdout.Reset()
if err := tp.Run("open", "-dir=cat0/1"); err != nil {
t.Error(err)
}
p := transpile.Puzzle{}
if err := json.Unmarshal(stdout.Bytes(), &p); err != nil {
t.Error(err)
}
if (len(p.Answers) != 1) || (p.Answers[0] != "YAML answer") {
t.Error("Didn't return the right object", p)
}
stdout.Reset()
if err := tp.Run("open", "-dir=cat0/1", "-file=moo.txt"); err != nil {
t.Error(err)
}
if stdout.String() != "Moo." {
t.Error("Wrong file pulled", stdout.String())
}
stdout.Reset()
if err := tp.Run("mothball", "-dir=unbroken"); err != nil {
t.Log(tp.fs)
t.Error(err)
}
if stdout.Len() < 200 {
t.Error("That's way too short to be a mothball")
}
if stdout.String()[:2] != "PK" {
t.Error("This mothball isn't a zip file!")
}
}

View File

@ -1,27 +0,0 @@
#!/bin/sh
#
# Script to clone and start a development server
set -e
if [ -f tools/devel-server.py ]; then
cat <<EOM
This script is intended to be used to bootstrap a moth development server. It
looks like you're running the script from a moth repository working directory.
$ mkdir /tmp/moth
$ cd /tmp/moth
$ curl https://raw.githubusercontent.com/dirtbags/moth/master/devel.sh | sh
EOM
exit 1
fi
[ -d puzzles ] || mkdir -p puzzles
[ -d moth/bin ] || git clone https://github.com/dirtbags/moth.git
cd moth
puzzles="$(readlink -e ../puzzles)"
ln -sf "${puzzles}" puzzles
printf "\n[+] Place puzzles at ${puzzles} ...\n"
python3 tools/devel-server.py

File diff suppressed because it is too large Load Diff

View File

@ -1,303 +0,0 @@
#!/usr/bin/python3
import cgitb
import html
import cgi
import http.server
import io
import json
import mimetypes
import moth
import logging
import os
import pathlib
import random
import shutil
import socketserver
import sys
import traceback
import mothballer
import parse
import urllib.parse
import posixpath
from http import HTTPStatus
sys.dont_write_bytecode = True # Don't write .pyc files
class MothServer(socketserver.ForkingMixIn, http.server.HTTPServer):
def __init__(self, server_address, RequestHandlerClass):
super().__init__(server_address, RequestHandlerClass)
self.args = {}
class MothRequestHandler(http.server.SimpleHTTPRequestHandler):
endpoints = []
def __init__(self, request, client_address, server):
self.directory = str(server.args["theme_dir"])
try:
super().__init__(request, client_address, server, directory=server.args["theme_dir"])
except TypeError:
super().__init__(request, client_address, server)
# Why isn't this the default?!
def guess_type(self, path):
mtype, encoding = mimetypes.guess_type(path)
if encoding:
return "%s; encoding=%s" % (mtype, encoding)
else:
return mtype
# Backport from Python 3.7
def translate_path(self, path):
# I guess we just hope that some other thread doesn't call getcwd
getcwd = os.getcwd
os.getcwd = lambda: self.directory
ret = super().translate_path(path)
os.getcwd = getcwd
return ret
def get_puzzle(self):
category = self.req.get("cat")
points = int(self.req.get("points"))
catpath = str(self.server.args["puzzles_dir"].joinpath(category))
cat = moth.Category(catpath, self.seed)
puzzle = cat.puzzle(points)
return puzzle
def handle_answer(self):
for f in ("cat", "points", "answer"):
self.req[f] = self.fields.getfirst(f)
puzzle = self.get_puzzle()
ret = {
"status": "success",
"data": {
"short": "",
"description": "%r was not in list of answers" % self.req.get("answer")
},
}
if self.req.get("answer") in puzzle.answers:
ret["data"]["description"] = "Answer %r is correct" % self.req.get("answer")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(ret).encode("utf-8"))
endpoints.append(('/{seed}/answer', handle_answer))
def handle_puzzlelist(self):
puzzles = {
"__devel__": [[0, ""]],
}
for p in self.server.args["puzzles_dir"].glob("*"):
if not p.is_dir() or p.match(".*"):
continue
catName = p.parts[-1]
cat = moth.Category(p, self.seed)
puzzles[catName] = [[i, str(i)] for i in cat.pointvals()]
puzzles[catName].append([0, ""])
if len(puzzles) <= 1:
logging.warning("No directories found matching {}/*".format(self.server.args["puzzles_dir"]))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(puzzles).encode("utf-8"))
endpoints.append(('/{seed}/puzzles.json', handle_puzzlelist))
def handle_puzzle(self):
puzzle = self.get_puzzle()
obj = puzzle.package()
obj["answers"] = puzzle.answers
obj["hint"] = puzzle.hint
obj["summary"] = puzzle.summary
obj["logs"] = puzzle.logs
obj["format"] = puzzle._source_format
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(obj).encode("utf-8"))
endpoints.append(('/{seed}/content/{cat}/{points}/puzzle.json', handle_puzzle))
def handle_puzzlefile(self):
puzzle = self.get_puzzle()
try:
file = puzzle.files[self.req["filename"]]
except KeyError:
self.send_error(
HTTPStatus.NOT_FOUND,
"File Not Found",
)
return
self.send_response(200)
self.send_header("Content-Type", mimetypes.guess_type(file.name))
self.end_headers()
shutil.copyfileobj(file.stream, self.wfile)
endpoints.append(("/{seed}/content/{cat}/{points}/{filename}", handle_puzzlefile))
def handle_mothballer(self):
category = self.req.get("cat")
try:
catdir = self.server.args["puzzles_dir"].joinpath(category)
mb = mothballer.package(category, catdir, self.seed)
except Exception as ex:
logging.exception(ex)
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=\"utf-8\"")
self.end_headers()
self.wfile.write(bytes(cgitb.html(sys.exc_info()), "utf-8"))
return
self.send_response(200)
self.send_header("Content-Type", "application/octet_stream")
self.end_headers()
shutil.copyfileobj(mb, self.wfile)
endpoints.append(("/{seed}/mothballer/{cat}.mb", handle_mothballer))
def handle_index(self):
seed = random.getrandbits(32)
body = """<!DOCTYPE html>
<html>
<head>
<title>Dev Server</title>
<script>
// Skip trying to log in
sessionStorage.setItem("id", "devel-server")
</script>
</head>
<body>
<h1>Dev Server</h1>
<p>
Pick a seed:
</p>
<ul>
<li><a href="{seed}/">{seed}</a>: a special seed I made just for you!</li>
<li><a href="random/">random</a>: will use a different seed every time you load a page (could be useful for debugging)</li>
<li>You can also hack your own seed into the URL, if you want to.</li>
</ul>
<p>
Puzzles can be generated from Python code: these puzzles can use a random number generator if they want.
The seed is used to create these random numbers.
</p>
<p>
We like to make a new seed for every contest,
and re-use that seed whenever we regenerate a category during an event
(say to fix a bug).
By using the same seed,
we make sure that all the dynamically-generated puzzles have the same values
in any new packages we build.
</p>
</body>
</html>
""".format(seed=seed)
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(body.encode('utf-8'))
endpoints.append((r"/", handle_index))
def handle_theme_file(self):
self.path = "/" + self.req.get("path", "")
super().do_GET()
endpoints.append(("/{seed}/", handle_theme_file))
endpoints.append(("/{seed}/{path}", handle_theme_file))
def do_GET(self):
self.fields = cgi.FieldStorage(
fp=self.rfile,
headers=self.headers,
environ={
"REQUEST_METHOD": self.command,
"CONTENT_TYPE": self.headers["Content-Type"],
},
)
url = urllib.parse.urlparse(self.path)
for pattern, function in self.endpoints:
result = parse.parse(pattern, url.path)
if result:
self.req = result.named
seed = self.req.get("seed", "random")
if seed == "random":
self.seed = random.getrandbits(32)
else:
self.seed = int(seed)
return function(self)
super().do_GET()
def do_POST(self):
self.do_GET()
def do_HEAD(self):
self.send_error(
HTTPStatus.NOT_IMPLEMENTED,
"Unsupported method (%r)" % self.command,
)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description="MOTH puzzle development server")
parser.add_argument(
'--puzzles', default='puzzles',
help="Directory containing your puzzles"
)
parser.add_argument(
'--theme', default='theme',
help="Directory containing theme files")
parser.add_argument(
'--bind', default="127.0.0.1:8080",
help="Bind to ip:port"
)
parser.add_argument(
'--base', default="",
help="Base URL to this server, for reverse proxy setup"
)
parser.add_argument(
"-v", "--verbose",
action="count",
default=1, # Leave at 1, for now, to maintain current default behavior
help="Include more verbose logging. Use multiple flags to increase level",
)
args = parser.parse_args()
parts = args.bind.split(":")
addr = parts[0] or "0.0.0.0"
port = int(parts[1])
if args.verbose >= 2:
log_level = logging.DEBUG
elif args.verbose == 1:
log_level = logging.INFO
else:
log_level = logging.WARNING
logging.basicConfig(level=log_level)
mimetypes.add_type("application/javascript", ".mjs")
server = MothServer((addr, port), MothRequestHandler)
server.args["base_url"] = args.base
server.args["puzzles_dir"] = pathlib.Path(args.puzzles)
server.args["theme_dir"] = args.theme
logging.info("Listening on %s:%d", addr, port)
server.serve_forever()

File diff suppressed because it is too large Load Diff

View File

@ -1,505 +0,0 @@
#!/usr/bin/python3
import argparse
import contextlib
import copy
import glob
import hashlib
import html
import io
import importlib.machinery
import logging
import mistune
import os
import random
import string
import sys
import tempfile
import shlex
import pathlib
import yaml
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
LOGGER = logging.getLogger(__name__)
def sha256hash(str):
return hashlib.sha256(str.encode("utf-8")).hexdigest()
@contextlib.contextmanager
def pushd(newdir):
newdir = str(newdir)
curdir = os.getcwd()
LOGGER.debug("Attempting to chdir from %s to %s" % (curdir, newdir))
os.chdir(newdir)
# Force a copy of the old path, instead of just a reference
old_path = list(sys.path)
old_modules = copy.copy(sys.modules)
sys.path.append(newdir)
try:
yield
finally:
# Restore the old path
to_remove = []
for module in sys.modules:
if module not in old_modules:
to_remove.append(module)
for module in to_remove:
del(sys.modules[module])
sys.path = old_path
LOGGER.debug("Changing directory back from %s to %s" % (newdir, curdir))
os.chdir(curdir)
def loadmod(name, path):
abspath = str(path.resolve())
loader = importlib.machinery.SourceFileLoader(name, abspath)
return loader.load_module()
# Get a big list of clean words for our answer file.
ANSWER_WORDS = [w.strip() for w in open(os.path.join(os.path.dirname(__file__),
'answer_words.txt'))]
class PuzzleFile:
"""A file associated with a puzzle.
path: The path to the original input file. May be None (when this is created from a file handle
and there is no original input.
handle: A File-like object set to read the file from. You should be able to read straight
from it without having to seek to the beginning of the file.
name: The name of the output file.
visible: A boolean indicating whether this file should visible to the user. If False,
the file is still expected to be accessible, but it's path must be known
(or figured out) to retrieve it."""
def __init__(self, stream, name, visible=True):
self.stream = stream
self.name = name
self.visible = visible
class PuzzleSuccess(dict):
"""Puzzle success objectives
:param acceptable: Learning outcome from acceptable knowledge of the subject matter
:param mastery: Learning outcome from mastery of the subject matter
"""
valid_fields = ["acceptable", "mastery"]
def __init__(self, **kwargs):
super(PuzzleSuccess, self).__init__()
for key in self.valid_fields:
self[key] = None
for key, value in kwargs.items():
if key in self.valid_fields:
self[key] = value
def __getattr__(self, attr):
if attr in self.valid_fields:
return self[attr]
raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr))
def __setattr__(self, attr, value):
if attr in self.valid_fields:
self[attr] = value
else:
raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr))
class Puzzle:
def __init__(self, category_seed, points):
"""A MOTH Puzzle.
:param category_seed: A byte string to use as a seed for random numbers for this puzzle.
It is combined with the puzzle points.
:param points: The point value of the puzzle.
"""
super().__init__()
self._source_format = "py"
self.points = points
self.summary = None
self.authors = []
self.answers = []
self.xAnchors = {"begin", "end"}
self.scripts = []
self.pattern = None
self.hint = None
self.files = {}
self.body = io.StringIO()
# NIST NICE objective content
self.objective = None # Text describing the expected learning outcome from solving this puzzle, *why* are you solving this puzzle
self.success = PuzzleSuccess() # Text describing criteria for different levels of success, e.g. {"Acceptable": "Did OK", "Mastery": "Did even better"}
self.solution = None # Text describing how to solve the puzzle
self.ksas = [] # A list of references to related NICE KSAs (e.g. K0058, . . .)
self.logs = []
self.randseed = category_seed * self.points
self.rand = random.Random(self.randseed)
def log(self, *vals):
"""Add a new log message to this puzzle."""
msg = ' '.join(str(v) for v in vals)
self.logs.append(msg)
def read_stream(self, stream):
header = True
line = ""
if stream.read(3) == "---":
header = "yaml"
self._source_format = "yaml"
else:
header = "moth"
self._source_format = "moth"
stream.seek(0)
if header == "yaml":
LOGGER.info("Puzzle is YAML-formatted")
self.read_yaml_header(stream)
elif header == "moth":
LOGGER.info("Puzzle is MOTH-formatted")
self.read_moth_header(stream)
for line in stream:
self.body.write(line)
def read_yaml_header(self, stream):
contents = ""
header = False
for line in stream:
if line.strip() == "---" and header: # Handle last line
break
elif line.strip() == "---": # Handle first line
header = True
continue
else:
contents += line
config = yaml.safe_load(contents)
for key, value in config.items():
key = key.lower()
self.handle_header_key(key, value)
def read_moth_header(self, stream):
for line in stream:
line = line.strip()
if not line:
break
key, val = line.split(':', 1)
key = key.lower()
val = val.strip()
self.handle_header_key(key, val)
def handle_header_key(self, key, val):
LOGGER.debug("Handling key: %s, value: %s", key, val)
if key == 'author':
self.authors.append(val)
elif key == 'authors':
if not isinstance(val, list):
raise ValueError("Authors must be a list, got %s, instead" & (type(val),))
self.authors = list(val)
elif key == 'summary':
self.summary = val
elif key == 'answer':
if not isinstance(val, str):
raise ValueError("Answers must be strings, got %s, instead" % (type(val),))
self.answers.append(val)
elif key == 'x-answer-pattern':
a = val.strip("*")
assert "*" not in a, "Patterns may only have * at the beginning and end"
assert "?" not in a, "Patterns do not currently support ? characters"
assert "[" not in a, "Patterns do not currently support character ranges"
self.answers.append(a)
if val.startswith("*"):
self.xAnchors.discard("begin")
if val.endswith("*"):
self.xAnchors.discard("end")
elif key == "answers":
for answer in val:
if not isinstance(answer, str):
raise ValueError("Answers must be strings, got %s, instead" % (type(answer),))
self.answers.append(answer)
elif key == 'pattern':
self.pattern = val
elif key == 'hint':
self.hint = val
elif key == 'name':
pass
elif key == 'file':
parts = shlex.split(val)
name = parts[0]
hidden = False
LOGGER.debug("Attempting to open %s", os.path.abspath(name))
stream = open(name, 'rb')
try:
name = parts[1]
hidden = (parts[2].lower() == "hidden")
except IndexError:
pass
self.files[name] = PuzzleFile(stream, name, not hidden)
elif key == 'files' and isinstance(val, dict):
for filename, options in val.items():
if "source" in options:
source = options["source"]
else:
source = filename
if "hidden" in options and options["hidden"]:
hidden = True
else:
hidden = False
stream = open(source, "rb")
self.files[filename] = PuzzleFile(stream, filename, not hidden)
elif key == 'files' and isinstance(val, list):
for filename in val:
stream = open(filename, "rb")
self.files[filename] = PuzzleFile(stream, filename)
elif key == 'script':
stream = open(val, 'rb')
self.add_script_stream(stream, val)
elif key == "scripts" and isinstance(val, list):
for script in val:
stream = open(script, "rb")
self.add_script_stream(stream, script)
elif key == "objective":
self.objective = val
elif key == "success":
# Force success dictionary keys to be lower-case
self.success = dict((x.lower(), y) for x,y in val.items())
elif key == "success.acceptable":
self.success.acceptable = val
elif key == "success.mastery":
self.success.mastery = val
elif key == "solution":
self.solution = val
elif key == "ksas":
if not isinstance(val, list):
raise ValueError("KSAs must be a list, got %s, instead" & (type(val),))
self.ksas = val
elif key == "ksa":
self.ksas.append(val)
else:
raise ValueError("Unrecognized header field: {}".format(key))
def read_directory(self, path):
path = pathlib.Path(path)
try:
puzzle_mod = loadmod("puzzle", path / "puzzle.py")
except FileNotFoundError:
puzzle_mod = None
with pushd(path):
if puzzle_mod:
puzzle_mod.make(self)
else:
with open('puzzle.moth') as f:
self.read_stream(f)
def random_hash(self):
"""Create a file basename (no extension) with our number generator."""
return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8))
def make_temp_file(self, name=None, visible=True):
"""Get a file object for adding dynamically generated data to the puzzle. When you're
done with this file, flush it, but don't close it.
:param name: The name of the file for links within the puzzle. If this is None, a name
will be generated for you.
:param visible: Whether or not the file will be visible to the user.
:return: A file object for writing
"""
stream = tempfile.TemporaryFile()
self.add_stream(stream, name, visible)
return stream
def add_script_stream(self, stream, name):
# Make sure this shows up in the header block of the HTML output.
self.files[name] = PuzzleFile(stream, name, visible=False)
self.scripts.append(name)
def add_stream(self, stream, name=None, visible=True):
if name is None:
name = self.random_hash()
self.files[name] = PuzzleFile(stream, name, visible)
def add_file(self, filename, visible=True):
fd = open(filename, 'rb')
name = os.path.basename(filename)
self.add_stream(fd, name=name, visible=visible)
def randword(self):
"""Return a randomly-chosen word"""
return self.rand.choice(ANSWER_WORDS)
def make_answer(self, word_count=4, sep=' '):
"""Generate and return a new answer. It's automatically added to the puzzle answer list.
:param int word_count: The number of words to include in the answer.
:param str|bytes sep: The word separator.
:returns: The answer string
"""
words = [self.randword() for i in range(word_count)]
answer = sep.join(words)
self.answers.append(answer)
return answer
hexdump_stdch = stdch = (
'················'
'················'
' !"#$%&\'()*+,-./'
'0123456789:;<=>?'
'@ABCDEFGHIJKLMNO'
'PQRSTUVWXYZ[\]^_'
'`abcdefghijklmno'
'pqrstuvwxyz{|}~·'
'················'
'················'
'················'
'················'
'················'
'················'
'················'
'················'
)
def hexdump(self, buf, charset=hexdump_stdch, gap=('<EFBFBD>', '')):
hexes, chars = [], []
out = []
for b in buf:
if len(chars) == 16:
out.append((hexes, chars))
hexes, chars = [], []
if b is None:
h, c = gap
else:
h = '{:02x}'.format(b)
c = charset[b]
chars.append(c)
hexes.append(h)
out.append((hexes, chars))
offset = 0
elided = False
lastchars = None
self.body.write('<pre>')
for hexes, chars in out:
if chars == lastchars:
offset += len(chars)
if not elided:
self.body.write('*\n')
elided = True
continue
lastchars = chars[:]
elided = False
pad = 16 - len(chars)
hexes += [' '] * pad
self.body.write('{:08x} '.format(offset))
self.body.write(' '.join(hexes[:8]))
self.body.write(' ')
self.body.write(' '.join(hexes[8:]))
self.body.write(' |')
self.body.write(html.escape(''.join(chars)))
self.body.write('|\n')
offset += len(chars)
self.body.write('{:08x}\n'.format(offset))
self.body.write('</pre>')
def get_authors(self):
if len(self.authors) > 0:
return self.authors
elif hasattr(self, "author"):
return [self.author]
else:
return []
def get_body(self):
return self.body.getvalue()
def html_body(self):
"""Format and return the markdown for the puzzle body."""
return mistune.markdown(self.get_body(), escape=False)
def package(self, answers=False):
"""Return a dict packaging of the puzzle."""
files = [fn for fn,f in self.files.items() if f.visible]
return {
'authors': self.get_authors(),
'hashes': self.hashes(),
'files': files,
'scripts': self.scripts,
'pattern': self.pattern,
'body': self.html_body(),
'objective': self.objective,
'success': self.success,
'solution': self.solution,
'ksas': self.ksas,
'xAnchors': list(self.xAnchors),
}
def hashes(self):
"Return a list of answer hashes"
return [sha256hash(a) for a in self.answers]
class Category:
def __init__(self, path, seed):
self.path = pathlib.Path(path)
self.seed = seed
self.catmod = None
try:
self.catmod = loadmod('category', self.path / 'category.py')
except FileNotFoundError:
self.catmod = None
def pointvals(self):
if self.catmod:
with pushd(self.path):
pointvals = self.catmod.pointvals()
else:
pointvals = []
for fpath in self.path.glob("[0-9]*"):
points = int(fpath.name)
pointvals.append(points)
return sorted(pointvals)
def puzzle(self, points):
puzzle = Puzzle(self.seed, points)
path = self.path / str(points)
if self.catmod:
with pushd(self.path):
self.catmod.make(points, puzzle)
else:
with pushd(self.path):
puzzle.read_directory(path)
return puzzle
def __iter__(self):
for points in self.pointvals():
yield self.puzzle(points)

View File

@ -1,136 +0,0 @@
#!/usr/bin/env python3
import argparse
import binascii
import datetime
import hashlib
import io
import json
import logging
import moth
import os
import platform
import shutil
import tempfile
import zipfile
import random
SEEDFN = "SEED"
def write_kv_pairs(ziphandle, filename, kv):
""" Write out a sorted map to file
:param ziphandle: a zipfile object
:param filename: The filename to write within the zipfile object
:param kv: the map to write out
:return:
"""
filehandle = io.StringIO()
for key in sorted(kv.keys()):
if isinstance(kv[key], list):
for val in kv[key]:
filehandle.write("%s %s\n" % (key, val))
else:
filehandle.write("%s %s\n" % (key, kv[key]))
filehandle.seek(0)
ziphandle.writestr(filename, filehandle.read())
def escape(s):
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def build_category(categorydir, outdir):
category_seed = random.getrandbits(32)
categoryname = os.path.basename(categorydir.strip(os.sep))
zipfilename = os.path.join(outdir, "%s.mb" % categoryname)
logging.info("Building {} from {}".format(zipfilename, categorydir))
if os.path.exists(zipfilename):
# open and gather some state
existing = zipfile.ZipFile(zipfilename, 'r')
try:
category_seed = int(existing.open(SEEDFN).read().strip())
except Exception:
pass
existing.close()
logging.debug("Using PRNG seed {}".format(category_seed))
zipfileraw = tempfile.NamedTemporaryFile(delete=False)
mothball = package(categoryname, categorydir, category_seed)
shutil.copyfileobj(mothball, zipfileraw)
zipfileraw.close()
shutil.move(zipfileraw.name, zipfilename)
def write_metadata(ziphandle, category):
metadata = {"platform": {}, "moth": {}, "category": {}}
try:
with open("../VERSION", "r") as infile:
version = infile.read().strip()
metadata["moth"]["version"] = version
except IOError:
pass
metadata["category"]["build_time"] = datetime.datetime.now().strftime("%c")
metadata["category"]["type"] = "catmod" if category.catmod is not None else "traditional"
metadata["platform"]["arch"] = platform.machine()
metadata["platform"]["os"] = platform.system()
metadata["platform"]["version"] = platform.platform()
metadata["platform"]["python_version"] = platform.python_version()
ziphandle.writestr("meta.json", json.dumps(metadata))
# Returns a file-like object containing the contents of the new zip file
def package(categoryname, categorydir, seed):
zfraw = io.BytesIO()
zf = zipfile.ZipFile(zfraw, 'x')
zf.writestr("category_seed.txt", str(seed))
cat = moth.Category(categorydir, seed)
mapping = {}
answers = {}
summary = {}
for puzzle in cat:
logging.info("Processing point value {}".format(puzzle.points))
hashmap = hashlib.sha1(str(seed).encode('utf-8'))
hashmap.update(str(puzzle.points).encode('utf-8'))
puzzlehash = hashmap.hexdigest()
mapping[puzzle.points] = puzzlehash
answers[puzzle.points] = puzzle.answers
summary[puzzle.points] = puzzle.summary
puzzledir = os.path.join("content", puzzlehash)
for fn, f in puzzle.files.items():
payload = f.stream.read()
zf.writestr(os.path.join(puzzledir, fn), payload)
obj = puzzle.package()
zf.writestr(os.path.join(puzzledir, 'puzzle.json'), json.dumps(obj))
write_kv_pairs(zf, 'map.txt', mapping)
write_kv_pairs(zf, 'answers.txt', answers)
write_kv_pairs(zf, 'summaries.txt', summary)
write_metadata(zf, cat)
# clean up
zf.close()
zfraw.seek(0)
return zfraw
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Build a category package')
parser.add_argument('outdir', help='Output directory')
parser.add_argument('categorydirs', nargs='+', help='Directory of category source')
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG)
outdir = os.path.abspath(args.outdir)
for categorydir in args.categorydirs:
categorydir = os.path.abspath(categorydir)
build_category(categorydir, outdir)

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +0,0 @@
[flake8]
# flake8 is an automated code formatting pedant.
# Use it, please.
#
# python3 -m flake8 .
#
ignore = E501
exclude = .git

View File

@ -1,19 +0,0 @@
#!/bin/sh
set +e
url='https://rawgit.com/first20hours/google-10000-english/master/google-10000-english-no-swears.txt'
getter="curl -sL"
fn="answer_words.txt"
filterer() {
grep '......*'
}
if ! curl -h >/dev/null 2>/dev/null; then
getter="wget -q -O -"
elif ! wget -h >/dev/null 2>/dev/null; then
echo "[!] I don't know how to download. I need curl or wget."
fi
$getter "${url}" | filterer > ${fn}.tmp \
&& mv -f ${fn}.tmp ${fn}

View File

@ -1,229 +0,0 @@
#!/usr/bin/python3
"""A validator for MOTH puzzles"""
import logging
import os
import os.path
import re
import moth
# pylint: disable=len-as-condition, line-too-long
DEFAULT_REQUIRED_FIELDS = ["answers", "authors", "summary"]
LOGGER = logging.getLogger(__name__)
class MothValidationError(Exception):
"""An exception for encapsulating MOTH puzzle validation errors"""
class MothValidator:
"""A class which validates MOTH categories"""
def __init__(self, fields):
self.required_fields = fields
self.results = {"category": {}, "checks": []}
def validate(self, categorydir, only_errors=False):
"""Run validation checks against a category"""
LOGGER.debug("Loading category from %s", categorydir)
try:
category = moth.Category(categorydir, 0)
except NotADirectoryError:
return
LOGGER.debug("Found %d puzzles in %s", len(category.pointvals()), categorydir)
self.results["category"][categorydir] = {
"puzzles": {},
"name": os.path.basename(categorydir.strip(os.sep)),
}
curr_category = self.results["category"][categorydir]
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
if check_function_name not in self.results["checks"]:
self.results["checks"].append(check_function_name)
for puzzle in category:
LOGGER.info("Processing %s: %s", categorydir, puzzle.points)
curr_category["puzzles"][puzzle.points] = {}
curr_puzzle = curr_category["puzzles"][puzzle.points]
curr_puzzle["failures"] = []
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
check_function = getattr(self, check_function_name)
LOGGER.debug("Running %s on %d", check_function_name, puzzle.points)
try:
check_function(puzzle)
except MothValidationError as ex:
curr_puzzle["failures"].append(str(ex))
if only_errors and len(curr_puzzle["failures"]) == 0:
del curr_category["puzzles"][puzzle.points]
def check_fields(self, puzzle):
"""Check if the puzzle has the requested fields"""
for field in self.required_fields:
if not hasattr(puzzle, field) or \
getattr(puzzle,field) is None or \
getattr(puzzle,field) == "":
raise MothValidationError("Missing field %s" % (field,))
@staticmethod
def check_has_answers(puzzle):
"""Check if the puzle has answers defined"""
if len(puzzle.answers) == 0:
raise MothValidationError("No answers provided")
@staticmethod
def check_unique_answers(puzzle):
"""Check if puzzle answers are unique"""
known_answers = []
duplicate_answers = []
for answer in puzzle.answers:
if answer not in known_answers:
known_answers.append(answer)
else:
duplicate_answers.append(answer)
if len(duplicate_answers) > 0:
raise MothValidationError("Duplicate answer(s) %s" % ", ".join(duplicate_answers))
@staticmethod
def check_has_authors(puzzle):
"""Check if the puzzle has authors defined"""
if len(puzzle.authors) == 0:
raise MothValidationError("No authors provided")
@staticmethod
def check_unique_authors(puzzle):
"""Check if puzzle authors are unique"""
known_authors = []
duplicate_authors = []
for author in puzzle.authors:
if author not in known_authors:
known_authors.append(author)
else:
duplicate_authors.append(author)
if len(duplicate_authors) > 0:
raise MothValidationError("Duplicate author(s) %s" % ", ".join(duplicate_authors))
@staticmethod
def check_has_summary(puzzle):
"""Check if the puzzle has a summary"""
if puzzle.summary is None:
raise MothValidationError("Summary has not been provided")
@staticmethod
def check_has_body(puzzle):
"""Check if the puzzle has a body defined"""
old_pos = puzzle.body.tell()
puzzle.body.seek(0)
if len(puzzle.body.read()) == 0:
puzzle.body.seek(old_pos)
raise MothValidationError("No body provided")
puzzle.body.seek(old_pos)
@staticmethod
def check_ksa_format(puzzle):
"""Check if KSAs are properly formatted"""
ksa_re = re.compile("^[KSA]\d{4}$")
if hasattr(puzzle, "ksa"):
for ksa in puzzle.ksa:
if ksa_re.match(ksa) is None:
raise MothValidationError("Unrecognized KSA format (%s)" % (ksa,))
@staticmethod
def check_success(puzzle):
"""Check if success criteria are defined"""
if not hasattr(puzzle, "success"):
raise MothValidationError("Success not defined")
criteria = ["acceptable", "mastery"]
missing_criteria = []
for criterion in criteria:
if criterion not in puzzle.success.keys() or \
puzzle.success[criterion] is None or \
len(puzzle.success[criterion]) == 0:
missing_criteria.append(criterion)
if len(missing_criteria) > 0:
raise MothValidationError("Missing success criteria (%s)" % (", ".join(missing_criteria)))
def output_json(data):
"""Output results in JSON format"""
import json
print(json.dumps(data))
def output_text(data):
"""Output results in a text-based tabular format"""
longest_category = max([len(y["name"]) for x, y in data["category"].items()])
longest_category = max([longest_category, len("Category")])
longest_failure = len("Failures")
for category_data in data["category"].values():
for points, puzzle_data in category_data["puzzles"].items():
longest_failure = max([longest_failure, len(", ".join(puzzle_data["failures"]))])
formatstr = "| %%%ds | %%6s | %%%ds |" % (longest_category, longest_failure)
headerfmt = formatstr % ("Category", "Points", "Failures")
print(headerfmt)
for cat_data in data["category"].values():
for points, puzzle_data in sorted(cat_data["puzzles"].items()):
print(formatstr % (cat_data["name"], points, ", ".join([str(x) for x in puzzle_data["failures"]])))
def main():
"""Main function"""
# pylint: disable=invalid-name
import argparse
LOGGER.addHandler(logging.StreamHandler())
parser = argparse.ArgumentParser(description="Validate MOTH puzzle field compliance")
parser.add_argument("category", nargs="+", help="Categories to validate")
parser.add_argument("-f", "--fields", help="Comma-separated list of fields to check for", default=",".join(DEFAULT_REQUIRED_FIELDS))
parser.add_argument("-o", "--output-format", choices=["text", "json"], default="text", help="Output format (default: text)")
parser.add_argument("-e", "--only-errors", action="store_true", default=False, help="Only output errors")
parser.add_argument("-v", "--verbose", action="count", default=0, help="Increase verbosity of output, repeat to increase")
args = parser.parse_args()
if args.verbose == 1:
LOGGER.setLevel("INFO")
elif args.verbose > 1:
LOGGER.setLevel("DEBUG")
LOGGER.debug(args)
validator = MothValidator(args.fields.split(","))
for category in args.category:
LOGGER.info("Validating %s", category)
validator.validate(category, only_errors=args.only_errors)
if args.output_format == "text":
output_text(validator.results)
elif args.output_format == "json":
output_json(validator.results)
if __name__ == "__main__":
main()

125
docs/administration.md Normal file
View File

@ -0,0 +1,125 @@
Administration
=========
Everything you need to do happens through the filesystem.
Usually, in `/srv/moth/state`.
The server doesn't cache anything in memory,
so the `state` directory always contains the current state.
Backing up current state
---------------------------
tar czf backup.tar.gz /srv/moth/state # Full backup
curl http://localhost:8080/state > state.json # Pull anonymized event log and team names (scoreboard)
Pausing/resuming scoring
-------------------
rm /srv/moth/state/enabled # Pause scoring
touch /srv/moth/state/enabled # Resume scoring
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.
Scheduling an automatic pause and resume
-----------------------------------
printf '-'; date --rfc-3339=s -d '10:00 PM' >> /srv/moth/state/hours.txt # Schedule suspend at 10:00 PM
printf '+'; date --rfc-3339=s -d '08:00 tomorrow' >> /srv/moth/state/hours.txt # Schedule resume at 08:00 tomorrow
You might prefer to open `/srv/moth/state/hours.txt` in a text editor.
I do.
Re-initalize
-------------------
rm /srv/moth/state/initialized
This will reset the following:
* team registrations
* points log
Team tokens stick around, though.
Setting up custom team IDs
-------------------
echo > /srv/moth/state/teamids.txt # Teams must be registered manually
seq 9999 > /srv/moth/state/teamids.txt # Allow all 4-digit numbers
`teamids.txt` is a list of acceptable team IDs,
one per line.
You can make it anything you want.
New instances will initialize this with some hex values.
Remember that team IDs are essentially passwords.
Adjusting scores
------------------
rm /srv/moth/state/enabled # Suspend scoring
nano /srv/moth/state/points.log
touch /srv/moth/state/enabled # Resume scoring
We don't warn participants before we do this:
any points scored while scoring is suspended are queued up and processed as soon as scoreing is resumed.
It's very important to suspend scoring before mucking around with the points log.
The maintenance loop assumes it is the only thing writing to this file,
and any edits you make could blow aware points scored.
No, I don't use nano.
None of us use nano.
Changing a team name
----------------------
grep . /srv/moth/state/teams/* # Show all team IDs and names
echo 'exciting new team name' > /srv/moth/state/teams/$teamid
Please remember, you have to replace `$teamid` with the actual team ID that you want to edit.
Dealing with puzzles
===========
Checking on an answer
----------------------
Mothballs are just zip files.
If you need to check something about a running category,
just unzip the mothball for that category.
mkdir /tmp/category
cd /tmp/category
unzip /srv/moth/mothballs/category.zip
cat answers.txt # Show all valid answers for all puzzles. Watch your shoulder!
Installing new categories
-------------------
Just drop a new mothball in the `mothballs' directory.
cp new-category.mb /srv/moth/mothballs
Taking a category offline
-------------------------
rm /srv/moth/mothballs/old-category.mb
Removing a category won't remove points that have been scored in it!

View File

@ -1,63 +0,0 @@
Using the MOTH Development Server
======================
To make puzzle development easier,
MOTH comes with a standalone web server written in Python,
which will show you how your puzzles are going to look without making you compile or package anything.
It even works in Windows,
because that is what my career has become.
Getting It Going
----------------
### With Docker
If you can use docker, you are in luck:
docker run --rm -t -p 8080:8080 dirtbags/moth-devel
Gets you a development puzzle server running on port 8080,
with the sample puzzle directory set up.
### Without Docker
If you can't use docker,
try this:
apt install python3
pip3 install scapy pillow PyYAML
git clone https://github.com/dirtbags/moth/
cd moth
python3 devel/devel-server.py --puzzles example-puzzles
Installing New Puzzles
-----------------------------
The development server wants to see category directories under `puzzles`,
like this:
$ find puzzles -type d
puzzles/
puzzles/category1/
puzzles/category1/10/
puzzles/category1/20/
puzzles/category1/30/
puzzles/category2/
puzzles/category2/100/
puzzles/category2/200/
puzzles/category2/300/
### With Docker
docker run --rm -t -v /path/to/my/puzzles:/puzzles:ro -p 8080:8080 dirtbags/moth-devel
### Without Docker
You can use the `--puzzles` argument to `devel-server.py`
to specify a path to your puzzles directory.

105
docs/development.md Normal file
View File

@ -0,0 +1,105 @@
Developing Content
============================
The development server shows debugging for each puzzle,
and will compile puzzles on the fly.
Use it along with a text editor and shell to create new puzzles and categories.
Set up some example puzzles
---------
If you don't have puzzles of your own to start with,
you can copy the example puzzles that come with the source:
cp -r /path/to/src/moth/example-puzzles /srv/moth/puzzles
Run the server in development mode
---------------
These recipes run the server in the foreground,
so you can watch the access log and any error messages.
### Podman
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth -puzzles /puzzles
### Docker
docker run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth -puzzles /puzzles
### Native
I assume you've built and installed the `moth` command from the source tree.
If you don't know how to build Go packages,
please consider using Podman or Docker.
Building Go software is not a skill related to running MOTH or puzzle events,
unless you plan on hacking on the source code.
mkdir -p /srv/moth/state
cp -r /path/to/src/moth/theme /srv/moth/theme
cd /srv/moth
moth -puzzles puzzles
Log In
-----
Point a browser to http://localhost:8080/ (or whatever host is running the server).
You will be logged in automatically.
Browse the example puzzles
------------
The example puzzles are written to demonstrate various features of MOTH,
and serve as documentation of the puzzle format.
Make your own puzzle category
-------------------------
cp -r /srv/moth/puzzles/example /srv/moth/puzzles/my-category
Edit the one point puzzle
--------
nano /srv/moth/puzzles/my-category/1/puzzle.md
I don't use nano, personally,
but if you're advanced enough to have an opinion about nano,
you're advanced enough to know how to use a different editor.
Read our advice
---------------
The [Writing Puzzles](writing-puzzles.md) document
has some tips on how we approach puzzle writing.
There may be something in here that will help you out!
Stop the server
-------
You can hit Control-C in the terminal where you started the server,
and it will exit.
Mothballs
=======
In the list of puzzle categories and puzzles,
there will be a button to download a mothball.
Once your category is set up the way you like it,
download a mothball for it,
and you're ready to [get started](getting-started.md)
with the production server.

77
docs/getting-started.md Normal file
View File

@ -0,0 +1,77 @@
Getting Started
===============
Compile Mothballs
--------------------
Mothballs are compiled, static-content versions of a puzzle category.
You need a mothball for every category you want to run.
To get some mothballs, you'll need to run a development server, which includes the category compiler.
See [development](development.md) for details.
Set up directories
--------------------
mkdir -p /srv/moth/state
mkdir -p /srv/moth/mothballs
cp -r /path/to/src/moth/theme /srv/moth/theme # Skip if using Docker/Podman/Kubernetes
MOTH needs three directories. We recommend putting them all in `/srv/moth`.
* `/srv/moth/state`: (read-write) an empty directory for the server to record its state
* `/srv/moth/mothballs`: (read-only) drop your mothballs here
* `/srv/moth/theme`: (read-only) The HTML5 MOTH client: static content served to web browsers
Run the server
----------------
We're going to assume you put everything in `/srv/moth`, like we suggested.
### Podman
podman run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth
### Docker
docker run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth
### Native
cd /srv/moth
moth
Copy in some mothballs
-------------------------
cp category1.mb category2.mb /srv/moth/mothballs
You can add and remove mothballs at any time while the server is running.
Get a list of valid team tokens
-----------------------
cat /srv/moth/state/tokens.txt
You can edit or replace this file if you want to use different tokens than the pre-generated ones.
Connect to the server
------------------------
Open http://localhost:8080/
Substitute the hostname appropriately if you're a fancypants with a cloud.
Yay!
-------
You should be all set now!
See [administration](administration.md) for how to keep your new MOTH server running the way you want.

View File

@ -1,7 +1,10 @@
Philosophy
==========
This is just some scattered thoughts by the architect, Neale.
Some scattered thoughts by the architect, Neale.
Hardening
-----------
People are going to try to break this thing.
It needs to be bulletproof.
@ -10,23 +13,48 @@ This pretty much set the entire design:
* As much as possible is done client-side
* Participants can attack their own web browsers as much as they feel like
* Also reduces server load
* We will help you create brute-force attacks!
* We even made a puzzle category to walk people through creating brute-force attacks!
* Your laptop is faster than our server
* We give you the carrot of hashed answers and the hashing function
* This removes one incentive to DoS the server
* Generate static content whenever possible
* Puzzles are statically compiled before the event even starts
* `points.json` and `puzzles.json` are generated and cached by a maintenance loop
* Puzzles must be statically compiled before the event even starts
* As much content as possible is generated by a maintenance loop
* Minimize dynamic handling
* There are only two (2) dynamic handlers
* There are only three (3) dynamic handlers
* team registration
* answer validation
* server state (open puzzles + event log)
* You can disable team registration if you want, just remove `teamids.txt`
* I even removed token handling once I realized we replicate the user experience with the `answer` handler and some client-side JavaScript
* As much as possible is read-only
* The only rw directory is `state`
* The only read-write directory is `state`
* This plays very well with Docker, which didn't exist when we designed MOTH
* Server code should be as tiny as possible
* Server should provide highly limited functionality
* It should be easy to remember in your head everything it does
* Server is also compiled
* Static type-checking helps assure no run-time errors
* Server only tracks who scored how many points at what time
* This means the scoreboard program determines rankings
* Want to provide a time bonus for quick answers? I don't, but if you do, you can just modify the scoreboard to do so.
* Maybe you want to show a graph of team rankings over time: just replay the event log.
* Want to do some analysis of what puzzles take the longest to answer? It's all there.
Fairness
---------
We spend a lot of time thinking about whether new content is going to feel fair.
Or, more importantly, if there's a possibility for it to be viewed as unfair.
It's possible to run fun events that don't focus so much on fairness,
but those aren't the type of events we run.
* People generally don't mind discovering that they could improve
* People can get furious if they feel like some system is unfairly targeting them
* Every team that does the same amount of work should have the same score
* No time bonuses / decaying points
* No penalties for trying things that don't work out
* No one should ever feel like it's impossible to catch up
* Achievements ("cheevos") work well here
* Time-based awards (flags) don't mesh with this idea

View File

@ -1,12 +1,42 @@
Tokens
======
Tokens are good for a single point in a single category. They are
formed by prepending the category and a colon to the bubblebabble digest
of 3 random octets. A token for the "merfing" category might look like
this:
We used to use tokens extensively for categories outside of MOTH
(like scavenger hunts, Dirtbags Tanks, and other standalone stuff).
merfing:xunap-motex
We still occasionally pull out tokens to deal with oddball categories
that we want to score alongside MOTH categories.
Here's how they work.
Description
------------
Tokens are a 3-tuple:
> (category, points, nonce)
We build a mothball with nothing but `answers.txt`,
and a special 1-point puzzle that uses JavaScript to parse and submit tokens.
Generally, tokens use colon separators, so they look like this:
category:12:xunap-motex
Uniqueness
--------
Because they work just like normal categories,
you can't have two distinct tokens worth the same number of points.
When we need two or more tokens worth the same amount,
we make the point values very high,
so the least significant digit doesn't have much impact on the overall value.
For instance:
category:1000001:xylep-nanox
category:1000002:xenod-relix
category:1000003:xoter-darox
Entropy

View File

@ -1,21 +1,26 @@
Author: neale
Summary: static puzzles
Answer: puzzle.moth
---
pre:
authors:
- neale
debug:
summary: static puzzles
answers:
- puzzle.md
---
Puzzle categories are laid out on the filesystem:
example/
├─1
│ └─puzzle.moth
│ └─puzzle.md
├─2
│ ├─puzzle.moth
│ ├─puzzle.md
│ └─salad.jpg
├─3
│ └─puzzle.py
│ └─mkpuzzle
├─10
│ └─puzzle.moth
│ └─puzzle.md
└─100
└─puzzle.py
└─mkpuzzle
In this example,
there are puzzles with point values 1, 2, 3, 10, and 100.
@ -24,17 +29,22 @@ Puzzles 1, 2, and 10 are "static" puzzles:
their content was written by hand.
Puzzles 3 and 100 are "dynamic" puzzles:
they are generated from a Python module.
their content is generated by `mkpuzzle`.
To create a static puzzle, all you must have is a
`puzzle.moth` file in the puzzle's directory.
`puzzle.md` file in the puzzle's directory.
This file is in the following format:
Author: [name of the person who wrote this puzzle]
Summary: [brief description of the puzzle]
Answer: [answer to this puzzle]
Answer: [second acceptable answer to this puzzle]
---
pre:
authors:
- name of the person who wrote this puzzle
debug:
summary: brief description of the puzzle
answers:
- answer to this puzzle
- second acceptable answer to this puzzle
---
This is the puzzle body.
It is Markdown formatted:
you can read more about Markdown on the Internet.

View File

@ -1,6 +1,12 @@
Author: neale
Summary: Making excellent puzzles
Answer: moo
---
pre:
authors:
- neale
debug:
summary: Making excellent puzzles
answers:
- moo
---
Making Excellent Puzzles
====================

View File

@ -1,25 +0,0 @@
#!/usr/bin/python3
import io
def make(puzzle):
puzzle.author = 'neale'
puzzle.summary = 'crazy stuff you can do with puzzle generation'
puzzle.body.write("## Crazy Things You Can Do With Puzzle Generation\n")
puzzle.body.write("\n")
puzzle.body.write("The source to this puzzle has some advanced examples of stuff you can do in Python.\n")
puzzle.body.write("\n")
# You can use any file-like object; even your own class that generates output.
f = io.BytesIO("This is some text! Isn't that fantastic?".encode('utf-8'))
puzzle.add_stream(f)
# We have debug logging
puzzle.log("You don't have to disable puzzle.log calls to move to production; the debug log is just ignored at build-time.")
puzzle.log("HTML is <i>escaped</i>, so you don't have to worry about that!")
puzzle.answers.append('coffee')
answer = puzzle.make_answer() # Generates a random answer, appending it to puzzle.answers too
puzzle.log("Answers: {}".format(puzzle.answers))

View File

@ -0,0 +1,36 @@
---
pre:
authors:
- neale
attachments:
- filename: salad.jpg
- filename: s2.jpg
filesystempath: salad2.jpg
debug:
summary: Static puzzle resource files
answers:
- salad
---
You can include additional resources in a static puzzle,
by dropping them in the directory and listing them under `attachments`.
If the puzzle compiler sees both `filename` and `filesystempath`,
it changes the filename when the puzzle category is built.
You can use this to give good filenames while building,
but obscure them during build.
On this page, we obscure
`salad2.jpg` to `s2.jpg`,
so that people can't guess the answer based on filename.
Check the source to this puzzle to see how this is done!
You can refer to resources directly in your Markdown,
or use them however else you see fit.
They will appear in the same directory on the web server once the exercise is running.
Check the source for this puzzle to see how it was created.
![Leafy Green Deliciousness](salad.jpg)
![Mmm so good](s2.jpg)
The answer for this page is what is featured in the photograph.

View File

@ -1,39 +0,0 @@
Author: neale
Summary: Static puzzle resource files
File: salad.jpg s.jpg
File: salad2.jpg s2.jpg hidden
Answer: salad
X-Answer-Pattern: *pong
You can include additional resources in a static puzzle,
by dropping them in the directory and listing them in a `File:` header field.
The format is:
File: filename [translatedname] [hidden]
If `translatedname` is provided,
the filename is changed to it when the puzzle category is built.
You can use this to give good filenames while building,
but obscure them during build.
On this page, we obscure `salad.jpg` to `s.jpg`,
and `salad2.jpg` to `s2.jpg`,
so that people can't guess the answer based on filename.
The word `hidden`, if present,
prevents a file from being listed at the bottom of the page.
Here are the `File:` fields in this page:
File: salad.jpg s.jpg
File: salad2.jpg s2.jpg hidden
You can refer to resources directly in your Markdown,
or use them however else you see fit.
They will appear in the same directory on the web server once the exercise is running.
Check the source for this puzzle to see how it was created.
![Leafy Green Deliciousness](s.jpg)
![Mmm so good](s2.jpg)
The answer for this page is what is featured in the photograph.

View File

@ -1,19 +0,0 @@
import io
import categorylib # Category-level libraries can be imported here
def make(puzzle):
import puzzlelib # puzzle-level libraries can only be imported inside of the make function
puzzle.authors = ['donaldson']
puzzle.summary = 'more crazy stuff you can do with puzzle generation using Python libraries'
puzzle.body.write("## Crazy Things You Can Do With Puzzle Generation (part II)\n")
puzzle.body.write("\n")
puzzle.body.write("The source to this puzzle has some more advanced examples of stuff you can do in Python.\n")
puzzle.body.write("\n")
puzzle.body.write("1 == %s\n\n" % puzzlelib.getone(),)
puzzle.body.write("2 == %s\n\n" % categorylib.gettwo(),)
puzzle.answers.append('tea')
answer = puzzle.make_answer() # Generates a random answer, appending it to puzzle.answers too
puzzle.log("Answers: {}".format(puzzle.answers))

View File

@ -1,7 +0,0 @@
"""This is an example of a puzzle-level library.
This library can be imported by sibling puzzles using `import puzzlelib`
"""
def getone():
return 1

View File

@ -0,0 +1,60 @@
#! /usr/bin/python3
import argparse
import json
import os
import random
import shutil
import sys
random.seed(os.getenv("SEED", ""))
words = ["apple", "pear", "peach", "tangerine", "orange", "potato", "carrot", "pea"]
answer = ' '.join(random.sample(words, 4))
def puzzle():
number = random.randint(20, 500)
obj = {
"Pre": {
"Authors": ["neale"],
"Body": (
"<p>Dynamic puzzles are provided with a JSON-generating <code>mkpuzzles</code> program in the puzzle directory.</p>"
"<p>You can write <code>mkpuzzles</code> in any language you like. This puzzle was written in Python 3.</p>"
"<p>Here is some salad:<img src='salad.jpg'></p>"
),
"Attachments": ["salad.jpg"],
},
"Answers": [
answer,
],
"Debug": {
"Summary": "Dynamic puzzles",
"Hints": [
"Check the debug output to get the answer." ,
],
"Errors": [],
"Log": [
"%d is a positive integer" % number,
],
}
}
json.dump(obj, sys.stdout)
def open_file(filename):
f = open(filename, "rb")
shutil.copyfileobj(f, sys.stdout.buffer)
def check_answer(check):
if answer == check:
print("correct")
else:
print("incorrect")
if len(sys.argv) == 1:
puzzle()
elif sys.argv[1] == "file":
open_file(sys.argv[2])
elif sys.argv[1] == "answer":
check_answer(sys.argv[2])
else:
raise RuntimeError("Unknown command: %s" % sys.argv[1])

View File

@ -1,27 +0,0 @@
#!/usr/bin/python3
def make(puzzle):
puzzle.author = 'neale'
puzzle.summary = 'dynamic puzzles'
answer = puzzle.randword()
puzzle.answers.append(answer)
puzzle.body.write("To generate a dynamic puzzle, you need to write a Python module.\n")
puzzle.body.write("\n")
puzzle.body.write("The passed-in puzzle object provides some handy methods.\n")
puzzle.body.write("In particular, please use the `puzzle.rand` object to guarantee that rebuilding a category\n")
puzzle.body.write("won't change puzzles and answers.\n")
puzzle.body.write("(Participants don't like it when puzzles and answers change.)\n")
puzzle.body.write("\n")
puzzle.add_file('salad.jpg')
puzzle.body.write("Here are some more pictures of salad:\n")
puzzle.body.write("<img src='salad.jpg' alt='Markdown lets you insert raw HTML if you want'>")
puzzle.body.write("![salad](salad.jpg)")
puzzle.body.write("\n\n")
number = puzzle.rand.randint(20, 500)
puzzle.log("One is the loneliest number, but {} is the saddest number.".format(number))
puzzle.body.write("The answer for this page is `{}`.\n".format(answer))

View File

@ -1,20 +0,0 @@
Summary: Answer patterns
Answer: command.com
Answer: COMMAND.COM
X-Answer-Pattern: PINBALL.*
X-Answer-Pattern: pinball.*
Author: neale
Pattern: [0-9A-Za-z]{1,8}\.[A-Za-z]{1,3}
This puzzle features answer input pattern checking.
Sometimes you need to provide a hint about whether the user has entered the answer in the right format.
By providing a `Pattern` value (a regular expression),
the browser will (hopefully) provide a visual hint when an answer is incorrectly formatted.
It will also (hopefully) prevent the user from submitting,
which will (hopefully) inform the participant that they may have the right solution technique,
but there's a problem with the format of the answer.
This will (hopefully) keep people from getting overly-frustrated with difficult-to-enter answers.
This answer field will validate only FAT 8+3 filenames.
Try it!

View File

@ -1,6 +1,6 @@
// jshint asi:true
function helperUpdateAnswer(event) {
async function helperUpdateAnswer(event) {
let e = event.currentTarget
let value = e.value
let inputs = e.querySelectorAll("input")
@ -24,7 +24,11 @@ function helperUpdateAnswer(event) {
if (join === undefined) {
join = ","
}
value = values.join(join)
if (values.length == 0) {
value = "None"
} else {
value = values.join(join)
}
}
// First make any adjustments to the value
@ -35,6 +39,35 @@ function helperUpdateAnswer(event) {
value = value.toUpperCase()
}
// "substrings" answers try all substrings. If any are the answer, they're filled in.
if (e.classList.contains("substring")) {
let validated = null
let anchorEnd = e.classList.contains("anchor-end")
let anchorBeg = e.classList.contains("anchor-beg")
for (let end = 0; end <= value.length; end += 1) {
for (let beg = 0; beg < value.length; beg += 1) {
if (anchorEnd && (end != value.length)) {
continue
}
if (anchorBeg && (beg != 0)) {
continue
}
let sub = value.substring(beg, end)
if (await checkAnswer(sub)) {
validated = sub
}
}
}
value = validated
}
// If anything zeroed out value, don't update the answer field
if (!value) {
return
}
let answer = document.querySelector("#answer")
answer.value = value
answer.dispatchEvent(new InputEvent("input"))
@ -78,15 +111,16 @@ function helperActivate(e) {
}
}
{
let init = function(event) {
for (let e of document.querySelectorAll(".answer")) {
helperActivate(e)
}
}
function helperInit(event) {
for (let e of document.querySelectorAll(".answer")) {
helperActivate(e)
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", helperInit);
} else {
helperInit();
}

View File

@ -1,9 +1,15 @@
Summary: Using JavaScript Input Helpers
Author: neale
Script: helpers.js
Script: draggable.js
Answer: helper
---
pre:
authors:
- neale
scripts:
- filename: helpers.js
- filename: draggable.js
answers:
- helper
debug:
summary: Using JavaScript Input Helpers
---
MOTH only takes static answers:
you can't, for instance, write code to check answer correctness.
But you can provide as many correct answers as you like in a single puzzle.
@ -18,7 +24,7 @@ This is just a demonstration page.
You will probably only want one of these in a page,
to avoid confusing people.
RFC3339 Timestamp
### RFC3339 Timestamp
<div class="answer" data-join="">
<input type="date">
<input type="hidden" value="T">
@ -26,10 +32,10 @@ RFC3339 Timestamp
<input type="hidden" value="Z">
</div>
All lower-case letters
### All lower-case letters
<input class="answer lower">
Multiple concatenated values
### Multiple concatenated values
<div class="answer lower">
<input type="color">
<input type="number">
@ -37,22 +43,32 @@ Multiple concatenated values
<input>
</div>
Free input, sorted, concatenated values
### Free input, sorted, concatenated values
<ul class="answer lower sort">
<li><input></li>
<li><button class="expand" title="Add another input"></button><l/i>
</ul>
User-draggable values
### User-draggable values
<ul class="answer">
<li draggable="true"><input value="First" readonly></li>
<li draggable="true"><input value="Third" readonly></li>
<li draggable="true"><input value="Second" readonly></li>
</ul>
Select from an ordered list of options
### Select from an ordered list of options
<ul class="answer">
<li><input type="checkbox" value="horn">Horns</li>
<li><input type="checkbox" value="hoof">Hooves</li>
<li><input type="checkbox" value="antler">Antlers</li>
</ul>
### Substring matches
#### Any substring
<input class="answer substring">
#### Only if at the beginning
<input class="answer substring anchor-beg">
#### Only if at the end
<input class="answer substring anchor-end">

View File

@ -1,7 +0,0 @@
"""This is an example of a category-level library.
This library can be imported by child puzzles using `import categorylib`
"""
def gettwo():
return 2

10
go.mod Normal file
View File

@ -0,0 +1,10 @@
module github.com/dirtbags/moth
go 1.13
require (
github.com/russross/blackfriday/v2 v2.0.1
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/spf13/afero v1.3.4
gopkg.in/yaml.v2 v2.3.0
)

34
go.sum Normal file
View File

@ -0,0 +1,34 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/spf13/afero v1.3.4 h1:8q6vk3hthlpb2SouZcnBVKboxWQWMDNF38bwholZrJc=
github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

161
pkg/award/award.go Normal file
View File

@ -0,0 +1,161 @@
// Package award defines a MOTH award, and provides tools to use them.
package award
import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
)
// T represents a single award event.
type T struct {
// Unix epoch time of this event
When int64
TeamID string
Category string
Points int
}
// List is a collection of award events.
type List []T
// Len returns the length of the awards list.
func (awards List) Len() int {
return len(awards)
}
// Less returns true if i was awarded before j.
func (awards List) Less(i, j int) bool {
return awards[i].When < awards[j].When
}
// Swap exchanges the awards in positions i and j.
func (awards List) Swap(i, j int) {
tmp := awards[i]
awards[i] = awards[j]
awards[j] = tmp
}
// Parse parses a string log entry into an award.T.
func Parse(s string) (T, error) {
ret := T{}
s = strings.TrimSpace(s)
n, err := fmt.Sscanf(s, "%d %s %s %d", &ret.When, &ret.TeamID, &ret.Category, &ret.Points)
if err != nil {
return ret, err
} else if n != 4 {
return ret, fmt.Errorf("Malformed award string: only parsed %d fields", n)
}
return ret, nil
}
// String returns a log entry string for an award.T.
func (a T) String() string {
return fmt.Sprintf("%d %s %s %d", a.When, a.TeamID, a.Category, a.Points)
}
// MarshalJSON returns the award event, encoded as a list.
func (a T) MarshalJSON() ([]byte, error) {
ao := []interface{}{
a.When,
a.TeamID,
a.Category,
a.Points,
}
return json.Marshal(ao)
}
// UnmarshalJSON decodes the JSON string b.
func (a T) UnmarshalJSON(b []byte) error {
r := bytes.NewReader(b)
dec := json.NewDecoder(r)
dec.UseNumber() // Don't use floats
// All this to make sure we get `[`
if t, err := dec.Token(); err != nil {
return err
} else {
switch token := t.(type) {
case json.Delim:
if token.String() != "[" {
return &json.UnmarshalTypeError{
Value: token.String(),
Type: reflect.TypeOf(a),
Offset: 0,
}
}
default:
return &json.UnmarshalTypeError{
Value: fmt.Sprintf("%v", t),
Type: reflect.TypeOf(a),
Offset: 0,
}
}
}
var num json.Number
var err error
if err := dec.Decode(&num); err != nil {
return err
}
if a.When, err = strconv.ParseInt(string(num), 10, 64); err != nil {
return err
}
if err := dec.Decode(&a.Category); err != nil {
return err
}
if err := dec.Decode(&a.TeamID); err != nil {
return err
}
if err := dec.Decode(&num); err != nil {
return err
}
if a.Points, err = strconv.Atoi(string(num)); err != nil {
return err
}
// All this to make sure we get `]`
if t, err := dec.Token(); err != nil {
return err
} else {
switch token := t.(type) {
case json.Delim:
if token.String() != "]" {
return &json.UnmarshalTypeError{
Value: token.String(),
Type: reflect.TypeOf(a),
Offset: 0,
}
}
default:
return &json.UnmarshalTypeError{
Value: fmt.Sprintf("%v", t),
Type: reflect.TypeOf(a),
Offset: 0,
}
}
}
return nil
}
// Equal returns true if two award events represent the same award.
// Timestamps are ignored in this comparison!
func (a T) Equal(o T) 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
}

92
pkg/award/award_test.go Normal file
View File

@ -0,0 +1,92 @@
package award
import (
"sort"
"testing"
)
func TestAward(t *testing.T) {
entry := "1536958399 1a2b3c4d counting 10"
a, err := Parse(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 != 10 {
t.Error("Points parsed wrong")
}
if a.String() != entry {
t.Error("String conversion wonky")
}
b, err := Parse(entry[2:])
if err != nil {
t.Error(err)
}
if !a.Equal(b) {
t.Error("Different timestamp events do not compare equal")
}
c, err := Parse(entry[:len(entry)-1])
if err != nil {
t.Error(err)
}
if a.Equal(c) {
t.Error("Different pount values compare equal")
}
ja, err := a.MarshalJSON()
if err != nil {
t.Error(err)
} else if string(ja) != `[1536958399,"1a2b3c4d","counting",10]` {
t.Error("JSON wrong")
}
if _, err := Parse("bad bad bad 1"); err == nil {
t.Error("Not throwing error on bad timestamp")
}
if _, err := Parse("1 bad bad bad"); err == nil {
t.Error("Not throwing error on bad points")
}
if err := b.UnmarshalJSON(ja); err != nil {
t.Error(err)
} else if !b.Equal(a) {
t.Error("UnmarshalJSON didn't work")
}
for _, s := range []string{`12`, `"moo"`, `{"a":1}`, `[1 2 3 4]`, `[]`, `[1,"a"]`, `[1,"a","b",4, 5]`} {
buf := []byte(s)
if err := a.UnmarshalJSON(buf); err == nil {
t.Error("Bad unmarshal didn't return error:", s)
}
}
}
func TestAwardList(t *testing.T) {
a, _ := Parse("1536958399 1a2b3c4d counting 1")
b, _ := Parse("1536958400 1a2b3c4d counting 1")
c, _ := Parse("1536958300 1a2b3c4d counting 1")
list := List{a, b, c}
if sort.IsSorted(list) {
t.Error("Unsorted list thinks it's sorted")
}
sort.Stable(list)
if (list[0] != c) || (list[1] != a) || (list[2] != b) {
t.Error("Sorting didn't")
}
if !sort.IsSorted(list) {
t.Error("Sorted list thinks it isn't")
}
}

59
pkg/jsend/jsend.go Normal file
View File

@ -0,0 +1,59 @@
package jsend
import (
"encoding/json"
"fmt"
"net/http"
)
// This provides a JSend function for MOTH
// https://github.com/omniti-labs/jsend
const (
// Success is the return code indicating "All went well, and (usually) some data was returned".
Success = "success"
// Fail is the return code indicating "There was a problem with the data submitted, or some pre-condition of the API call wasn't satisfied".
Fail = "fail"
// Error is the return code indicating "An error occurred in processing the request, i.e. an exception was thrown".
Error = "error"
)
// JSONWrite writes out data as JSON, sending headers and content length
func JSONWrite(w http.ResponseWriter, data interface{}) {
respBytes, err := json.Marshal(data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(respBytes)))
w.WriteHeader(http.StatusOK) // RFC2616 makes it pretty clear that 4xx codes are for the user-agent
w.Write(respBytes)
}
// Send sends arbitrary data as a JSend response
func Send(w http.ResponseWriter, status string, data interface{}) {
resp := struct {
Status string `json:"status"`
Data interface{} `json:"data"`
}{}
resp.Status = status
resp.Data = data
JSONWrite(w, resp)
}
// Sendf sends a Sprintf()-formatted string as a JSend response
func Sendf(w http.ResponseWriter, status, short string, format string, a ...interface{}) {
data := struct {
Short string `json:"short"`
Description string `json:"description"`
}{}
data.Short = short
data.Description = fmt.Sprintf(format, a...)
Send(w, status, data)
}

18
pkg/jsend/jsend_test.go Normal file
View File

@ -0,0 +1,18 @@
package jsend
import (
"net/http/httptest"
"testing"
)
func TestEverything(t *testing.T) {
w := httptest.NewRecorder()
Sendf(w, Success, "You have cows", "You have %d cows", 12)
if w.Result().StatusCode != 200 {
t.Errorf("HTTP Status code: %d", w.Result().StatusCode)
}
if w.Body.String() != `{"status":"success","data":{"short":"You have cows","description":"You have 12 cows"}}` {
t.Errorf("HTTP Body %s", w.Body.Bytes())
}
}

69
pkg/transpile/basepath.go Normal file
View File

@ -0,0 +1,69 @@
package transpile
import (
"os"
"path/filepath"
"runtime"
"strings"
"github.com/spf13/afero"
)
// RecursiveBasePathFs is an overloaded afero.BasePathFs that has a recursive RealPath().
type RecursiveBasePathFs struct {
afero.Fs
source afero.Fs
path string
}
// NewRecursiveBasePathFs returns a new RecursiveBasePathFs.
func NewRecursiveBasePathFs(source afero.Fs, path string) *RecursiveBasePathFs {
ret := &RecursiveBasePathFs{
source: source,
path: path,
}
if path == "" {
ret.Fs = source
} else {
ret.Fs = afero.NewBasePathFs(source, path)
}
return ret
}
// RealPath returns the real path to a file, "breaking out" of the RecursiveBasePathFs.
func (b *RecursiveBasePathFs) RealPath(name string) (path string, err error) {
if err := validateBasePathName(name); err != nil {
return name, err
}
bpath := filepath.Clean(b.path)
path = filepath.Clean(filepath.Join(bpath, name))
if parentRecursiveBasePathFs, ok := b.source.(*RecursiveBasePathFs); ok {
return parentRecursiveBasePathFs.RealPath(path)
} else if parentRecursiveBasePathFs, ok := b.source.(*afero.BasePathFs); ok {
return parentRecursiveBasePathFs.RealPath(path)
}
if !strings.HasPrefix(path, bpath) {
return name, os.ErrNotExist
}
return path, nil
}
func validateBasePathName(name string) error {
if runtime.GOOS != "windows" {
// Not much to do here;
// the virtual file paths all look absolute on *nix.
return nil
}
// On Windows a common mistake would be to provide an absolute OS path
// We could strip out the base part, but that would not be very portable.
if filepath.IsAbs(name) {
return os.ErrNotExist
}
return nil
}

190
pkg/transpile/category.go Normal file
View File

@ -0,0 +1,190 @@
package transpile
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os/exec"
"path"
"strconv"
"strings"
"time"
"github.com/spf13/afero"
)
// Category defines the functionality required to be a puzzle category.
type Category interface {
// Inventory lists every puzzle in the category.
Inventory() ([]int, error)
// Puzzle provides a Puzzle structure for the given point value.
Puzzle(points int) (Puzzle, error)
// Open returns an io.ReadCloser for the given filename.
Open(points int, filename string) (ReadSeekCloser, error)
// Answer returns whether the given answer is correct.
Answer(points int, answer string) bool
}
// NopReadCloser provides an io.ReadCloser which does nothing.
type NopReadCloser struct {
}
// Read satisfies io.Reader.
func (n NopReadCloser) Read(b []byte) (int, error) {
return 0, nil
}
// Close satisfies io.Closer.
func (n NopReadCloser) Close() error {
return nil
}
// NewFsCategory returns a Category based on which files are present.
// If 'mkcategory' is present and executable, an FsCommandCategory is returned.
// Otherwise, FsCategory is returned.
func NewFsCategory(fs afero.Fs, cat string) Category {
bfs := NewRecursiveBasePathFs(fs, cat)
if info, err := bfs.Stat("mkcategory"); (err == nil) && (info.Mode()&0100 != 0) {
if command, err := bfs.RealPath(info.Name()); err != nil {
log.Println("Unable to resolve full path to", info.Name(), bfs)
} else {
return FsCommandCategory{
fs: bfs,
command: command,
timeout: 2 * time.Second,
}
}
}
return FsCategory{fs: bfs}
}
// FsCategory provides a category backed by a .md file.
type FsCategory struct {
fs afero.Fs
}
// Inventory returns a list of point values for this category.
func (c FsCategory) Inventory() ([]int, error) {
puzzleEntries, err := afero.ReadDir(c.fs, ".")
if err != nil {
return nil, err
}
puzzles := make([]int, 0, len(puzzleEntries))
for _, ent := range puzzleEntries {
if !ent.IsDir() {
continue
}
if points, err := strconv.Atoi(ent.Name()); err != nil {
log.Println("Skipping non-numeric directory", ent.Name())
continue
} else {
puzzles = append(puzzles, points)
}
}
return puzzles, nil
}
// Puzzle returns a Puzzle structure for the given point value.
func (c FsCategory) Puzzle(points int) (Puzzle, error) {
return NewFsPuzzlePoints(c.fs, points).Puzzle()
}
// Open returns an io.ReadCloser for the given filename.
func (c FsCategory) Open(points int, filename string) (ReadSeekCloser, error) {
return NewFsPuzzlePoints(c.fs, points).Open(filename)
}
// Answer checks whether an answer is correct.
func (c FsCategory) Answer(points int, answer string) bool {
// BUG(neale): FsCategory.Answer should probably always return false, to prevent you from running uncompiled puzzles with participants.
p, err := c.Puzzle(points)
if err != nil {
return false
}
for _, a := range p.Answers {
if a == answer {
return true
}
}
return false
}
// FsCommandCategory provides a category backed by running an external command.
type FsCommandCategory struct {
fs afero.Fs
command string
timeout time.Duration
}
func (c FsCommandCategory) run(command string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
defer cancel()
cmdargs := append([]string{command}, args...)
cmd := exec.CommandContext(ctx, "./"+path.Base(c.command), cmdargs...)
cmd.Dir = path.Dir(c.command)
return cmd.Output()
}
// Inventory returns a list of point values for this category.
func (c FsCommandCategory) Inventory() ([]int, error) {
stdout, err := c.run("inventory")
if exerr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("inventory: %s: %s", err, string(exerr.Stderr))
} else if err != nil {
return nil, err
}
ret := make([]int, 0)
if err := json.Unmarshal(stdout, &ret); err != nil {
return nil, err
}
return ret, nil
}
// Puzzle returns a Puzzle structure for the given point value.
func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) {
var p Puzzle
stdout, err := c.run("puzzle", strconv.Itoa(points))
if err != nil {
return p, err
}
if err := json.Unmarshal(stdout, &p); err != nil {
return p, err
}
p.computeAnswerHashes()
return p, nil
}
// Open returns an io.ReadCloser for the given filename.
func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) {
stdout, err := c.run("file", strconv.Itoa(points), filename)
return nopCloser{bytes.NewReader(stdout)}, err
}
// Answer checks whether an answer is correct.
func (c FsCommandCategory) Answer(points int, answer string) bool {
stdout, err := c.run("answer", strconv.Itoa(points), answer)
if err != nil {
log.Printf("ERROR: Answering %d points: %s", points, err)
return false
}
switch strings.TrimSpace(string(stdout)) {
case "correct":
return true
}
return false
}

View File

@ -0,0 +1,117 @@
package transpile
import (
"bytes"
"io"
"testing"
"github.com/spf13/afero"
)
func TestFsCategory(t *testing.T) {
c := NewFsCategory(newTestFs(), "cat0")
if inv, err := c.Inventory(); err != nil {
t.Error(err)
} else if len(inv) != 9 {
t.Error("Inventory wrong length", inv)
}
if p, err := c.Puzzle(1); err != nil {
t.Error(err)
} else if len(p.Answers) != 1 {
t.Error("Wrong length for answers", p.Answers)
} else if p.Answers[0] != "YAML answer" {
t.Error("Wrong answer list", p.Answers)
} else if !c.Answer(1, p.Answers[0]) {
t.Error("Correct answer not accepted")
}
if c.Answer(1, "incorrect answer") {
t.Error("Incorrect answer accepted as correct")
}
if r, err := c.Open(1, "moo.txt"); err != nil {
t.Log(c.Puzzle(1))
t.Error(err)
} else {
defer r.Close()
buf := new(bytes.Buffer)
if _, err := io.Copy(buf, r); err != nil {
t.Error(err)
}
if buf.String() != "Moo." {
t.Error("Opened file contents wrong")
}
}
if r, err := c.Open(1, "error"); err == nil {
r.Close()
t.Error("File wasn't supposed to exist")
}
}
func TestOsFsCategory(t *testing.T) {
fs := NewRecursiveBasePathFs(afero.NewOsFs(), "testdata")
static := NewFsCategory(fs, "static")
if p, err := static.Puzzle(1); err != nil {
t.Error(err)
} else if len(p.Pre.Authors) != 1 {
t.Error("Wrong authors list", p.Pre.Authors)
} else if p.Pre.Authors[0] != "neale" {
t.Error("Wrong authors", p.Pre.Authors)
}
if p, err := static.Puzzle(3); err != nil {
t.Error(err)
} else if len(p.Pre.Authors) != 1 {
t.Error("Wrong authors", p.Pre.Authors)
}
generated := NewFsCategory(fs, "generated")
if inv, err := generated.Inventory(); err != nil {
t.Error(err)
} else if len(inv) != 5 {
t.Error("Wrong inventory", inv)
}
if p, err := generated.Puzzle(1); err != nil {
t.Error(err)
} else if len(p.Answers) != 1 {
t.Error("Wrong answers", p.Answers)
} else if p.Answers[0] != "answer1.0" {
t.Error("Wrong answers:", p.Answers)
}
if _, err := generated.Puzzle(20); err == nil {
t.Error("Puzzle shouldn't exist")
}
if r, err := generated.Open(1, "moo.txt"); err != nil {
t.Error(err)
} else {
defer r.Close()
buf := new(bytes.Buffer)
if _, err := io.Copy(buf, r); err != nil {
t.Error(err)
}
if buf.String() != "Moo.\n" {
t.Errorf("Wrong body: %#v", buf.String())
}
}
if r, err := generated.Open(1, "fail"); err == nil {
r.Close()
t.Error("File shouldn't exist")
}
if !generated.Answer(1, "answer1.0") {
t.Error("Correct answer failed")
}
if generated.Answer(1, "wrong") {
t.Error("Incorrect answer didn't fail")
}
if generated.Answer(2, "error") {
t.Error("Error answer didn't fail")
}
}

View File

@ -0,0 +1,53 @@
package transpile
import (
"github.com/spf13/afero"
)
var testMothYaml = []byte(`---
answers:
- YAML answer
pre:
authors:
- Arthur
- Buster
- DW
attachments:
- moo.txt
---
YAML body
`)
var testMothRfc822 = []byte(`author: test
Author: Arthur
author: Fred Flintstone
answer: RFC822 answer
RFC822 body
`)
func newTestFs() afero.Fs {
fs := afero.NewMemMapFs()
afero.WriteFile(fs, "cat0/1/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/1/moo.txt", []byte("Moo."), 0644)
afero.WriteFile(fs, "cat0/2/puzzle.md", testMothRfc822, 0644)
afero.WriteFile(fs, "cat0/3/puzzle.moth", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/4/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/5/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/10/puzzle.md", []byte(`---
Answers:
- moo
Authors:
- bad field
---
body
`), 0644)
afero.WriteFile(fs, "cat0/20/puzzle.md", []byte("Answer: no\nBadField: yes\n\nbody\n"), 0644)
afero.WriteFile(fs, "cat0/21/puzzle.md", []byte("Answer: broken\nSpooon\n"), 0644)
afero.WriteFile(fs, "cat0/22/puzzle.md", []byte("---\nanswers:\n - pencil\npre:\n unused-field: Spooon\n---\nSpoon?\n"), 0644)
afero.WriteFile(fs, "cat1/93/puzzle.md", []byte("Answer: no\n\nbody"), 0644)
afero.WriteFile(fs, "cat1/barney/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "unbroken/1/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "unbroken/1/moo.txt", []byte("Moo."), 0644)
afero.WriteFile(fs, "unbroken/2/puzzle.md", testMothRfc822, 0644)
return fs
}

View File

@ -0,0 +1,41 @@
package transpile
import (
"log"
"sort"
"strings"
"github.com/spf13/afero"
)
// Inventory maps category names to lists of point values.
type Inventory map[string][]int
// FsInventory returns a mapping of category names to puzzle point values.
func FsInventory(fs afero.Fs) (Inventory, error) {
dirEnts, err := afero.ReadDir(fs, "")
if err != nil {
log.Print(err)
return nil, err
}
inv := make(Inventory)
for _, ent := range dirEnts {
if strings.HasPrefix(ent.Name(), ".") {
continue
}
if ent.IsDir() {
name := ent.Name()
c := NewFsCategory(fs, name)
puzzles, err := c.Inventory()
if err != nil {
log.Printf("Inventory: %s: %s", name, err)
continue
}
sort.Ints(puzzles)
inv[name] = puzzles
}
}
return inv, nil
}

View File

@ -0,0 +1,16 @@
package transpile
import "testing"
func TestInventory(t *testing.T) {
fs := newTestFs()
inv, err := FsInventory(fs)
if err != nil {
t.Error(err)
}
if c, ok := inv["cat0"]; !ok {
t.Error("No cat0")
} else if len(c) != 9 {
t.Error("Wrong category length", c)
}
}

83
pkg/transpile/mothball.go Normal file
View File

@ -0,0 +1,83 @@
package transpile
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"os/exec"
)
// Mothball packages a Category up for a production server run.
func Mothball(c Category) (*bytes.Reader, error) {
buf := new(bytes.Buffer)
zf := zip.NewWriter(buf)
inv, err := c.Inventory()
if err != nil {
return nil, err
}
puzzlesTxt, err := zf.Create("puzzles.txt")
if err != nil {
return nil, err
}
answersTxt, err := zf.Create("answers.txt")
if err != nil {
return nil, err
}
for _, points := range inv {
fmt.Fprintln(puzzlesTxt, points)
puzzlePath := fmt.Sprintf("%d/puzzle.json", points)
pw, err := zf.Create(puzzlePath)
if err != nil {
return nil, err
}
puzzle, err := c.Puzzle(points)
if err != nil {
return nil, fmt.Errorf("Puzzle %d: %s", points, err)
}
// Record answers in answers.txt
for _, answer := range puzzle.Answers {
fmt.Fprintln(answersTxt, points, answer)
}
// Remove answers and debugging from puzzle object
puzzle.Answers = []string{}
puzzle.Debug.Errors = []string{}
puzzle.Debug.Hints = []string{}
puzzle.Debug.Log = []string{}
// Write out Puzzle object
penc := json.NewEncoder(pw)
if err := penc.Encode(puzzle); err != nil {
return nil, fmt.Errorf("Puzzle %d: %s", points, err)
}
// Write out all attachments and scripts
attachments := append(puzzle.Pre.Attachments, puzzle.Pre.Scripts...)
for _, att := range attachments {
attPath := fmt.Sprintf("%d/%s", points, att)
aw, err := zf.Create(attPath)
if err != nil {
return nil, err
}
ar, err := c.Open(points, att)
if exerr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("Puzzle %d: %s: %s: %s", points, att, err, string(exerr.Stderr))
} else if err != nil {
return nil, fmt.Errorf("Puzzle %d: %s: %s", points, att, err)
}
if _, err := io.Copy(aw, ar); err != nil {
return nil, fmt.Errorf("Puzzle %d: %s: %s", points, att, err)
}
}
}
zf.Close()
return bytes.NewReader(buf.Bytes()), nil
}

View File

@ -0,0 +1,50 @@
package transpile
import (
"archive/zip"
"io/ioutil"
"os"
"path"
"runtime"
"testing"
"github.com/spf13/afero"
"github.com/spf13/afero/zipfs"
)
func TestMothballsMemFs(t *testing.T) {
static := NewFsCategory(newTestFs(), "cat1")
if _, err := Mothball(static); err != nil {
t.Error(err)
}
}
func TestMothballsOsFs(t *testing.T) {
_, testfn, _, _ := runtime.Caller(0)
os.Chdir(path.Dir(testfn))
fs := NewRecursiveBasePathFs(afero.NewOsFs(), "testdata")
static := NewFsCategory(fs, "static")
mb, err := Mothball(static)
if err != nil {
t.Error(err)
return
}
mbr, err := zip.NewReader(mb, int64(mb.Len()))
if err != nil {
t.Error(err)
}
zfs := zipfs.New(mbr)
if f, err := zfs.Open("puzzles.txt"); err != nil {
t.Error(err)
} else {
defer f.Close()
if buf, err := ioutil.ReadAll(f); err != nil {
t.Error(err)
} else if string(buf) != "" {
t.Error("Bad puzzles.txt", string(buf))
}
}
}

433
pkg/transpile/puzzle.go Normal file
View File

@ -0,0 +1,433 @@
package transpile
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/mail"
"os/exec"
"path"
"strconv"
"strings"
"time"
"github.com/russross/blackfriday/v2"
"github.com/spf13/afero"
"gopkg.in/yaml.v2"
)
// Puzzle contains everything about a puzzle that a client would see.
type Puzzle struct {
Pre struct {
Authors []string
Attachments []string
Scripts []string
Body string
AnswerPattern string
AnswerHashes []string
}
Post struct {
Objective string
Success struct {
Acceptable string
Mastery string
}
KSAs []string
}
Debug struct {
Log []string
Errors []string
Hints []string
Summary string
}
Answers []string
}
func (puzzle *Puzzle) computeAnswerHashes() {
if len(puzzle.Answers) == 0 {
return
}
puzzle.Pre.AnswerHashes = make([]string, len(puzzle.Answers))
for i, answer := range puzzle.Answers {
sum := sha256.Sum256([]byte(answer))
hexsum := fmt.Sprintf("%x", sum)
puzzle.Pre.AnswerHashes[i] = hexsum
}
}
// StaticPuzzle contains everything a static puzzle might tell us.
type StaticPuzzle struct {
Pre struct {
Authors []string
Attachments []StaticAttachment
Scripts []StaticAttachment
AnswerPattern string
}
Post struct {
Objective string
Success struct {
Acceptable string
Mastery string
}
KSAs []string
}
Debug struct {
Log []string
Errors []string
Hints []string
Summary string
}
Answers []string
}
// StaticAttachment carries information about an attached file.
type StaticAttachment struct {
Filename string // Filename presented as part of puzzle
FilesystemPath string // Filename in backing FS (URL, mothball, or local FS)
}
// UnmarshalYAML allows a StaticAttachment to be specified as a single string.
// The way the yaml library works is weird.
func (sa *StaticAttachment) UnmarshalYAML(unmarshal func(interface{}) error) error {
if err := unmarshal(&sa.Filename); err == nil {
sa.FilesystemPath = sa.Filename
return nil
}
parts := new(struct {
Filename string
FilesystemPath string
})
if err := unmarshal(parts); err != nil {
return err
}
sa.Filename = parts.Filename
sa.FilesystemPath = parts.FilesystemPath
return nil
}
// ReadSeekCloser provides io.Reader, io.Seeker, and io.Closer.
type ReadSeekCloser interface {
io.Reader
io.Seeker
io.Closer
}
// PuzzleProvider establishes the functionality required to provide one puzzle.
type PuzzleProvider interface {
// Puzzle returns a Puzzle struct for the current puzzle.
Puzzle() (Puzzle, error)
// Open returns a newly-opened file.
Open(filename string) (ReadSeekCloser, error)
// Answer returns whether the provided answer is correct.
Answer(answer string) bool
}
// NewFsPuzzle returns a new FsPuzzle.
func NewFsPuzzle(fs afero.Fs) PuzzleProvider {
var command string
if info, err := fs.Stat("mkpuzzle"); (err == nil) && (info.Mode()&0100 != 0) {
// Try to get the actual path to the executable
if pfs, ok := fs.(*RecursiveBasePathFs); ok {
if command, err = pfs.RealPath(info.Name()); err != nil {
log.Println("Unable to resolve full path to", info.Name(), pfs)
}
} else if pfs, ok := fs.(*afero.BasePathFs); ok {
if command, err = pfs.RealPath(info.Name()); err != nil {
log.Println("Unable to resolve full path to", info.Name(), pfs)
}
}
}
if command != "" {
return FsCommandPuzzle{
fs: fs,
command: command,
timeout: 2 * time.Second,
}
}
return FsPuzzle{
fs: fs,
}
}
// NewFsPuzzlePoints returns a new FsPuzzle for points.
func NewFsPuzzlePoints(fs afero.Fs, points int) PuzzleProvider {
return NewFsPuzzle(NewRecursiveBasePathFs(fs, strconv.Itoa(points)))
}
// FsPuzzle is a single puzzle's directory.
type FsPuzzle struct {
fs afero.Fs
mkpuzzle bool
}
// Puzzle returns a Puzzle struct for the current puzzle.
func (fp FsPuzzle) Puzzle() (Puzzle, error) {
var puzzle Puzzle
static, body, err := fp.staticPuzzle()
if err != nil {
return puzzle, err
}
// Convert to an exportable Puzzle
puzzle.Post = static.Post
puzzle.Debug = static.Debug
puzzle.Answers = static.Answers
puzzle.Pre.Authors = static.Pre.Authors
puzzle.Pre.Body = string(body)
puzzle.Pre.AnswerPattern = static.Pre.AnswerPattern
puzzle.Pre.Attachments = make([]string, len(static.Pre.Attachments))
for i, attachment := range static.Pre.Attachments {
puzzle.Pre.Attachments[i] = attachment.Filename
}
puzzle.Pre.Scripts = make([]string, len(static.Pre.Scripts))
for i, script := range static.Pre.Scripts {
puzzle.Pre.Scripts[i] = script.Filename
}
puzzle.computeAnswerHashes()
return puzzle, nil
}
// Open returns a newly-opened file.
func (fp FsPuzzle) Open(name string) (ReadSeekCloser, error) {
empty := nopCloser{new(bytes.Reader)}
static, _, err := fp.staticPuzzle()
if err != nil {
return empty, err
}
var fsPath string
for _, attachment := range append(static.Pre.Attachments, static.Pre.Scripts...) {
if attachment.Filename == name {
if attachment.FilesystemPath == "" {
fsPath = attachment.Filename
} else {
fsPath = attachment.FilesystemPath
}
}
}
if fsPath == "" {
return empty, fmt.Errorf("Not listed in attachments or scripts: %s", name)
}
return fp.fs.Open(fsPath)
}
func (fp FsPuzzle) staticPuzzle() (StaticPuzzle, []byte, error) {
r, err := fp.fs.Open("puzzle.md")
if err != nil {
var err2 error
if r, err2 = fp.fs.Open("puzzle.moth"); err2 != nil {
return StaticPuzzle{}, nil, err
}
}
defer r.Close()
headerBuf := new(bytes.Buffer)
headerParser := rfc822HeaderParser
headerEnd := ""
scanner := bufio.NewScanner(r)
lineNo := 0
for scanner.Scan() {
line := scanner.Text()
lineNo++
if lineNo == 1 {
if line == "---" {
headerParser = yamlHeaderParser
headerEnd = "---"
continue
}
}
if line == headerEnd {
headerBuf.WriteRune('\n')
break
}
headerBuf.WriteString(line)
headerBuf.WriteRune('\n')
}
bodyBuf := new(bytes.Buffer)
for scanner.Scan() {
line := scanner.Text()
lineNo++
bodyBuf.WriteString(line)
bodyBuf.WriteRune('\n')
}
static, err := headerParser(headerBuf)
if err != nil {
return static, nil, err
}
body := blackfriday.Run(bodyBuf.Bytes())
return static, body, err
}
func legacyAttachmentParser(val []string) []StaticAttachment {
ret := make([]StaticAttachment, len(val))
for idx, txt := range val {
parts := strings.SplitN(txt, " ", 3)
cur := StaticAttachment{}
cur.FilesystemPath = parts[0]
if len(parts) > 1 {
cur.Filename = parts[1]
} else {
cur.Filename = cur.FilesystemPath
}
ret[idx] = cur
}
return ret
}
func yamlHeaderParser(r io.Reader) (StaticPuzzle, error) {
p := StaticPuzzle{}
decoder := yaml.NewDecoder(r)
decoder.SetStrict(true)
err := decoder.Decode(&p)
return p, err
}
func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) {
p := StaticPuzzle{}
m, err := mail.ReadMessage(r)
if err != nil {
return p, fmt.Errorf("Parsing RFC822 headers: %v", err)
}
for key, val := range m.Header {
key = strings.ToLower(key)
switch key {
case "author":
p.Pre.Authors = val
case "pattern":
p.Pre.AnswerPattern = val[0]
case "script":
p.Pre.Scripts = legacyAttachmentParser(val)
case "file":
p.Pre.Attachments = legacyAttachmentParser(val)
case "answer":
p.Answers = val
case "summary":
p.Debug.Summary = val[0]
case "hint":
p.Debug.Hints = val
case "solution":
p.Debug.Hints = val
case "ksa":
p.Post.KSAs = val
case "objective":
p.Post.Objective = val[0]
case "success.acceptable":
p.Post.Success.Acceptable = val[0]
case "success.mastery":
p.Post.Success.Mastery = val[0]
default:
return p, fmt.Errorf("Unknown header field: %s", key)
}
}
return p, nil
}
// Answer checks whether the given answer is correct.
func (fp FsPuzzle) Answer(answer string) bool {
p, _, err := fp.staticPuzzle()
if err != nil {
return false
}
for _, ans := range p.Answers {
if ans == answer {
return true
}
}
return false
}
// FsCommandPuzzle provides an FsPuzzle backed by running a command.
type FsCommandPuzzle struct {
fs afero.Fs
command string
timeout time.Duration
}
func (fp FsCommandPuzzle) run(command string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), fp.timeout)
defer cancel()
cmdargs := append([]string{command}, args...)
cmd := exec.CommandContext(ctx, "./"+path.Base(fp.command), cmdargs...)
cmd.Dir = path.Dir(fp.command)
return cmd.Output()
}
// Puzzle returns a Puzzle struct for the current puzzle.
func (fp FsCommandPuzzle) Puzzle() (Puzzle, error) {
stdout, err := fp.run("puzzle")
if exiterr, ok := err.(*exec.ExitError); ok {
return Puzzle{}, errors.New(string(exiterr.Stderr))
} else if err != nil {
return Puzzle{}, err
}
jsdec := json.NewDecoder(bytes.NewReader(stdout))
jsdec.DisallowUnknownFields()
puzzle := Puzzle{}
if err := jsdec.Decode(&puzzle); err != nil {
return Puzzle{}, err
}
puzzle.computeAnswerHashes()
return puzzle, nil
}
type nopCloser struct {
io.ReadSeeker
}
func (c nopCloser) Close() error {
return nil
}
// Open returns a newly-opened file.
// BUG(neale): FsCommandPuzzle.Open() reads everything into memory, and will suck for large files.
func (fp FsCommandPuzzle) Open(filename string) (ReadSeekCloser, error) {
stdout, err := fp.run("file", filename)
buf := nopCloser{bytes.NewReader(stdout)}
if err != nil {
return buf, err
}
return buf, nil
}
// Answer checks whether the given answer is correct.
func (fp FsCommandPuzzle) Answer(answer string) bool {
stdout, err := fp.run("answer", answer)
if err != nil {
log.Printf("ERROR: checking answer: %s", err)
return false
}
switch strings.TrimSpace(string(stdout)) {
case "correct":
return true
}
return false
}

View File

@ -0,0 +1,170 @@
package transpile
import (
"bytes"
"io"
"testing"
"github.com/spf13/afero"
)
func TestPuzzle(t *testing.T) {
puzzleFs := newTestFs()
catFs := NewRecursiveBasePathFs(puzzleFs, "cat0")
{
pd := NewFsPuzzlePoints(catFs, 1)
p, err := pd.Puzzle()
if err != nil {
t.Error(err)
}
if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") {
t.Error("Answers are wrong", p.Answers)
}
if (len(p.Pre.Authors) != 3) || (p.Pre.Authors[1] != "Buster") {
t.Error("Authors are wrong", p.Pre.Authors)
}
if p.Pre.Body != "<p>YAML body</p>\n" {
t.Errorf("Body parsed wrong: %#v", p.Pre.Body)
}
f, err := pd.Open("moo.txt")
if err != nil {
t.Error(err)
}
defer f.Close()
buf := new(bytes.Buffer)
if _, err := io.Copy(buf, f); err != nil {
t.Error(err)
}
if buf.String() != "Moo." {
t.Error("Attachment wrong: ", buf.String())
}
}
{
p, err := NewFsPuzzlePoints(catFs, 2).Puzzle()
if err != nil {
t.Error(err)
}
if (len(p.Answers) == 0) || (p.Answers[0] != "RFC822 answer") {
t.Error("Answers are wrong", p.Answers)
}
if (len(p.Pre.Authors) != 3) || (p.Pre.Authors[1] != "Arthur") {
t.Error("Authors are wrong", p.Pre.Authors)
}
if p.Pre.Body != "<p>RFC822 body</p>\n" {
t.Errorf("Body parsed wrong: %#v", p.Pre.Body)
}
}
if _, err := NewFsPuzzlePoints(catFs, 3).Puzzle(); err != nil {
t.Error("Legacy `puzzle.moth` file:", err)
}
if _, err := NewFsPuzzlePoints(catFs, 99).Puzzle(); err == nil {
t.Error("Non-existent puzzle", err)
}
if _, err := NewFsPuzzlePoints(catFs, 10).Puzzle(); err == nil {
t.Error("Broken YAML")
}
if _, err := NewFsPuzzlePoints(catFs, 20).Puzzle(); err == nil {
t.Error("Bad RFC822 header")
}
if _, err := NewFsPuzzlePoints(catFs, 21).Puzzle(); err == nil {
t.Error("Boken RFC822 header")
}
{
fs := afero.NewMemMapFs()
if err := afero.WriteFile(fs, "1/mkpuzzle", []byte("bleat"), 0755); err != nil {
t.Error(err)
}
p := NewFsPuzzlePoints(fs, 1)
if _, ok := p.(FsCommandPuzzle); !ok {
t.Error("We didn't get an FsCommandPuzzle")
}
if _, err := p.Puzzle(); err == nil {
t.Error("We didn't get an error trying to run a command from a MemMapFs")
}
}
}
func TestFsPuzzle(t *testing.T) {
catFs := NewRecursiveBasePathFs(NewRecursiveBasePathFs(afero.NewOsFs(), "testdata"), "static")
if _, err := NewFsPuzzlePoints(catFs, 1).Puzzle(); err != nil {
t.Error(err)
}
if _, err := NewFsPuzzlePoints(catFs, 2).Puzzle(); err != nil {
t.Error(err)
}
mkpuzzleDir := NewFsPuzzlePoints(catFs, 3)
if _, err := mkpuzzleDir.Puzzle(); err != nil {
t.Error(err)
}
if r, err := mkpuzzleDir.Open("moo.txt"); err != nil {
t.Error(err)
} else {
defer r.Close()
buf := new(bytes.Buffer)
if _, err := io.Copy(buf, r); err != nil {
t.Error(err)
}
if buf.String() != "Moo.\n" {
t.Errorf("Wrong body: %#v", buf.String())
}
}
if r, err := mkpuzzleDir.Open("error"); err == nil {
r.Close()
t.Error("Error open didn't return error")
}
if !mkpuzzleDir.Answer("moo") {
t.Error("Right answer marked wrong")
}
if mkpuzzleDir.Answer("wrong") {
t.Error("Wrong answer marked correct")
}
if mkpuzzleDir.Answer("error") {
t.Error("Error answer marked correct")
}
}
func TestAttachment(t *testing.T) {
buf := bytes.NewBufferString(`
pre:
attachments:
- simple
- filename: complex
filesystempath: backingfile
`)
p, err := yamlHeaderParser(buf)
if err != nil {
t.Error(err)
return
}
att := p.Pre.Attachments
if len(att) != 2 {
t.Error("Wrong number of attachments", att)
}
if att[0].Filename != "simple" {
t.Error("Attachment 0 wrong")
}
if att[0].Filename != att[0].FilesystemPath {
t.Error("Attachment 0 wrong")
}
if att[1].Filename != "complex" {
t.Error("Attachment 1 wrong")
}
if att[1].FilesystemPath != "backingfile" {
t.Error("Attachment 2 wrong")
}
}

45
pkg/transpile/testdata/generated/mkcategory vendored Executable file
View File

@ -0,0 +1,45 @@
#! /bin/sh -e
fail () {
echo "ERROR: $*" 1>&2
exit 1
}
case $1:$2:$3 in
inventory::)
echo "[1,2,3,"
echo "4,5]"
;;
puzzle:1:)
cat <<EOT
{
"Answers": ["answer1.0"],
"Pre": {
"Authors": ["author1.0"],
"Body": "<h1>moo.</h1>"
}
}
EOT
;;
puzzle:*)
fail "No such puzzle: $2"
;;
file:1:moo.txt)
echo "Moo."
;;
file:*:*)
fail "No such file: $2"
;;
answer:1:answer1.0)
echo "correct"
;;
answer:1:*)
echo "incorrect"
;;
answer:*:*)
fail "Fail answer"
;;
*)
fail "What is $1" 1>&2
;;
esac

View File

@ -0,0 +1,22 @@
---
pre:
authors:
- neale
answers:
- moo
---
A YAML MOTH file
===========
This is a moth file, woo wo!
With YAML metadata!
# A MOTH file
* moo
* moo
* moo
*

View File

@ -0,0 +1 @@
console.log("Moo.")

View File

@ -0,0 +1 @@
Moo.

View File

@ -0,0 +1,25 @@
Summary: A legacy RFC822-formatted puzzle
Author: neale
Answer: moo 0
Answer: moo 1
Answer: moo 2
Pattern: moo *
Hint: Use the source, Luke.
KSA: none
File: moo.txt
File: moo.txt moo-two.txt
File: moo.txt moo-too.txt hidden
Script: moo.js moo.js
A MOTH file
===========
This is a moth file, woo wo!
# A MOTH file
* moo
* moo
* moo
* squeak

39
pkg/transpile/testdata/static/3/mkpuzzle vendored Executable file
View File

@ -0,0 +1,39 @@
#! /bin/sh
fail () {
echo "ERROR: $*" 1>&2
exit 1
}
case $1:$2 in
puzzle:)
cat <<'EOT'
{
"Answers": ["answer"],
"Pre": {
"Authors": ["neale"],
"Body": "I am a generated puzzle."
}
}
EOT
;;
file:moo.txt)
echo "Moo."
;;
file:*)
fail "no such file: $1"
;;
answer:moo)
echo "correct"
;;
answer:error)
fail "you requested an error"
;;
answer:*)
echo "incorrect"
;;
*)
fail "What is $1"
;;
esac

View File

@ -1,90 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type Award struct {
When time.Time
TeamId string
Category string
Points int
}
type AwardList []*Award
// Implement sort.Interface on AwardList
func (awards AwardList) Len() int {
return len(awards)
}
func (awards AwardList) Less(i, j int) bool {
return awards[i].When.Before(awards[j].When)
}
func (awards AwardList) Swap(i, j int) {
tmp := awards[i]
awards[i] = awards[j]
awards[j] = tmp
}
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
}

View File

@ -1,55 +0,0 @@
package main
import (
"testing"
"sort"
)
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")
}
}
func TestAwardList(t *testing.T) {
a, _ := ParseAward("1536958399 1a2b3c4d counting 1")
b, _ := ParseAward("1536958400 1a2b3c4d counting 1")
c, _ := ParseAward("1536958300 1a2b3c4d counting 1")
list := AwardList{a, b, c}
if sort.IsSorted(list) {
t.Error("Unsorted list thinks it's sorted")
}
sort.Stable(list)
if (list[0] != c) || (list[1] != a) || (list[2] != b) {
t.Error("Sorting didn't")
}
if ! sort.IsSorted(list) {
t.Error("Sorted list thinks it isn't")
}
}

View File

@ -1,372 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// https://github.com/omniti-labs/jsend
type JSend struct {
Status string `json:"status"`
Data struct {
Short string `json:"short"`
Description string `json:"description"`
} `json:"data"`
}
const (
JSendSuccess = "success"
JSendFail = "fail"
JSendError = "error"
)
func respond(w http.ResponseWriter, req *http.Request, status string, short string, format string, a ...interface{}) {
resp := JSend{}
resp.Status = status
resp.Data.Short = short
resp.Data.Description = fmt.Sprintf(format, a...)
respBytes, err := json.Marshal(resp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) // RFC2616 makes it pretty clear that 4xx codes are for the user-agent
w.Write(respBytes)
}
// 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")
if !ctx.ValidTeamId(teamId) {
respond(
w, req, JSendFail,
"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 {
if os.IsExist(err) {
respond(
w, req, JSendFail,
"Already registered",
"This team ID has already been registered.",
)
} else {
log.Print(err)
respond(
w, req, JSendFail,
"Registration failed",
"Unable to register. Perhaps a teammate has already registered?",
)
}
return
}
defer f.Close()
fmt.Fprintln(f, teamName)
respond(
w, req, JSendSuccess,
"Team registered",
"Your team has been named and you may begin using your team ID!",
)
}
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")
if !ctx.ValidTeamId(teamId) {
respond(
w, req, JSendFail,
"Invalid team ID",
"That team ID is not valid for this event.",
)
return
}
if ctx.TooFast(teamId) {
respond(
w, req, JSendFail,
"Submitting too quickly",
"Your team can only submit one answer every %v", ctx.AttemptInterval,
)
return
}
points, err := strconv.Atoi(pointstr)
if err != nil {
respond(
w, req, JSendFail,
"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, JSendFail,
"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, JSendFail,
"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, JSendError,
"Cannot award points",
"The answer is correct, but there was an error awarding points: %v", err.Error(),
)
return
}
respond(
w, req, JSendSuccess,
"Points awarded",
fmt.Sprintf("%d points for %s!", points, teamId),
)
}
func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) {
teamId := req.FormValue("id")
if _, err := ctx.TeamName(teamId); err != nil {
http.Error(w, "Must provide team ID", http.StatusUnauthorized)
return
}
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) {
teamId, ok := req.URL.Query()["id"]
pointsLog := ctx.jPointsLog
if ok && len(teamId[0]) > 0 {
pointsLog = ctx.generatePointsLog(teamId[0])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(pointsLog)
}
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) {
path := req.URL.Path
if strings.Contains(path, "..") {
http.Error(w, "Invalid URL path", http.StatusBadRequest)
return
}
if path == "/" {
path = "/index.html"
}
f, err := os.Open(ctx.ThemePath(path))
if err != nil {
http.NotFound(w, req)
return
}
defer f.Close()
d, err := f.Stat()
if err != nil {
http.NotFound(w, req)
return
}
http.ServeContent(w, req, path, d.ModTime(), f)
}
func (ctx *Instance) manifestHandler(w http.ResponseWriter, req *http.Request) {
if !ctx.Runtime.export_manifest {
http.Error(w, "Endpoint disabled", http.StatusForbidden)
return
}
teamId := req.FormValue("id")
if _, err := ctx.TeamName(teamId); err != nil {
http.Error(w, "Must provide a valid team ID", http.StatusUnauthorized)
return
}
if req.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
manifest := make([]string, 0)
manifest = append(manifest, "puzzles.json")
manifest = append(manifest, "points.json")
// Pack up the theme files
theme_root_re := regexp.MustCompile(fmt.Sprintf("^%s/", ctx.ThemeDir))
filepath.Walk(ctx.ThemeDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() { // Only package up files
localized_path := theme_root_re.ReplaceAllLiteralString(path, "")
manifest = append(manifest, localized_path)
}
return nil
})
// Package up files for currently-unlocked puzzles in categories
for category_name, category := range ctx.categories {
if _, ok := ctx.MaxPointsUnlocked[category_name]; ok { // Check that the category is actually unlocked. This should never fail, probably
for _, file := range category.zf.File {
parts := strings.Split(file.Name, "/")
if parts[0] == "content" { // Only pick up content files, not thing like map.txt
for _, puzzlemap := range category.puzzlemap { // Figure out which puzzles are currently unlocked
if puzzlemap.Path == parts[1] && puzzlemap.Points <= ctx.MaxPointsUnlocked[category_name] {
manifest = append(manifest, path.Join("content", category_name, path.Join(parts[1:]...)))
break
}
}
}
}
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
manifest_json, _ := json.Marshal(manifest)
w.Write(manifest_json)
}
type FurtiveResponseWriter struct {
w http.ResponseWriter
statusCode *int
}
func (w FurtiveResponseWriter) WriteHeader(statusCode int) {
*w.statusCode = statusCode
w.w.WriteHeader(statusCode)
}
func (w FurtiveResponseWriter) Write(buf []byte) (n int, err error) {
n, err = w.w.Write(buf)
return
}
func (w FurtiveResponseWriter) Header() http.Header {
return w.w.Header()
}
// This gives Instances the signature of http.Handler
func (ctx *Instance) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
w := FurtiveResponseWriter{
w: wOrig,
statusCode: new(int),
}
clientIP := r.RemoteAddr
if (ctx.UseXForwarded) {
forwardedIP := r.Header.Get("X-Forwarded-For")
forwardedIP = strings.Split(forwardedIP, ", ")[0]
if forwardedIP != "" {
clientIP = forwardedIP
}
}
ctx.mux.ServeHTTP(w, r)
log.Printf(
"%s %s %s %d\n",
clientIP,
r.Method,
r.URL,
*w.statusCode,
)
}
func (ctx *Instance) BindHandlers() {
ctx.mux.HandleFunc(ctx.Base+"/", ctx.staticHandler)
ctx.mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler)
ctx.mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler)
ctx.mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler)
ctx.mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler)
ctx.mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler)
ctx.mux.HandleFunc(ctx.Base+"/current_manifest.json", ctx.manifestHandler)
}

View File

@ -1,249 +0,0 @@
package main
import (
"bufio"
"fmt"
"io"
"io/ioutil"
"log"
"math/rand"
"net/http"
"os"
"path"
"strings"
"sync"
"time"
)
type RuntimeConfig struct {
export_manifest bool
}
type Instance struct {
Base string
MothballDir string
StateDir string
ThemeDir string
AttemptInterval time.Duration
UseXForwarded bool
Runtime RuntimeConfig
categories map[string]*Mothball
MaxPointsUnlocked map[string]int
update chan bool
jPuzzleList []byte
jPointsLog []byte
nextAttempt map[string]time.Time
nextAttemptMutex *sync.RWMutex
mux *http.ServeMux
}
func (ctx *Instance) Initialize() error {
// Roll over and die if directories aren't even set up
if _, err := os.Stat(ctx.MothballDir); err != nil {
return err
}
if _, err := os.Stat(ctx.StateDir); err != nil {
return err
}
ctx.Base = strings.TrimRight(ctx.Base, "/")
ctx.categories = map[string]*Mothball{}
ctx.update = make(chan bool, 10)
ctx.nextAttempt = map[string]time.Time{}
ctx.nextAttemptMutex = new(sync.RWMutex)
ctx.mux = http.NewServeMux()
ctx.BindHandlers()
ctx.MaybeInitialize()
return nil
}
// Stuff people with mediocre handwriting could write down unambiguously, and can be entered without holding down shift
const distinguishableChars = "234678abcdefhijkmnpqrtwxyz="
func mktoken() string {
a := make([]byte, 8)
for i := range a {
char := rand.Intn(len(distinguishableChars))
a[i] = distinguishableChars[char]
}
return string(a)
}
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 <= 100; i += 1 {
fmt.Fprintln(f, mktoken())
}
}
// 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 pathCleanse(parts []string) string {
clean := make([]string, len(parts))
for i := range parts {
part := parts[i]
part = strings.TrimLeft(part, ".")
if p := strings.LastIndex(part, "/"); p >= 0 {
part = part[p+1:]
}
clean[i] = part
}
return path.Join(clean...)
}
func (ctx Instance) MothballPath(parts ...string) string {
tail := pathCleanse(parts)
return path.Join(ctx.MothballDir, tail)
}
func (ctx *Instance) StatePath(parts ...string) string {
tail := pathCleanse(parts)
return path.Join(ctx.StateDir, tail)
}
func (ctx *Instance) ThemePath(parts ...string) string {
tail := pathCleanse(parts)
return path.Join(ctx.ThemeDir, tail)
}
func (ctx *Instance) TooFast(teamId string) bool {
now := time.Now()
ctx.nextAttemptMutex.RLock()
next, _ := ctx.nextAttempt[teamId]
ctx.nextAttemptMutex.RUnlock()
ctx.nextAttemptMutex.Lock()
ctx.nextAttempt[teamId] = now.Add(ctx.AttemptInterval)
ctx.nextAttemptMutex.Unlock()
return now.Before(next)
}
func (ctx *Instance) PointsLog(teamId string) AwardList {
awardlist := AwardList{}
fn := ctx.StatePath("points.log")
f, err := os.Open(fn)
if err != nil {
log.Printf("Unable to open %s: %s", fn, err)
return awardlist
}
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
}
if len(teamId) > 0 && cur.TeamId != teamId {
continue
}
awardlist = append(awardlist, cur)
}
return awardlist
}
// 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,
}
_, err := ctx.TeamName(teamId)
if err != nil {
return fmt.Errorf("No registered team with this hash")
}
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) ValidTeamId(teamId string) bool {
ctx.nextAttemptMutex.RLock()
_, ok := ctx.nextAttempt[teamId]
ctx.nextAttemptMutex.RUnlock()
return ok
}
func (ctx *Instance) TeamName(teamId string) (string, error) {
teamNameBytes, err := ioutil.ReadFile(ctx.StatePath("teams", teamId))
teamName := strings.TrimSpace(string(teamNameBytes))
return teamName, err
}

View File

@ -1 +0,0 @@
package main

View File

@ -1,333 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
)
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() {
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 {
filtered_puzzlemap := make([]PuzzleMap, 0, 30)
completed := true
for _, pm := range mb.puzzlemap {
filtered_puzzlemap = append(filtered_puzzlemap, pm)
if pm.Points > maxByCategory[catName] {
completed = false
maxByCategory[catName] = pm.Points
break
}
}
if completed {
filtered_puzzlemap = append(filtered_puzzlemap, PuzzleMap{0, ""})
}
ret[catName] = filtered_puzzlemap
}
// Cache the unlocked points for use in other functions
ctx.MaxPointsUnlocked = maxByCategory
jpl, err := json.Marshal(ret)
if err != nil {
log.Printf("Marshalling puzzles.js: %v", err)
return
}
ctx.jPuzzleList = jpl
}
func (ctx *Instance) generatePointsLog(teamId string) []byte {
var ret struct {
Teams map[string]string `json:"teams"`
Points []*Award `json:"points"`
}
ret.Teams = map[string]string{}
ret.Points = ctx.PointsLog(teamId)
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 = "Rodney" // https://en.wikipedia.org/wiki/Rogue_(video_game)#Gameplay
}
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 {
log.Printf("Marshalling points.js: %v", err)
return nil
}
if len(teamId) == 0 {
ctx.jPointsLog = jpl
}
return jpl
}
// maintenance runs
func (ctx *Instance) tidy() {
// Do they want to reset everything?
ctx.MaybeInitialize()
// Check set config
ctx.UpdateConfig()
// 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
}
}
}
// readTeams reads in the list of team IDs,
// so we can quickly validate them.
func (ctx *Instance) readTeams() {
filepath := ctx.StatePath("teamids.txt")
teamids, err := os.Open(filepath)
if err != nil {
log.Printf("Error openining %s: %s", filepath, err)
return
}
defer teamids.Close()
// List out team IDs
newList := map[string]bool{}
scanner := bufio.NewScanner(teamids)
for scanner.Scan() {
teamId := scanner.Text()
if (teamId == "..") || strings.ContainsAny(teamId, "/") {
log.Printf("Dangerous team ID dropped: %s", teamId)
continue
}
newList[scanner.Text()] = true
}
// For any new team IDs, set their next attempt time to right now
now := time.Now()
added := 0
for k, _ := range newList {
ctx.nextAttemptMutex.RLock()
_, ok := ctx.nextAttempt[k]
ctx.nextAttemptMutex.RUnlock()
if !ok {
ctx.nextAttemptMutex.Lock()
ctx.nextAttempt[k] = now
ctx.nextAttemptMutex.Unlock()
added += 1
}
}
// For any removed team IDs, remove them
removed := 0
ctx.nextAttemptMutex.Lock() // XXX: This could be less of a cludgel
for k, _ := range ctx.nextAttempt {
if _, ok := newList[k]; !ok {
delete(ctx.nextAttempt, k)
}
}
ctx.nextAttemptMutex.Unlock()
if (added > 0) || (removed > 0) {
log.Printf("Team IDs updated: %d added, %d removed", added, removed)
}
}
// 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() {
points := ctx.PointsLog("")
pointsFilename := ctx.StatePath("points.log")
pointsNewFilename := ctx.StatePath("points.log.new")
// Yo, this is delicate.
// If we have to return early, we must remove this file.
// If the file's written and we move it successfully,
// we need to remove all the little points files that built it.
newPoints, err := os.OpenFile(pointsNewFilename, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
if err != nil {
log.Printf("Can't append to points log: %s", err)
return
}
files, err := ioutil.ReadDir(ctx.StatePath("points.new"))
if err != nil {
log.Printf("Error reading packages: %s", err)
}
removearino := make([]string, 0, len(files))
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 points {
if award.Same(e) {
duplicate = true
break
}
}
if duplicate {
log.Printf("Skipping duplicate points: %s", award.String())
} else {
points = append(points, award)
}
removearino = append(removearino, filename)
}
sort.Stable(points)
for _, point := range points {
fmt.Fprintln(newPoints, point.String())
}
newPoints.Close()
if err := os.Rename(pointsNewFilename, pointsFilename); err != nil {
log.Printf("Unable to move %s to %s: %s", pointsFilename, pointsNewFilename, err)
if err := os.Remove(pointsNewFilename); err != nil {
log.Printf("Also couldn't remove %s: %s", pointsNewFilename, err)
}
return
}
for _, filename := range removearino {
if err := os.Remove(filename); err != nil {
log.Printf("Unable to remove %s: %s", filename, err)
}
}
}
func (ctx *Instance) isEnabled() bool {
// Skip if we've been disabled
if _, err := os.Stat(ctx.StatePath("disabled")); err == nil {
log.Print("Suspended: disabled file found")
return false
}
untilspec, err := ioutil.ReadFile(ctx.StatePath("until"))
if err == nil {
untilspecs := strings.TrimSpace(string(untilspec))
until, err := time.Parse(time.RFC3339, untilspecs)
if err != nil {
log.Printf("Suspended: Unparseable until date: %s", untilspec)
return false
}
if until.Before(time.Now()) {
log.Print("Suspended: until time reached, suspending maintenance")
return false
}
}
return true
}
func (ctx *Instance) UpdateConfig() {
// Handle export manifest
if _, err := os.Stat(ctx.StatePath("export_manifest")); err == nil {
if !ctx.Runtime.export_manifest {
log.Print("Enabling manifest export")
ctx.Runtime.export_manifest = true
}
} else if ctx.Runtime.export_manifest {
log.Print("Disabling manifest export")
ctx.Runtime.export_manifest = false
}
}
// maintenance is the goroutine that runs a periodic maintenance task
func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) {
for {
if ctx.isEnabled() {
ctx.tidy()
ctx.readTeams()
ctx.collectPoints()
ctx.generatePuzzleList()
ctx.generatePointsLog("")
}
select {
case <-ctx.update:
// log.Print("Forced update")
case <-time.After(maintenanceInterval):
// log.Print("Housekeeping...")
}
}
}

View File

@ -1,228 +0,0 @@
package main
import (
"archive/zip"
"bufio"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"strings"
"time"
)
type PuzzleMap struct {
Points int
Path string
}
type Mothball struct {
zf *zip.ReadCloser
filename string
puzzlemap []PuzzleMap
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
mf, err := m.Open("map.txt")
if err != nil {
// File isn't in there
} else {
defer mf.Close()
pm := make([]PuzzleMap, 0, 30)
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 {
log.Printf("Parsing map for %v", err)
} else if n != 2 {
log.Printf("Parsing map: short read")
}
pm = append(pm, PuzzleMap{pointval, dir})
}
m.puzzlemap = pm
}
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,63 +0,0 @@
package main
import (
"archive/zip"
"fmt"
"io"
"io/ioutil"
"os"
"testing"
)
func TestMothball(t *testing.T) {
tf, err := ioutil.TempFile("", "mothball")
if err != nil {
t.Error(err)
return
}
defer os.Remove(tf.Name())
w := zip.NewWriter(tf)
f, err := w.Create("moo.txt")
if err != nil {
t.Error(err)
return
}
// no Close method
_, err = fmt.Fprintln(f, "The cow goes moo")
//.Write([]byte("The cow goes moo"))
if err != nil {
t.Error(err)
return
}
w.Close()
tf.Close()
// Now read it in
mb, err := OpenMothball(tf.Name())
if err != nil {
t.Error(err)
return
}
cow, err := mb.Open("moo.txt")
if err != nil {
t.Error(err)
return
}
line := make([]byte, 200)
n, err := cow.Read(line)
if (err != nil) && (err != io.EOF) {
t.Error(err)
return
}
if string(line[:n]) != "The cow goes moo\n" {
t.Log(line)
t.Error("Contents didn't match")
return
}
}

View File

@ -1,86 +0,0 @@
package main
import (
"flag"
"log"
"math/rand"
"mime"
"net/http"
"time"
)
func setup() error {
rand.Seed(time.Now().UnixNano())
return nil
}
func main() {
ctx := &Instance{}
flag.StringVar(
&ctx.Base,
"base",
"/",
"Base URL of this instance",
)
flag.StringVar(
&ctx.MothballDir,
"mothballs",
"/mothballs",
"Path to read mothballs",
)
flag.StringVar(
&ctx.StateDir,
"state",
"/state",
"Path to write state",
)
flag.StringVar(
&ctx.ThemeDir,
"theme",
"/theme",
"Path to static theme resources (HTML, images, css, ...)",
)
flag.DurationVar(
&ctx.AttemptInterval,
"attempt",
500*time.Millisecond,
"Per-team time required between answer attempts",
)
maintenanceInterval := flag.Duration(
"maint",
20*time.Second,
"Time between maintenance tasks",
)
flag.BoolVar(
&ctx.UseXForwarded,
"x-forwarded-for",
false,
"Emit IPs from the X-Forwarded-For header in logs, when available, instead of the source IP. Use this when running behind a load-balancer or proxy",
)
listen := flag.String(
"listen",
":8080",
"[host]:port to bind and listen",
)
flag.Parse()
if err := setup(); err != nil {
log.Fatal(err)
}
err := ctx.Initialize()
if err != nil {
log.Fatal(err)
}
// 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, ctx))
}

View File

@ -91,8 +91,15 @@ input:invalid {
#devel {
background-color: #c88;
background-color: #eee;
color: black;
overflow: scroll;
}
#devel .string {
color: #9c27b0;
}
#devel .body {
background-color: #ffc107;
}
.kvpair {
border: solid black 2px;

View File

@ -5,7 +5,6 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="basic.css">
<script src="moth-pwa.js" type="text/javascript"></script>
<script src="moth.js"></script>
<link rel="manifest" href="manifest.json">
</head>

BIN
theme/luna-moth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

57
theme/luna-moth.svg Normal file
View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<svg id="svg2" viewBox="54.122 189.992 297.46 291.819" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g id="layer1" transform="translate(-169.61 -199.65)">
<g id="g4449" transform="matrix(-0.88604614,0.46359708,0.46359708,0.88604614,470.47926,-197.23926)">
<g id="g5242" transform="matrix(1.5856107,0.2341624,0.16998437,1.0681247,-408.59758,-19.14161)">
<path d="m 486.09807,434.34389 c 0,0 33.21943,37.1978 28.44237,74.37852 -9.69431,-1.18729 -28.05784,-15.23506 -49.27832,31.40255 -26.1093,57.38302 4.04174,56.10536 -8.44016,86.24229 -12.48109,30.13515 -22.15846,-14.83784 -21.15287,-20.87068 27.75853,-86.95342 19.16417,-80.74245 50.42898,-171.15268 z" style="fill:#a0b977;stroke:#fff6a4;stroke-width:0.84436792" id="path3012"/>
<path id="path5252" style="fill:#a0b977;stroke:#fff6a4;stroke-width:0.84436792" d="m 483.14429,428.90547 c 0,0 -49.53236,20.97257 -65.01984,56.05329 9.315,2.5399 33.23264,-3.21777 27.44694,46.44614 -7.11913,61.10645 -33.44802,48.75472 -38.28907,80.38979 -4.84082,31.6332 27.73702,-5.05478 30.04352,-10.83255 28.20085,-88.31453 25.759,-79.4494 45.81845,-172.05667 z"/>
</g>
<g id="g2994" transform="translate(137.37681,206.38155)">
<g id="g2996">
<path id="path2998" style="opacity:0.65;fill:#988378" d="m 331.9,266.68 c 0,0 -3.033,-36.711 32.988,-31.248 0.001,0 -8.798,28.704 -32.988,31.248 z"/>
<path id="path3000" style="opacity:0.65;fill:none;stroke:#4d2b2b" d="m 365.19,236.01 c -16.611,5.02 -37.262,34.979 -37.262,34.979"/>
</g>
<g id="g3002">
<path id="path3004" style="opacity:0.65;fill:#988378" d="m 322.29,263.16 c 0,0 30.612,-20.489 3.905,-45.27 0,0 -16.978,24.759 -3.905,45.27 z"/>
<path id="path3006" style="opacity:0.65;fill:none;stroke:#4d2b2b" d="m 325.56,218.02 c 6.415,16.123 -4.164,50.937 -4.164,50.937"/>
</g>
<g id="g3008">
<g id="g3014">
<g id="g3016">
<path id="path3018" style="fill:#a0b977;stroke:#fff8bd" d="m 312.76,265.25 c 0,0 -7.279,-0.166 -8.315,3.177 0,0 -10.622,-1.202 -12.795,0.741 -2.173,1.943 -83.173,23.401 -105.58,33.197 -22.412,9.798 -52.957,23.878 -62.557,56.499 -9.601,32.618 47.177,25.109 47.177,25.109 0,0 15.808,-0.329 43.093,11.279 27.285,11.609 68.089,11.708 84.028,-43.027 0,0 -19.067,71.565 45.054,82.563 64.119,11 88.375,59.362 91.092,15.183 2.717,-44.179 2.635,-40.541 -25.9,-81.855 -28.537,-41.314 -53.385,-72.585 -53.385,-72.585 0,0 -0.527,-10.11 -11.182,-12.893 0,0 -0.28,-5.846 -6.373,-9.83 -0.001,0 -7.393,-10.433 -24.354,-7.558 z"/>
<path id="path3020" style="fill:#fff8bd" d="m 360.56,302.09 c -3.478,-4.498 -5.454,-6.984 -5.454,-6.984 0,0 -0.527,-10.111 -11.182,-12.893 0,0 -0.279,-5.846 -6.371,-9.831 0,0 -7.394,-10.434 -24.355,-7.559 0,0 -7.277,-0.165 -8.315,3.178 0,0 -10.621,-1.202 -12.794,0.741 -0.426,0.382 -3.868,1.507 -9.299,3.149 l -6.162,11.986 c 0,0 26.633,-7.093 27.148,2.491 0.516,9.583 5.73,11.9 5.73,11.9 0,0 3.08,11.078 2.149,17.07 0,0 6.907,-12.514 10.485,-12.8 0,0 9.773,1.124 11.463,-7.692 0,0 16.849,10.656 21.34,16.184 0.237,0.292 0.439,0.568 0.604,0.828 3.271,5.205 5.013,-9.768 5.013,-9.768 z"/>
<path id="path3022" style="fill:none;stroke:#fff8bd" d="m 313.73,311.85 c 0,0 -13.709,22.16 -17.873,49.646"/>
<path id="path3024" style="fill:#fff8bd" d="m 225.91,315.99 c 0,0 6.543,21.195 22.854,12.133 0,0 -8.072,-8.788 -10.24,-9.892 -2.168,-1.102 -6.429,-1.994 -6.429,-1.994 l 3.36,-11.951 c 0,0 -1.844,-4.046 -9.545,11.704 z"/>
<path id="path3026" style="fill:#fff8bd" d="m 379.47,367.78 c 0,0 -15.43,8.015 -24.641,-3.771 l 10.435,-3.478 c 0,0 8.961,1.849 8.698,2.699 -0.265,0.851 6.322,-6.878 6.322,-6.878 0,0 3.014,7.895 -0.814,11.428 z"/>
<path id="path3028" style="fill:#4d2b2b" d="m 229.7,317.19 c 0,0 8.137,15.76 18.629,10.824 0,0 -8.191,-6.876 -8.109,-7.558 0.082,-0.682 -8,-5.868 -10.52,-3.266 z"/>
<path id="path3030" style="fill:#4d2b2b" d="m 374.9,364.06 c 0,0 -15.354,8.875 -21.523,-0.941 0,0 10.607,-1.369 10.906,-1.986 0.299,-0.619 9.903,-0.626 10.617,2.927 z"/>
<g id="g3032">
<path id="path3034" style="fill:#988378" d="m 341.58,278.41 c 2.846,1.426 1.744,5.08 1.744,5.08 10.654,2.782 11.182,12.894 11.182,12.894 0,0 24.85,31.27 53.385,72.584 28.537,41.315 27.662,40.764 24.943,84.943 0,0 0.961,-3.087 -0.504,-18.654 -1.465,-15.565 -5.752,-23.075 -5.752,-23.075 -12.012,-29.904 -42.311,-57.106 -42.311,-57.106 1.119,3.139 -6.828,16.378 -4.391,11.899 2.438,-4.479 0.834,-10.56 -0.529,-8.413 -1.367,2.148 -3.982,9.456 -7.013,5.725 -3.029,-3.732 -1.735,-2.285 -12.151,-1.33 -10.42,0.955 -6.982,-1.119 -6.982,-1.119 -0.889,2.865 6.521,-0.768 6.521,-0.768 10.789,-3.283 14.434,-0.406 14.434,-0.406 l 2.492,-6.906 c 5.588,-10.133 -29.023,-47.05 -32.24,-54.68 -3.217,-7.629 -7.158,-9.549 -7.158,-9.549 2.932,-6.071 -15.424,-6.533 -15.424,-6.533 -0.482,-2.943 -7.926,-5.949 -13.338,-7.629 -5.412,-1.68 -2.876,3.643 -2.876,3.643 -1.515,-1.865 -8.749,0.079 -16.517,4.299 -7.768,4.221 -29.491,8.996 -40.82,11.763 -11.329,2.766 -14.416,18.339 -14.416,18.339 0,0 5.523,0.191 8.379,5.614 2.854,5.422 6.021,8.15 6.021,8.15 0,0 0.098,4.742 -5.435,-2.732 -5.533,-7.478 -13.437,-7.313 -13.437,-7.313 l 3.704,-11.938 c -4.347,3.886 -7.491,12.333 -7.491,12.333 -1.203,-4.561 3.16,-15.247 3.16,-15.247 0,0 -26.412,7.509 -53.154,14.392 -26.741,6.883 -46.178,32.961 -46.178,32.961 12.879,-23.555 37.309,-38.035 56.473,-46.413 22.411,-9.798 103.41,-31.254 105.58,-33.197 2.174,-1.942 12.795,-0.741 12.795,-0.741 0.52,-1.671 2.598,-2.466 4.547,-2.842 0,0 14.472,4.288 15.098,9.018 0.001,0 6.241,-2.764 17.655,2.954 z"/>
<path id="path3036" style="fill:#4d2b2b" d="m 403.91,370.46 c -27.5,-39.816 -50.17,-74.071 -50.17,-74.071 0,0 -0.508,-9.744 -10.775,-12.426 0,0 1.061,-3.521 -1.682,-4.896 -11.001,-5.509 -17.014,-2.846 -17.014,-2.846 -0.604,-4.56 -14.551,-8.693 -14.551,-8.693 -1.878,0.363 -3.883,1.13 -4.381,2.739 0,0 -10.236,-1.157 -12.332,0.715 -2.095,1.872 -79.716,27.89 -101.31,37.332 0,0 108.65,-38.886 110.44,-35.965 0,0 2.538,-0.563 4.079,-0.085 0,0 1.508,-5.949 6.385,-2.073 0,0 2.324,-0.963 9.282,4.91 l 1.392,3.133 c 0,0 5.651,-3.311 11.19,0.771 0,0 8.491,-1.079 6.696,5.792 0,0 6.988,2.507 9.793,9.793 l 0.469,2.847 52.489,73.023 z"/>
</g>
</g>
<g id="g3038" style="opacity:0.5"/>
</g>
<g id="g3050" style="opacity:0.2">
<g id="g3052">
<path id="path3054" style="fill:none;stroke:#fdee72" d="m 193.51,313.22 c 0,0 -5.379,15.082 -24.865,26.137 -19.486,11.057 -23.777,32.758 -23.777,32.758"/>
<path id="path3056" style="fill:none;stroke:#fdee72" d="m 229.05,308.89 c 0,0 -17.959,18.51 -37.127,29.663 -19.166,11.153 -27.597,31.571 -27.927,36.007"/>
<path id="path3058" style="fill:none;stroke:#fdee72" d="m 219.07,341.05 c 0,0 -24.876,10.43 -32.637,37.687"/>
<path id="path3060" style="fill:none;stroke:#fdee72" d="m 237.68,338.45 c 0,0 -28.399,28.531 -29.662,43.846"/>
<path id="path3062" style="fill:none;stroke:#fdee72" d="m 301.4,299.58 c 0,0 -8.387,63.012 -22.965,81.872"/>
<path id="path3064" style="fill:none;stroke:#fdee72" d="m 284.8,292.34 c 0,0 -22.351,40.531 -18.574,59.851"/>
</g>
<g id="g3066">
<path id="path3068" style="fill:none;stroke:#fdee72" d="m 409.42,384 c 0,0 -4.191,15.455 5.504,35.652 9.695,20.199 0.838,40.471 0.838,40.471"/>
<path id="path3070" style="fill:none;stroke:#fdee72" d="m 382.7,360.17 c 0,0 4.191,25.448 13.566,45.545 9.377,20.094 4.648,41.675 2.391,45.505"/>
<path id="path3072" style="fill:none;stroke:#fdee72" d="m 372.55,392.27 c 0,0 14.479,22.76 5.299,49.573"/>
<path id="path3074" style="fill:none;stroke:#fdee72" d="m 358.75,379.52 c 0,0 7.042,39.637 -0.657,52.934"/>
<path id="path3076" style="fill:none;stroke:#fdee72" d="m 328.6,311.25 c 0,0 -29.065,56.534 -27.854,80.341"/>
<path id="path3078" style="fill:none;stroke:#fdee72" d="m 346.36,314.77 c 0,0 -4.771,46.039 -18.897,59.751"/>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

Some files were not shown because too many files have changed in this diff Show More