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
|
*.o
|
||||||
.idea
|
.idea
|
||||||
./bin/
|
./bin/
|
||||||
build/
|
|
||||||
cache/
|
|
||||||
target/
|
|
||||||
puzzles
|
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/),
|
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).
|
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]
|
## [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
|
## [v3.5.1] - 2020-03-16
|
||||||
### Fixed
|
### 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>
|
Copyright © 2015-2016 Neale Pickett <neale@woozle.org>
|
||||||
|
|
||||||
> Permission is hereby granted, free of charge, to any person
|
Permission is hereby granted, free of charge, to any person
|
||||||
> obtaining a copy of this software and associated documentation files
|
obtaining a copy of this software and associated documentation files
|
||||||
> (the "Software"), to deal in the Software without restriction,
|
(the "Software"), to deal in the Software without restriction,
|
||||||
> including without limitation the rights to use, copy, modify, merge,
|
including without limitation the rights to use, copy, modify, merge,
|
||||||
> publish, distribute, sublicense, and/or sell copies of the Software,
|
publish, distribute, sublicense, and/or sell copies of the Software,
|
||||||
> and to permit persons to whom the Software is furnished to do so,
|
and to permit persons to whom the Software is furnished to do so,
|
||||||
> subject to the following conditions:
|
subject to the following conditions:
|
||||||
|
|
||||||
> The above copyright notice and this permission notice shall be
|
The above copyright notice and this permission notice shall be
|
||||||
> included in all copies or substantial portions of the Software.
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
> The software is provided "as is", without warranty of any kind,
|
The software is provided "as is", without warranty of any kind,
|
||||||
> express or implied, including but not limited to the warranties of
|
express or implied, including but not limited to the warranties of
|
||||||
> merchantability, fitness for a particular purpose and
|
merchantability, fitness for a particular purpose and
|
||||||
> noninfringement. In no event shall the authors or copyright holders
|
noninfringement. In no event shall the authors or copyright holders
|
||||||
> be liable for any claim, damages or other liability, whether in an
|
be liable for any claim, damages or other liability, whether in an
|
||||||
> action of contract, tort or otherwise, arising from, out of or in
|
action of contract, tort or otherwise, arising from, out of or in
|
||||||
> connection with the software or the use or other dealings in the
|
connection with the software or the use or other dealings in the
|
||||||
> software.
|
software.
|
||||||
|
|
||||||
|
|
||||||
Font Licenses
|
Font Licenses
|
||||||
|
|
163
README.md
163
README.md
|
@ -1,16 +1,11 @@
|
||||||
Dirtbags Monarch Of The Hill Server
|
Dirtbags Monarch Of The Hill Server
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
Master:
|
![Build badge](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=master)
|
||||||
![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=master)
|
![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)
|
||||||
![](https://github.com/dirtbags/moth/workflows/moth-devel%20Docker%20build/badge.svg?branch=master)
|
|
||||||
|
|
||||||
Devel:
|
Monarch Of The Hill (MOTH) is a puzzle server.
|
||||||
![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=devel)
|
We (the authors) have used it for instructional and contest events called
|
||||||
![](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
|
|
||||||
"Tracer FIRE",
|
"Tracer FIRE",
|
||||||
"Project 2",
|
"Project 2",
|
||||||
"HACK",
|
"HACK",
|
||||||
|
@ -23,152 +18,38 @@ and "Cyber Fire Foundry".
|
||||||
Information about these events is at
|
Information about these events is at
|
||||||
http://dirtbags.net/contest/
|
http://dirtbags.net/contest/
|
||||||
|
|
||||||
This software serves up puzzles in a manner similar to Jeopardy.
|
A few things make MOTH different than other Capture The Flag server projects:
|
||||||
It also tracks scores,
|
|
||||||
and comes with a JavaScript-based scoreboard to display team rankings.
|
* 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
|
Documentation
|
||||||
============================
|
==========
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
|
* [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
|
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.
|
You can be more fine-grained about directories, if you like.
|
||||||
Inside the container, you need the following paths:
|
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.
|
* `/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
|
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
|
* 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
|
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.
|
People are going to try to break this thing.
|
||||||
It needs to be bulletproof.
|
It needs to be bulletproof.
|
||||||
|
@ -10,23 +13,48 @@ This pretty much set the entire design:
|
||||||
* As much as possible is done client-side
|
* As much as possible is done client-side
|
||||||
* Participants can attack their own web browsers as much as they feel like
|
* Participants can attack their own web browsers as much as they feel like
|
||||||
* Also reduces server load
|
* 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
|
* Your laptop is faster than our server
|
||||||
* We give you the carrot of hashed answers and the hashing function
|
* We give you the carrot of hashed answers and the hashing function
|
||||||
* This removes one incentive to DoS the server
|
* This removes one incentive to DoS the server
|
||||||
* Generate static content whenever possible
|
* Generate static content whenever possible
|
||||||
* Puzzles are statically compiled before the event even starts
|
* Puzzles must be statically compiled before the event even starts
|
||||||
* `points.json` and `puzzles.json` are generated and cached by a maintenance loop
|
* As much content as possible is generated by a maintenance loop
|
||||||
* Minimize dynamic handling
|
* Minimize dynamic handling
|
||||||
* There are only two (2) dynamic handlers
|
* There are only three (3) dynamic handlers
|
||||||
* team registration
|
* team registration
|
||||||
* answer validation
|
* answer validation
|
||||||
|
* server state (open puzzles + event log)
|
||||||
* You can disable team registration if you want, just remove `teamids.txt`
|
* 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
|
* 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
|
* 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 code should be as tiny as possible
|
||||||
* Server should provide highly limited functionality
|
* Server should provide highly limited functionality
|
||||||
* It should be easy to remember in your head everything it does
|
* It should be easy to remember in your head everything it does
|
||||||
* Server is also compiled
|
* Server is also compiled
|
||||||
* Static type-checking helps assure no run-time errors
|
* 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
|
||||||
======
|
======
|
||||||
|
|
||||||
Tokens are good for a single point in a single category. They are
|
We used to use tokens extensively for categories outside of MOTH
|
||||||
formed by prepending the category and a colon to the bubblebabble digest
|
(like scavenger hunts, Dirtbags Tanks, and other standalone stuff).
|
||||||
of 3 random octets. A token for the "merfing" category might look like
|
|
||||||
this:
|
|
||||||
|
|
||||||
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
|
Entropy
|
||||||
|
|
|
@ -1,21 +1,26 @@
|
||||||
Author: neale
|
---
|
||||||
Summary: static puzzles
|
pre:
|
||||||
Answer: puzzle.moth
|
authors:
|
||||||
|
- neale
|
||||||
|
debug:
|
||||||
|
summary: static puzzles
|
||||||
|
answers:
|
||||||
|
- puzzle.md
|
||||||
|
---
|
||||||
Puzzle categories are laid out on the filesystem:
|
Puzzle categories are laid out on the filesystem:
|
||||||
|
|
||||||
example/
|
example/
|
||||||
├─1
|
├─1
|
||||||
│ └─puzzle.moth
|
│ └─puzzle.md
|
||||||
├─2
|
├─2
|
||||||
│ ├─puzzle.moth
|
│ ├─puzzle.md
|
||||||
│ └─salad.jpg
|
│ └─salad.jpg
|
||||||
├─3
|
├─3
|
||||||
│ └─puzzle.py
|
│ └─mkpuzzle
|
||||||
├─10
|
├─10
|
||||||
│ └─puzzle.moth
|
│ └─puzzle.md
|
||||||
└─100
|
└─100
|
||||||
└─puzzle.py
|
└─mkpuzzle
|
||||||
|
|
||||||
In this example,
|
In this example,
|
||||||
there are puzzles with point values 1, 2, 3, 10, and 100.
|
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.
|
their content was written by hand.
|
||||||
|
|
||||||
Puzzles 3 and 100 are "dynamic" puzzles:
|
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
|
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:
|
This file is in the following format:
|
||||||
|
|
||||||
Author: [name of the person who wrote this puzzle]
|
---
|
||||||
Summary: [brief description of the puzzle]
|
pre:
|
||||||
Answer: [answer to this puzzle]
|
authors:
|
||||||
Answer: [second acceptable answer to this puzzle]
|
- 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.
|
This is the puzzle body.
|
||||||
It is Markdown formatted:
|
It is Markdown formatted:
|
||||||
you can read more about Markdown on the Internet.
|
you can read more about Markdown on the Internet.
|
|
@ -1,6 +1,12 @@
|
||||||
Author: neale
|
---
|
||||||
Summary: Making excellent puzzles
|
pre:
|
||||||
Answer: moo
|
authors:
|
||||||
|
- neale
|
||||||
|
debug:
|
||||||
|
summary: Making excellent puzzles
|
||||||
|
answers:
|
||||||
|
- moo
|
||||||
|
---
|
||||||
|
|
||||||
Making Excellent Puzzles
|
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
|
// jshint asi:true
|
||||||
|
|
||||||
function helperUpdateAnswer(event) {
|
async function helperUpdateAnswer(event) {
|
||||||
let e = event.currentTarget
|
let e = event.currentTarget
|
||||||
let value = e.value
|
let value = e.value
|
||||||
let inputs = e.querySelectorAll("input")
|
let inputs = e.querySelectorAll("input")
|
||||||
|
@ -24,7 +24,11 @@ function helperUpdateAnswer(event) {
|
||||||
if (join === undefined) {
|
if (join === undefined) {
|
||||||
join = ","
|
join = ","
|
||||||
}
|
}
|
||||||
value = values.join(join)
|
if (values.length == 0) {
|
||||||
|
value = "None"
|
||||||
|
} else {
|
||||||
|
value = values.join(join)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// First make any adjustments to the value
|
// First make any adjustments to the value
|
||||||
|
@ -35,6 +39,35 @@ function helperUpdateAnswer(event) {
|
||||||
value = value.toUpperCase()
|
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")
|
let answer = document.querySelector("#answer")
|
||||||
answer.value = value
|
answer.value = value
|
||||||
answer.dispatchEvent(new InputEvent("input"))
|
answer.dispatchEvent(new InputEvent("input"))
|
||||||
|
@ -78,15 +111,16 @@ function helperActivate(e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let init = function(event) {
|
||||||
|
for (let e of document.querySelectorAll(".answer")) {
|
||||||
|
helperActivate(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function helperInit(event) {
|
if (document.readyState === "loading") {
|
||||||
for (let e of document.querySelectorAll(".answer")) {
|
document.addEventListener("DOMContentLoaded", init)
|
||||||
helperActivate(e)
|
} else {
|
||||||
|
init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", helperInit);
|
|
||||||
} else {
|
|
||||||
helperInit();
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
Summary: Using JavaScript Input Helpers
|
---
|
||||||
Author: neale
|
pre:
|
||||||
Script: helpers.js
|
authors:
|
||||||
Script: draggable.js
|
- neale
|
||||||
Answer: helper
|
scripts:
|
||||||
|
- filename: helpers.js
|
||||||
|
- filename: draggable.js
|
||||||
|
answers:
|
||||||
|
- helper
|
||||||
|
debug:
|
||||||
|
summary: Using JavaScript Input Helpers
|
||||||
|
---
|
||||||
MOTH only takes static answers:
|
MOTH only takes static answers:
|
||||||
you can't, for instance, write code to check answer correctness.
|
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.
|
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,
|
You will probably only want one of these in a page,
|
||||||
to avoid confusing people.
|
to avoid confusing people.
|
||||||
|
|
||||||
RFC3339 Timestamp
|
### RFC3339 Timestamp
|
||||||
<div class="answer" data-join="">
|
<div class="answer" data-join="">
|
||||||
<input type="date">
|
<input type="date">
|
||||||
<input type="hidden" value="T">
|
<input type="hidden" value="T">
|
||||||
|
@ -26,10 +32,10 @@ RFC3339 Timestamp
|
||||||
<input type="hidden" value="Z">
|
<input type="hidden" value="Z">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
All lower-case letters
|
### All lower-case letters
|
||||||
<input class="answer lower">
|
<input class="answer lower">
|
||||||
|
|
||||||
Multiple concatenated values
|
### Multiple concatenated values
|
||||||
<div class="answer lower">
|
<div class="answer lower">
|
||||||
<input type="color">
|
<input type="color">
|
||||||
<input type="number">
|
<input type="number">
|
||||||
|
@ -37,22 +43,32 @@ Multiple concatenated values
|
||||||
<input>
|
<input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Free input, sorted, concatenated values
|
### Free input, sorted, concatenated values
|
||||||
<ul class="answer lower sort">
|
<ul class="answer lower sort">
|
||||||
<li><input></li>
|
<li><input></li>
|
||||||
<li><button class="expand" title="Add another input">➕</button><l/i>
|
<li><button class="expand" title="Add another input">➕</button><l/i>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
User-draggable values
|
### User-draggable values
|
||||||
<ul class="answer">
|
<ul class="answer">
|
||||||
<li draggable="true"><input value="First" readonly></li>
|
<li draggable="true"><input value="First" readonly></li>
|
||||||
<li draggable="true"><input value="Third" readonly></li>
|
<li draggable="true"><input value="Third" readonly></li>
|
||||||
<li draggable="true"><input value="Second" readonly></li>
|
<li draggable="true"><input value="Second" readonly></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
Select from an ordered list of options
|
### Select from an ordered list of options
|
||||||
<ul class="answer">
|
<ul class="answer">
|
||||||
<li><input type="checkbox" value="horn">Horns</li>
|
<li><input type="checkbox" value="horn">Horns</li>
|
||||||
<li><input type="checkbox" value="hoof">Hooves</li>
|
<li><input type="checkbox" value="hoof">Hooves</li>
|
||||||
<li><input type="checkbox" value="antler">Antlers</li>
|
<li><input type="checkbox" value="antler">Antlers</li>
|
||||||
</ul>
|
</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 {
|
#devel {
|
||||||
background-color: #c88;
|
background-color: #eee;
|
||||||
color: black;
|
color: black;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
#devel .string {
|
||||||
|
color: #9c27b0;
|
||||||
|
}
|
||||||
|
#devel .body {
|
||||||
|
background-color: #ffc107;
|
||||||
}
|
}
|
||||||
.kvpair {
|
.kvpair {
|
||||||
border: solid black 2px;
|
border: solid black 2px;
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<link rel="stylesheet" href="basic.css">
|
<link rel="stylesheet" href="basic.css">
|
||||||
<script src="moth-pwa.js" type="text/javascript"></script>
|
|
||||||
<script src="moth.js"></script>
|
<script src="moth.js"></script>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</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