mirror of https://github.com/dirtbags/moth.git
Merge branch 'v4'
This commit is contained in:
commit
a22df7a253
|
@ -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 .
|
|
@ -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 .
|
|
@ -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
|
|
@ -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,7 +4,5 @@
|
|||
*.o
|
||||
.idea
|
||||
./bin/
|
||||
build/
|
||||
cache/
|
||||
target/
|
||||
puzzles
|
||||
__debug_bin
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
39
CHANGELOG.md
39
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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" ]
|
|
@ -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" ]
|
34
LICENSE.md
34
LICENSE.md
|
@ -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
163
README.md
|
@ -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
|
||||
==================
|
||||
|
||||
|
|
4
TODO.md
4
TODO.md
|
@ -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?
|
||||
|
|
14
build.sh
14
build.sh
|
@ -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
|
|
@ -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" ]
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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) {
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
author: neale
|
||||
|
||||
Hello, world.
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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!")
|
||||
}
|
||||
}
|
27
devel.sh
27
devel.sh
|
@ -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
|
@ -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()
|
1190
devel/mistune.py
1190
devel/mistune.py
File diff suppressed because it is too large
Load Diff
505
devel/moth.py
505
devel/moth.py
|
@ -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)
|
|
@ -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('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
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)
|
1335
devel/parse.py
1335
devel/parse.py
File diff suppressed because it is too large
Load Diff
|
@ -1,8 +0,0 @@
|
|||
[flake8]
|
||||
# flake8 is an automated code formatting pedant.
|
||||
# Use it, please.
|
||||
#
|
||||
# python3 -m flake8 .
|
||||
#
|
||||
ignore = E501
|
||||
exclude = .git
|
|
@ -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}
|
|
@ -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()
|
|
@ -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!
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
|
@ -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
|
||||
====================
|
|
@ -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))
|
||||
|
|
@ -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.
|
|
@ -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.
|
|
@ -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))
|
||||
|
|
@ -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
|
|
@ -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])
|
|
@ -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))
|
||||
|
|
@ -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!
|
|
@ -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,8 +24,12 @@ function helperUpdateAnswer(event) {
|
|||
if (join === undefined) {
|
||||
join = ","
|
||||
}
|
||||
if (values.length == 0) {
|
||||
value = "None"
|
||||
} else {
|
||||
value = values.join(join)
|
||||
}
|
||||
}
|
||||
|
||||
// First make any adjustments to the value
|
||||
if (e.classList.contains("lower")) {
|
||||
|
@ -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) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
function helperInit(event) {
|
||||
{
|
||||
let init = function(event) {
|
||||
for (let e of document.querySelectorAll(".answer")) {
|
||||
helperActivate(e)
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", helperInit);
|
||||
document.addEventListener("DOMContentLoaded", init)
|
||||
} else {
|
||||
helperInit();
|
||||
init()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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">
|
|
@ -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
|
|
@ -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
|
||||
)
|
|
@ -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=
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
*
|
||||
|
|
@ -0,0 +1 @@
|
|||
console.log("Moo.")
|
|
@ -0,0 +1 @@
|
|||
Moo.
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
90
src/award.go
90
src/award.go
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
372
src/handlers.go
372
src/handlers.go
|
@ -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)
|
||||
}
|
249
src/instance.go
249
src/instance.go
|
@ -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
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
package main
|
|
@ -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...")
|
||||
}
|
||||
}
|
||||
}
|
228
src/mothball.go
228
src/mothball.go
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
86
src/mothd.go
86
src/mothd.go
|
@ -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))
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 34 KiB |
|
@ -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
Loading…
Reference in New Issue