diff --git a/.github/workflows/docker_build_devel.yml b/.github/workflows/docker_build_devel.yml deleted file mode 100644 index e726c91..0000000 --- a/.github/workflows/docker_build_devel.yml +++ /dev/null @@ -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 . diff --git a/.github/workflows/docker_build_mothd.yml b/.github/workflows/docker_build_mothd.yml deleted file mode 100644 index 1aff1ea..0000000 --- a/.github/workflows/docker_build_mothd.yml +++ /dev/null @@ -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 . diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..c6c03aa --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f704c0e --- /dev/null +++ b/.github/workflows/test.yml @@ -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 ./... diff --git a/.gitignore b/.gitignore index a22d658..ade229e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,5 @@ *.o .idea ./bin/ -build/ -cache/ -target/ puzzles +__debug_bin diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..166db3a --- /dev/null +++ b/.vscode/launch.json @@ -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", + ] + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be1526..2942bb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v4.0.0] - Unreleased +### Changed +- Major rewrite/refactor of `mothd` + - Clear separation of roles: State, Puzzles, and Theme + - Sqlite, Redis, or S3 should fit in easily now + - Will allow "dynamic" puzzles now, we just need a flag to enable it + - Server no longer provides unlocked content + - Puzzle URLs are now just `/content/${cat}/${points}/` + - Changes to `state` directory + - Most files now have a bit of (English) documentation at the beginning + - `state/until` is now `state/hours` and can specify multiple begin/end hours + - `state/disabled` is now `state/enabled` +- Mothball structure has changed + - Mothballs no longer contain `map.txt` + - Mothballs no longer obfuscate content paths + - Clients now expect unlocked puzzles to just be `map[string][]int` +- New `/state` API endpoint + - Provides *all* server state: event log, team mapping, messages, configuration + +### Added +- New `transpile` CLI command + - Provides `mothball` action to create mothballs + - Lets you test a few development server things, if you want + +### Deprecated + +### Removed +- Development server is gone now; use `mothd` directly with a flag to transpile on the fly + +### Fixed + +### Security + ## [Unreleased] +### Changed + - Endpoints `/points.json`, `/puzzles.json`, and `/messages.html` (optional theme file) combine into `/state` + - No more `__devel__` category for dev server: this is now `.config.devel` in the `/state` endpoint + - Development server no longer serves a static `/` with links: it now redirects you to a randomly-generated seed URL + - Default theme modifications to handle all this + - Default theme now automatically "logs you in" with Team ID if it's getting state from the devel server ## [v3.5.1] - 2020-03-16 ### Fixed diff --git a/Dockerfile.moth b/Dockerfile.moth deleted file mode 100644 index 86768d9..0000000 --- a/Dockerfile.moth +++ /dev/null @@ -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" ] diff --git a/Dockerfile.moth-devel b/Dockerfile.moth-devel deleted file mode 100644 index c1ca075..0000000 --- a/Dockerfile.moth-devel +++ /dev/null @@ -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" ] diff --git a/LICENSE.md b/LICENSE.md index d77eb2a..82e110c 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,24 +1,24 @@ Copyright © 2015-2016 Neale Pickett -> Permission is hereby granted, free of charge, to any person -> obtaining a copy of this software and associated documentation files -> (the "Software"), to deal in the Software without restriction, -> including without limitation the rights to use, copy, modify, merge, -> publish, distribute, sublicense, and/or sell copies of the Software, -> and to permit persons to whom the Software is furnished to do so, -> subject to the following conditions: +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: -> The above copyright notice and this permission notice shall be -> included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -> The software is provided "as is", without warranty of any kind, -> express or implied, including but not limited to the warranties of -> merchantability, fitness for a particular purpose and -> noninfringement. In no event shall the authors or copyright holders -> be liable for any claim, damages or other liability, whether in an -> action of contract, tort or otherwise, arising from, out of or in -> connection with the software or the use or other dealings in the -> software. +The software is provided "as is", without warranty of any kind, +express or implied, including but not limited to the warranties of +merchantability, fitness for a particular purpose and +noninfringement. In no event shall the authors or copyright holders +be liable for any claim, damages or other liability, whether in an +action of contract, tort or otherwise, arising from, out of or in +connection with the software or the use or other dealings in the +software. Font Licenses diff --git a/README.md b/README.md index 7fc26e9..47dfc91 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,11 @@ Dirtbags Monarch Of The Hill Server ===================== -Master: -![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=master) -![](https://github.com/dirtbags/moth/workflows/moth-devel%20Docker%20build/badge.svg?branch=master) +![Build badge](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=master) +![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth) -Devel: -![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=devel) -![](https://github.com/dirtbags/moth/workflows/moth-devel%20Docker%20build/badge.svg?branch=devel) - -This is a set of thingies to run our Monarch-Of-The-Hill contest, -which in the past has been called +Monarch Of The Hill (MOTH) is a puzzle server. +We (the authors) have used it for instructional and contest events called "Tracer FIRE", "Project 2", "HACK", @@ -23,152 +18,38 @@ and "Cyber Fire Foundry". Information about these events is at http://dirtbags.net/contest/ -This software serves up puzzles in a manner similar to Jeopardy. -It also tracks scores, -and comes with a JavaScript-based scoreboard to display team rankings. +A few things make MOTH different than other Capture The Flag server projects: + +* Once any team opens a puzzle, all teams can work on it (high fives to DC949/Orange County for this idea) +* No penalties for wrong answers +* No time-based point deductions (if you're faster, you get to answer more puzzles) +* No internal notion of ranking or score: it only stores an event log, and scoreboards parse it however they want +* All puzzles must be compiled to static content before it can be served up +* The server does very little: most functionality is in client-side JavaScript + +You can read more about why we made these decisions in [philosophy](docs/philosophy.md). -Running a Development Server -============================ - -To use example puzzles - - docker run --rm -it -p 8080:8080 dirtbags/moth-devel - -or, to use your own puzzles - - docker run --rm -it -p 8080:8080 -v /path/to/puzzles:/puzzles:ro dirtbags/moth-devel - -And point a browser to http://localhost:8080/ (or whatever host is running the server). - -The development server includes a number of Python libraries that we have found useful in writing puzzles. - -When you're ready to create your own puzzles, -read [the devel server documentation](docs/devel-server.md). - -Click the `[mb]` link by a puzzle category to compile and download a mothball that the production server can read. +Documentation +========== +* [Development](docs/development.md): The development server lets you create and test categories, and compile mothballs. +* [Getting Started](docs/getting-started.md): This guide will get you started with a production server. +* [Administration](docs/administration.md): How to set hours, and change setup. Running a Production Server =========================== - docker run --rm -it -p 8080:8080 -v /path/to/moth/state:/state -v /path/to/moth/balls:/mothballs:ro dirtbags/moth + docker run --rm -it -p 8080:8080 -v /path/to/moth/state:/state -v /path/to/moth/mothballs:/mothballs:ro dirtbags/moth You can be more fine-grained about directories, if you like. Inside the container, you need the following paths: -* `/state` (rw) Where state is stored. Read [the overview](docs/overview.md) to learn what's what in here. +* `/state` (rw) Where state is stored. Read [the overview](doc/overview.md) to learn what's what in here. * `/mothballs` (ro) Mothballs (puzzle bundles) as provided by the development server. -* `/resources` (ro) Overrides for built-in HTML/CSS resources. +* `/theme` (ro) Overrides for the built-in theme. - - - -Getting Started Developing -------------------------------- - -If you don't have a `puzzles` directory, -you can copy the example puzzles as a starting point: - - $ cp -r example-puzzles puzzles - -Then launch the development server: - - $ python3 devel/devel-server.py - -Point a web browser at http://localhost:8080/ -and start hacking on things in your `puzzles` directory. - -More on how the devel sever works in -[the devel server documentation](docs/devel-server.md) - - -Running A Production Server -==================== - -Run `dirtbags/moth` (Docker) or `mothd` (native). - -`mothd` assumes you're running a contest out of `/moth`. -For Docker, you'll need to bind-mount your actual directories -(`state`, `mothballs`, and optionally `resources`) into -`/moth/`. - -You can override any path with an option, -run `mothd -help` for usage. - - -State Directory -=============== - - -Pausing scoring -------------------- - -Create the file `state/disabled` -to pause scoring, -and remove it to resume. -You can use the Unix `touch` command to create the file: - - touch state/disabled - -When scoring is paused, -participants can still submit answers, -and the system will tell them whether the answer is correct. -As soon as you unpause, -all correctly-submitted answers will be scored. - - -Resetting an instance -------------------- - -Remove the file `state/initialized`, -and the server will zap everything. - - -Setting up custom team IDs -------------------- - -The file `state/teamids.txt` has all the team IDs, -one per line. -This defaults to all 4-digit natural numbers. -You can edit it to be whatever strings you like. - -We sometimes to set `teamids.txt` to a bunch of random 8-digit hex values: - - for i in $(seq 50); do od -x /dev/urandom | awk '{print $2 $3; exit;}'; done - -Remember that team IDs are essentially passwords. - - -Enabling offline/PWA mode -------------------- - -If the file `state/export_manifest` is found, the server will expose the -endpoint `/current_manifest.json?id=`. 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 ================== diff --git a/TODO.md b/TODO.md index 81a2aa1..665f4ed 100644 --- a/TODO.md +++ b/TODO.md @@ -1 +1,5 @@ * Figure out how to log JSend short text in addition to HTTP code +* We've got logic in state.go and httpd.go that is neither httpd nor state specific. + Pull this into some other file that means "here are the brains of the server". +* Get Bo's answer pattern anchors working again +* Are we logging every transaction now? diff --git a/VERSION b/VERSION deleted file mode 100644 index d5c0c99..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -3.5.1 diff --git a/build.sh b/build.sh deleted file mode 100755 index ac8d438..0000000 --- a/build.sh +++ /dev/null @@ -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 diff --git a/build/package/Containerfile b/build/package/Containerfile new file mode 100644 index 0000000..8b4b94c --- /dev/null +++ b/build/package/Containerfile @@ -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" ] diff --git a/build/package/build.sh b/build/package/build.sh new file mode 100755 index 0000000..c9913a0 --- /dev/null +++ b/build/package/build.sh @@ -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 diff --git a/cmd/mothd/httpd.go b/cmd/mothd/httpd.go new file mode 100644 index 0000000..66eb6f4 --- /dev/null +++ b/cmd/mothd/httpd.go @@ -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) +} diff --git a/cmd/mothd/httpd_test.go b/cmd/mothd/httpd_test.go new file mode 100644 index 0000000..6bd66dc --- /dev/null +++ b/cmd/mothd/httpd_test.go @@ -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") + } +} diff --git a/cmd/mothd/main.go b/cmd/mothd/main.go new file mode 100644 index 0000000..61bb823 --- /dev/null +++ b/cmd/mothd/main.go @@ -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) +} diff --git a/cmd/mothd/mothballs.go b/cmd/mothd/mothballs.go new file mode 100644 index 0000000..deab589 --- /dev/null +++ b/cmd/mothd/mothballs.go @@ -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() + } +} diff --git a/cmd/mothd/mothballs_test.go b/cmd/mothd/mothballs_test.go new file mode 100644 index 0000000..e32ca17 --- /dev/null +++ b/cmd/mothd/mothballs_test.go @@ -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) + } + +} diff --git a/cmd/mothd/providercommand.go b/cmd/mothd/providercommand.go new file mode 100644 index 0000000..75e46b8 --- /dev/null +++ b/cmd/mothd/providercommand.go @@ -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) { +} diff --git a/cmd/mothd/providercommand_test.go b/cmd/mothd/providercommand_test.go new file mode 100644 index 0000000..cf9a7e1 --- /dev/null +++ b/cmd/mothd/providercommand_test.go @@ -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) + } +} diff --git a/cmd/mothd/server.go b/cmd/mothd/server.go new file mode 100644 index 0000000..a355cf7 --- /dev/null +++ b/cmd/mothd/server.go @@ -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 +} diff --git a/cmd/mothd/server_test.go b/cmd/mothd/server_test.go new file mode 100644 index 0000000..2c5f732 --- /dev/null +++ b/cmd/mothd/server_test.go @@ -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 +} diff --git a/cmd/mothd/state.go b/cmd/mothd/state.go new file mode 100644 index 0000000..156e49c --- /dev/null +++ b/cmd/mothd/state.go @@ -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, "") + 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() + } + } +} diff --git a/cmd/mothd/state_test.go b/cmd/mothd/state_test.go new file mode 100644 index 0000000..cca8150 --- /dev/null +++ b/cmd/mothd/state_test.go @@ -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)) + } +} diff --git a/cmd/mothd/testdata/cat0/1/puzzle.md b/cmd/mothd/testdata/cat0/1/puzzle.md new file mode 100644 index 0000000..f29dfcd --- /dev/null +++ b/cmd/mothd/testdata/cat0/1/puzzle.md @@ -0,0 +1,3 @@ +author: neale + +Hello, world. diff --git a/cmd/mothd/testdata/testpiler.sh b/cmd/mothd/testdata/testpiler.sh new file mode 100755 index 0000000..60da536 --- /dev/null +++ b/cmd/mothd/testdata/testpiler.sh @@ -0,0 +1,40 @@ +#! /bin/sh -e + +fail () { + echo "$@" 1>&2 + exit 1 +} + +case "$ACTION:$CAT:$POINTS" in + inventory::) + cat < - - - Dev Server - - - -

Dev Server

- -

- Pick a seed: -

-
    -
  • {seed}: a special seed I made just for you!
  • -
  • random: will use a different seed every time you load a page (could be useful for debugging)
  • -
  • You can also hack your own seed into the URL, if you want to.
  • -
- -

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

- -

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

- - -""".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() diff --git a/devel/mistune.py b/devel/mistune.py deleted file mode 100644 index a81c4c1..0000000 --- a/devel/mistune.py +++ /dev/null @@ -1,1190 +0,0 @@ -# coding: utf-8 -"""mistune - ~~~~~~~ - - The fastest markdown parser in pure Python with renderer feature. - - Copyright (c) 2014 - 2015, Hsiaoming Yang - - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions - are met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - - * Neither the name of the creator nor the names of its - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. - - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND - CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, - INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF - MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS - BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, - EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED - TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR - TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF - THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF - SUCH DAMAGE. -""" - -import re -import inspect - -__version__ = '0.7.3' -__author__ = 'Hsiaoming Yang ' -__all__ = [ - 'BlockGrammar', 'BlockLexer', - 'InlineGrammar', 'InlineLexer', - 'Renderer', 'Markdown', - 'markdown', 'escape', -] - - -_key_pattern = re.compile(r'\s+') -_nonalpha_pattern = re.compile(r'\W') -_escape_pattern = re.compile(r'&(?!#?\w+;)') -_newline_pattern = re.compile(r'\r\n|\r') -_block_quote_leading_pattern = re.compile(r'^ *> ?', flags=re.M) -_block_code_leading_pattern = re.compile(r'^ {4}', re.M) -_inline_tags = [ - 'a', 'em', 'strong', 'small', 's', 'cite', 'q', 'dfn', 'abbr', 'data', - 'time', 'code', 'var', 'samp', 'kbd', 'sub', 'sup', 'i', 'b', 'u', 'mark', - 'ruby', 'rt', 'rp', 'bdi', 'bdo', 'span', 'br', 'wbr', 'ins', 'del', - 'img', 'font', -] -_pre_tags = ['pre', 'script', 'style'] -_valid_end = r'(?!:/|[^\w\s@]*@)\b' -_valid_attr = r'''\s*[a-zA-Z\-](?:\=(?:"[^"]*"|'[^']*'|\d+))*''' -_block_tag = r'(?!(?:%s)\b)\w+%s' % ('|'.join(_inline_tags), _valid_end) -_scheme_blacklist = ('javascript:', 'vbscript:') - - -def _pure_pattern(regex): - pattern = regex.pattern - if pattern.startswith('^'): - pattern = pattern[1:] - return pattern - - -def _keyify(key): - return _key_pattern.sub(' ', key.lower()) - - -def escape(text, quote=False, smart_amp=True): - """Replace special characters "&", "<" and ">" to HTML-safe sequences. - - The original cgi.escape will always escape "&", but you can control - this one for a smart escape amp. - - :param quote: if set to True, " and ' will be escaped. - :param smart_amp: if set to False, & will always be escaped. - """ - if smart_amp: - text = _escape_pattern.sub('&', text) - else: - text = text.replace('&', '&') - text = text.replace('<', '<') - text = text.replace('>', '>') - if quote: - text = text.replace('"', '"') - text = text.replace("'", ''') - return text - - -def escape_link(url): - """Remove dangerous URL schemes like javascript: and escape afterwards.""" - lower_url = url.lower().strip('\x00\x1a \n\r\t') - for scheme in _scheme_blacklist: - if lower_url.startswith(scheme): - return '' - return escape(url, quote=True, smart_amp=False) - - -def preprocessing(text, tab=4): - text = _newline_pattern.sub('\n', text) - text = text.expandtabs(tab) - text = text.replace('\u00a0', ' ') - text = text.replace('\u2424', '\n') - pattern = re.compile(r'^ +$', re.M) - return pattern.sub('', text) - - -class BlockGrammar(object): - """Grammars for block level tokens.""" - - def_links = re.compile( - r'^ *\[([^^\]]+)\]: *' # [key]: - r']+)>?' # or link - r'(?: +["(]([^\n]+)[")])? *(?:\n+|$)' - ) - def_footnotes = re.compile( - r'^\[\^([^\]]+)\]: *(' - r'[^\n]*(?:\n+|$)' # [^key]: - r'(?: {1,}[^\n]*(?:\n+|$))*' - r')' - ) - - newline = re.compile(r'^\n+') - block_code = re.compile(r'^( {4}[^\n]+\n*)+') - fences = re.compile( - r'^ *(`{3,}|~{3,}) *(\S+)? *\n' # ```lang - r'([\s\S]+?)\s*' - r'\1 *(?:\n+|$)' # ``` - ) - hrule = re.compile(r'^ {0,3}[-*_](?: *[-*_]){2,} *(?:\n+|$)') - heading = re.compile(r'^ *(#{1,6}) *([^\n]+?) *#* *(?:\n+|$)') - lheading = re.compile(r'^([^\n]+)\n *(=|-)+ *(?:\n+|$)') - block_quote = re.compile(r'^( *>[^\n]+(\n[^\n]+)*\n*)+') - list_block = re.compile( - r'^( *)([*+-]|\d+\.) [\s\S]+?' - r'(?:' - r'\n+(?=\1?(?:[-*_] *){3,}(?:\n+|$))' # hrule - r'|\n+(?=%s)' # def links - r'|\n+(?=%s)' # def footnotes - r'|\n{2,}' - r'(?! )' - r'(?!\1(?:[*+-]|\d+\.) )\n*' - r'|' - r'\s*$)' % ( - _pure_pattern(def_links), - _pure_pattern(def_footnotes), - ) - ) - list_item = re.compile( - r'^(( *)(?:[*+-]|\d+\.) [^\n]*' - r'(?:\n(?!\2(?:[*+-]|\d+\.) )[^\n]*)*)', - flags=re.M - ) - list_bullet = re.compile(r'^ *(?:[*+-]|\d+\.) +') - paragraph = re.compile( - r'^((?:[^\n]+\n?(?!' - r'%s|%s|%s|%s|%s|%s|%s|%s|%s' - r'))+)\n*' % ( - _pure_pattern(fences).replace(r'\1', r'\2'), - _pure_pattern(list_block).replace(r'\1', r'\3'), - _pure_pattern(hrule), - _pure_pattern(heading), - _pure_pattern(lheading), - _pure_pattern(block_quote), - _pure_pattern(def_links), - _pure_pattern(def_footnotes), - '<' + _block_tag, - ) - ) - block_html = re.compile( - r'^ *(?:%s|%s|%s) *(?:\n{2,}|\s*$)' % ( - r'', - r'<(%s)((?:%s)*?)>([\s\S]*?)<\/\1>' % (_block_tag, _valid_attr), - r'<%s(?:%s)*?\s*\/?>' % (_block_tag, _valid_attr), - ) - ) - table = re.compile( - r'^ *\|(.+)\n *\|( *[-:]+[-| :]*)\n((?: *\|.*(?:\n|$))*)\n*' - ) - nptable = re.compile( - r'^ *(\S.*\|.*)\n *([-:]+ *\|[-| :]*)\n((?:.*\|.*(?:\n|$))*)\n*' - ) - text = re.compile(r'^[^\n]+') - - -class BlockLexer(object): - """Block level lexer for block grammars.""" - grammar_class = BlockGrammar - - default_rules = [ - 'newline', 'hrule', 'block_code', 'fences', 'heading', - 'nptable', 'lheading', 'block_quote', - 'list_block', 'block_html', 'def_links', - 'def_footnotes', 'table', 'paragraph', 'text' - ] - - list_rules = ( - 'newline', 'block_code', 'fences', 'lheading', 'hrule', - 'block_quote', 'list_block', 'block_html', 'text', - ) - - footnote_rules = ( - 'newline', 'block_code', 'fences', 'heading', - 'nptable', 'lheading', 'hrule', 'block_quote', - 'list_block', 'block_html', 'table', 'paragraph', 'text' - ) - - def __init__(self, rules=None, **kwargs): - self.tokens = [] - self.def_links = {} - self.def_footnotes = {} - - if not rules: - rules = self.grammar_class() - - self.rules = rules - - def __call__(self, text, rules=None): - return self.parse(text, rules) - - def parse(self, text, rules=None): - text = text.rstrip('\n') - - if not rules: - rules = self.default_rules - - def manipulate(text): - for key in rules: - rule = getattr(self.rules, key) - m = rule.match(text) - if not m: - continue - getattr(self, 'parse_%s' % key)(m) - return m - return False # pragma: no cover - - while text: - m = manipulate(text) - if m is not False: - text = text[len(m.group(0)):] - continue - if text: # pragma: no cover - raise RuntimeError('Infinite loop at: %s' % text) - return self.tokens - - def parse_newline(self, m): - length = len(m.group(0)) - if length > 1: - self.tokens.append({'type': 'newline'}) - - def parse_block_code(self, m): - # clean leading whitespace - code = _block_code_leading_pattern.sub('', m.group(0)) - self.tokens.append({ - 'type': 'code', - 'lang': None, - 'text': code, - }) - - def parse_fences(self, m): - self.tokens.append({ - 'type': 'code', - 'lang': m.group(2), - 'text': m.group(3), - }) - - def parse_heading(self, m): - self.tokens.append({ - 'type': 'heading', - 'level': len(m.group(1)), - 'text': m.group(2), - }) - - def parse_lheading(self, m): - """Parse setext heading.""" - self.tokens.append({ - 'type': 'heading', - 'level': 1 if m.group(2) == '=' else 2, - 'text': m.group(1), - }) - - def parse_hrule(self, m): - self.tokens.append({'type': 'hrule'}) - - def parse_list_block(self, m): - bull = m.group(2) - self.tokens.append({ - 'type': 'list_start', - 'ordered': '.' in bull, - }) - cap = m.group(0) - self._process_list_item(cap, bull) - self.tokens.append({'type': 'list_end'}) - - def _process_list_item(self, cap, bull): - cap = self.rules.list_item.findall(cap) - - _next = False - length = len(cap) - - for i in range(length): - item = cap[i][0] - - # remove the bullet - space = len(item) - item = self.rules.list_bullet.sub('', item) - - # outdent - if '\n ' in item: - space = space - len(item) - pattern = re.compile(r'^ {1,%d}' % space, flags=re.M) - item = pattern.sub('', item) - - # determine whether item is loose or not - loose = _next - if not loose and re.search(r'\n\n(?!\s*$)', item): - loose = True - - rest = len(item) - if i != length - 1 and rest: - _next = item[rest - 1] == '\n' - if not loose: - loose = _next - - if loose: - t = 'loose_item_start' - else: - t = 'list_item_start' - - self.tokens.append({'type': t}) - # recurse - self.parse(item, self.list_rules) - self.tokens.append({'type': 'list_item_end'}) - - def parse_block_quote(self, m): - self.tokens.append({'type': 'block_quote_start'}) - # clean leading > - cap = _block_quote_leading_pattern.sub('', m.group(0)) - self.parse(cap) - self.tokens.append({'type': 'block_quote_end'}) - - def parse_def_links(self, m): - key = _keyify(m.group(1)) - self.def_links[key] = { - 'link': m.group(2), - 'title': m.group(3), - } - - def parse_def_footnotes(self, m): - key = _keyify(m.group(1)) - if key in self.def_footnotes: - # footnote is already defined - return - - self.def_footnotes[key] = 0 - - self.tokens.append({ - 'type': 'footnote_start', - 'key': key, - }) - - text = m.group(2) - - if '\n' in text: - lines = text.split('\n') - whitespace = None - for line in lines[1:]: - space = len(line) - len(line.lstrip()) - if space and (not whitespace or space < whitespace): - whitespace = space - newlines = [lines[0]] - for line in lines[1:]: - newlines.append(line[whitespace:]) - text = '\n'.join(newlines) - - self.parse(text, self.footnote_rules) - - self.tokens.append({ - 'type': 'footnote_end', - 'key': key, - }) - - def parse_table(self, m): - item = self._process_table(m) - - cells = re.sub(r'(?: *\| *)?\n$', '', m.group(3)) - cells = cells.split('\n') - for i, v in enumerate(cells): - v = re.sub(r'^ *\| *| *\| *$', '', v) - cells[i] = re.split(r' *\| *', v) - - item['cells'] = cells - self.tokens.append(item) - - def parse_nptable(self, m): - item = self._process_table(m) - - cells = re.sub(r'\n$', '', m.group(3)) - cells = cells.split('\n') - for i, v in enumerate(cells): - cells[i] = re.split(r' *\| *', v) - - item['cells'] = cells - self.tokens.append(item) - - def _process_table(self, m): - header = re.sub(r'^ *| *\| *$', '', m.group(1)) - header = re.split(r' *\| *', header) - align = re.sub(r' *|\| *$', '', m.group(2)) - align = re.split(r' *\| *', align) - - for i, v in enumerate(align): - if re.search(r'^ *-+: *$', v): - align[i] = 'right' - elif re.search(r'^ *:-+: *$', v): - align[i] = 'center' - elif re.search(r'^ *:-+ *$', v): - align[i] = 'left' - else: - align[i] = None - - item = { - 'type': 'table', - 'header': header, - 'align': align, - } - return item - - def parse_block_html(self, m): - tag = m.group(1) - if not tag: - text = m.group(0) - self.tokens.append({ - 'type': 'close_html', - 'text': text - }) - else: - attr = m.group(2) - text = m.group(3) - self.tokens.append({ - 'type': 'open_html', - 'tag': tag, - 'extra': attr, - 'text': text - }) - - def parse_paragraph(self, m): - text = m.group(1).rstrip('\n') - self.tokens.append({'type': 'paragraph', 'text': text}) - - def parse_text(self, m): - text = m.group(0) - self.tokens.append({'type': 'text', 'text': text}) - - -class InlineGrammar(object): - """Grammars for inline level tokens.""" - - escape = re.compile(r'^\\([\\`*{}\[\]()#+\-.!_>~|])') # \* \+ \! .... - inline_html = re.compile( - r'^(?:%s|%s|%s)' % ( - r'', - r'<(\w+%s)((?:%s)*?)\s*>([\s\S]*?)<\/\1>' % (_valid_end, _valid_attr), - r'<\w+%s(?:%s)*?\s*\/?>' % (_valid_end, _valid_attr), - ) - ) - autolink = re.compile(r'^<([^ >]+(@|:)[^ >]+)>') - link = re.compile( - r'^!?\[(' - r'(?:\[[^^\]]*\]|[^\[\]]|\](?=[^\[]*\]))*' - r')\]\(' - r'''\s*(<)?([\s\S]*?)(?(2)>)(?:\s+['"]([\s\S]*?)['"])?\s*''' - r'\)' - ) - reflink = re.compile( - r'^!?\[(' - r'(?:\[[^^\]]*\]|[^\[\]]|\](?=[^\[]*\]))*' - r')\]\s*\[([^^\]]*)\]' - ) - nolink = re.compile(r'^!?\[((?:\[[^\]]*\]|[^\[\]])*)\]') - url = re.compile(r'''^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])''') - double_emphasis = re.compile( - r'^_{2}([\s\S]+?)_{2}(?!_)' # __word__ - r'|' - r'^\*{2}([\s\S]+?)\*{2}(?!\*)' # **word** - ) - emphasis = re.compile( - r'^\b_((?:__|[^_])+?)_\b' # _word_ - r'|' - r'^\*((?:\*\*|[^\*])+?)\*(?!\*)' # *word* - ) - code = re.compile(r'^(`+)\s*([\s\S]*?[^`])\s*\1(?!`)') # `code` - linebreak = re.compile(r'^ {2,}\n(?!\s*$)') - strikethrough = re.compile(r'^~~(?=\S)([\s\S]*?\S)~~') # ~~word~~ - footnote = re.compile(r'^\[\^([^\]]+)\]') - text = re.compile(r'^[\s\S]+?(?=[\\%s' % (tag, extra, text, tag) - else: - html = m.group(0) - return self.renderer.inline_html(html) - - def output_footnote(self, m): - key = _keyify(m.group(1)) - if key not in self.footnotes: - return None - if self.footnotes[key]: - return None - self.footnote_index += 1 - self.footnotes[key] = self.footnote_index - return self.renderer.footnote_ref(key, self.footnote_index) - - def output_link(self, m): - return self._process_link(m, m.group(3), m.group(4)) - - def output_reflink(self, m): - key = _keyify(m.group(2) or m.group(1)) - if key not in self.links: - return None - ret = self.links[key] - return self._process_link(m, ret['link'], ret['title']) - - def output_nolink(self, m): - key = _keyify(m.group(1)) - if key not in self.links: - return None - ret = self.links[key] - return self._process_link(m, ret['link'], ret['title']) - - def _process_link(self, m, link, title=None): - line = m.group(0) - text = m.group(1) - if line[0] == '!': - return self.renderer.image(link, title, text) - - self._in_link = True - text = self.output(text) - self._in_link = False - return self.renderer.link(link, title, text) - - def output_double_emphasis(self, m): - text = m.group(2) or m.group(1) - text = self.output(text) - return self.renderer.double_emphasis(text) - - def output_emphasis(self, m): - text = m.group(2) or m.group(1) - text = self.output(text) - return self.renderer.emphasis(text) - - def output_code(self, m): - text = m.group(2) - return self.renderer.codespan(text) - - def output_linebreak(self, m): - return self.renderer.linebreak() - - def output_strikethrough(self, m): - text = self.output(m.group(1)) - return self.renderer.strikethrough(text) - - def output_text(self, m): - text = m.group(0) - return self.renderer.text(text) - - -class Renderer(object): - """The default HTML renderer for rendering Markdown. - """ - - def __init__(self, **kwargs): - self.options = kwargs - - def placeholder(self): - """Returns the default, empty output value for the renderer. - - All renderer methods use the '+=' operator to append to this value. - Default is a string so rendering HTML can build up a result string with - the rendered Markdown. - - Can be overridden by Renderer subclasses to be types like an empty - list, allowing the renderer to create a tree-like structure to - represent the document (which can then be reprocessed later into a - separate format like docx or pdf). - """ - return '' - - def block_code(self, code, lang=None): - """Rendering block level code. ``pre > code``. - - :param code: text content of the code block. - :param lang: language of the given code. - """ - code = code.rstrip('\n') - if not lang: - code = escape(code, smart_amp=False) - return '
%s\n
\n' % code - code = escape(code, quote=True, smart_amp=False) - return '
%s\n
\n' % (lang, code) - - def block_quote(self, text): - """Rendering
with the given text. - - :param text: text content of the blockquote. - """ - return '
%s\n
\n' % text.rstrip('\n') - - def block_html(self, html): - """Rendering block level pure html content. - - :param html: text content of the html snippet. - """ - if self.options.get('skip_style') and \ - html.lower().startswith('`` ``

``. - - :param text: rendered text content for the header. - :param level: a number for the header level, for example: 1. - :param raw: raw text content of the header. - """ - return '%s\n' % (level, text, level) - - def hrule(self): - """Rendering method for ``
`` tag.""" - if self.options.get('use_xhtml'): - return '
\n' - return '
\n' - - def list(self, body, ordered=True): - """Rendering list tags like ``
    `` and ``
      ``. - - :param body: body contents of the list. - :param ordered: whether this list is ordered or not. - """ - tag = 'ul' - if ordered: - tag = 'ol' - return '<%s>\n%s\n' % (tag, body, tag) - - def list_item(self, text): - """Rendering list item snippet. Like ``
    1. ``.""" - return '
    2. %s
    3. \n' % text - - def paragraph(self, text): - """Rendering paragraph tags. Like ``

      ``.""" - return '

      %s

      \n' % text.strip(' ') - - def table(self, header, body): - """Rendering table element. Wrap header and body in it. - - :param header: header part of the table. - :param body: body part of the table. - """ - return ( - '\n%s\n' - '\n%s\n
      \n' - ) % (header, body) - - def table_row(self, content): - """Rendering a table row. Like ````. - - :param content: content of current table row. - """ - return '\n%s\n' % content - - def table_cell(self, content, **flags): - """Rendering a table cell. Like ```` ````. - - :param content: content of current table cell. - :param header: whether this is header or not. - :param align: align of current table cell. - """ - if flags['header']: - tag = 'th' - else: - tag = 'td' - align = flags['align'] - if not align: - return '<%s>%s\n' % (tag, content, tag) - return '<%s style="text-align:%s">%s\n' % ( - tag, align, content, tag - ) - - def double_emphasis(self, text): - """Rendering **strong** text. - - :param text: text content for emphasis. - """ - return '%s' % text - - def emphasis(self, text): - """Rendering *emphasis* text. - - :param text: text content for emphasis. - """ - return '%s' % text - - def codespan(self, text): - """Rendering inline `code` text. - - :param text: text content for inline code. - """ - text = escape(text.rstrip(), smart_amp=False) - return '%s' % text - - def linebreak(self): - """Rendering line break like ``
      ``.""" - if self.options.get('use_xhtml'): - return '
      \n' - return '
      \n' - - def strikethrough(self, text): - """Rendering ~~strikethrough~~ text. - - :param text: text content for strikethrough. - """ - return '%s' % text - - def text(self, text): - """Rendering unformatted text. - - :param text: text content. - """ - return escape(text) - - def escape(self, text): - """Rendering escape sequence. - - :param text: text content. - """ - return escape(text) - - def autolink(self, link, is_email=False): - """Rendering a given link or email address. - - :param link: link content or email address. - :param is_email: whether this is an email or not. - """ - text = link = escape(link) - if is_email: - link = 'mailto:%s' % link - return '%s' % (link, text) - - def link(self, link, title, text): - """Rendering a given link with content and title. - - :param link: href link for ```` tag. - :param title: title content for `title` attribute. - :param text: text content for description. - """ - link = escape_link(link) - if not title: - return '%s' % (link, text) - title = escape(title, quote=True) - return '%s' % (link, title, text) - - def image(self, src, title, text): - """Rendering a image with title and text. - - :param src: source link of the image. - :param title: title text of the image. - :param text: alt text of the image. - """ - src = escape_link(src) - text = escape(text, quote=True) - if title: - title = escape(title, quote=True) - html = '%s' % html - return '%s>' % html - - def inline_html(self, html): - """Rendering span level pure html content. - - :param html: text content of the html snippet. - """ - if self.options.get('escape'): - return escape(html) - return html - - def newline(self): - """Rendering newline element.""" - return '' - - def footnote_ref(self, key, index): - """Rendering the ref anchor of a footnote. - - :param key: identity key for the footnote. - :param index: the index count of current footnote. - """ - html = ( - '' - '%d' - ) % (escape(key), escape(key), index) - return html - - def footnote_item(self, key, text): - """Rendering a footnote item. - - :param key: identity key for the footnote. - :param text: text content of the footnote. - """ - back = ( - '' - ) % escape(key) - text = text.rstrip() - if text.endswith('

      '): - text = re.sub(r'<\/p>$', r'%s

      ' % back, text) - else: - text = '%s

      %s

      ' % (text, back) - html = '
    4. %s
    5. \n' % (escape(key), text) - return html - - def footnotes(self, text): - """Wrapper for all footnotes. - - :param text: contents of all footnotes. - """ - html = '
      \n%s
        %s
      \n
      \n' - return html % (self.hrule(), text) - - -class Markdown(object): - """The Markdown parser. - - :param renderer: An instance of ``Renderer``. - :param inline: An inline lexer class or instance. - :param block: A block lexer class or instance. - """ - def __init__(self, renderer=None, inline=None, block=None, **kwargs): - if not renderer: - renderer = Renderer(**kwargs) - else: - kwargs.update(renderer.options) - - self.renderer = renderer - - if inline and inspect.isclass(inline): - inline = inline(renderer, **kwargs) - if block and inspect.isclass(block): - block = block(**kwargs) - - if inline: - self.inline = inline - else: - self.inline = InlineLexer(renderer, **kwargs) - - self.block = block or BlockLexer(BlockGrammar()) - self.footnotes = [] - self.tokens = [] - - # detect if it should parse text in block html - self._parse_block_html = kwargs.get('parse_block_html') - - def __call__(self, text): - return self.parse(text) - - def render(self, text): - """Render the Markdown text. - - :param text: markdown formatted text content. - """ - return self.parse(text) - - def parse(self, text): - out = self.output(preprocessing(text)) - - keys = self.block.def_footnotes - - # reset block - self.block.def_links = {} - self.block.def_footnotes = {} - - # reset inline - self.inline.links = {} - self.inline.footnotes = {} - - if not self.footnotes: - return out - - footnotes = filter(lambda o: keys.get(o['key']), self.footnotes) - self.footnotes = sorted( - footnotes, key=lambda o: keys.get(o['key']), reverse=True - ) - - body = self.renderer.placeholder() - while self.footnotes: - note = self.footnotes.pop() - body += self.renderer.footnote_item( - note['key'], note['text'] - ) - - out += self.renderer.footnotes(body) - return out - - def pop(self): - if not self.tokens: - return None - self.token = self.tokens.pop() - return self.token - - def peek(self): - if self.tokens: - return self.tokens[-1] - return None # pragma: no cover - - def output(self, text, rules=None): - self.tokens = self.block(text, rules) - self.tokens.reverse() - - self.inline.setup(self.block.def_links, self.block.def_footnotes) - - out = self.renderer.placeholder() - while self.pop(): - out += self.tok() - return out - - def tok(self): - t = self.token['type'] - - # sepcial cases - if t.endswith('_start'): - t = t[:-6] - - return getattr(self, 'output_%s' % t)() - - def tok_text(self): - text = self.token['text'] - while self.peek()['type'] == 'text': - text += '\n' + self.pop()['text'] - return self.inline(text) - - def output_newline(self): - return self.renderer.newline() - - def output_hrule(self): - return self.renderer.hrule() - - def output_heading(self): - return self.renderer.header( - self.inline(self.token['text']), - self.token['level'], - self.token['text'], - ) - - def output_code(self): - return self.renderer.block_code( - self.token['text'], self.token['lang'] - ) - - def output_table(self): - aligns = self.token['align'] - aligns_length = len(aligns) - cell = self.renderer.placeholder() - - # header part - header = self.renderer.placeholder() - for i, value in enumerate(self.token['header']): - align = aligns[i] if i < aligns_length else None - flags = {'header': True, 'align': align} - cell += self.renderer.table_cell(self.inline(value), **flags) - - header += self.renderer.table_row(cell) - - # body part - body = self.renderer.placeholder() - for i, row in enumerate(self.token['cells']): - cell = self.renderer.placeholder() - for j, value in enumerate(row): - align = aligns[j] if j < aligns_length else None - flags = {'header': False, 'align': align} - cell += self.renderer.table_cell(self.inline(value), **flags) - body += self.renderer.table_row(cell) - - return self.renderer.table(header, body) - - def output_block_quote(self): - body = self.renderer.placeholder() - while self.pop()['type'] != 'block_quote_end': - body += self.tok() - return self.renderer.block_quote(body) - - def output_list(self): - ordered = self.token['ordered'] - body = self.renderer.placeholder() - while self.pop()['type'] != 'list_end': - body += self.tok() - return self.renderer.list(body, ordered) - - def output_list_item(self): - body = self.renderer.placeholder() - while self.pop()['type'] != 'list_item_end': - if self.token['type'] == 'text': - body += self.tok_text() - else: - body += self.tok() - - return self.renderer.list_item(body) - - def output_loose_item(self): - body = self.renderer.placeholder() - while self.pop()['type'] != 'list_item_end': - body += self.tok() - return self.renderer.list_item(body) - - def output_footnote(self): - self.inline._in_footnote = True - body = self.renderer.placeholder() - key = self.token['key'] - while self.pop()['type'] != 'footnote_end': - body += self.tok() - self.footnotes.append({'key': key, 'text': body}) - self.inline._in_footnote = False - return self.renderer.placeholder() - - def output_close_html(self): - text = self.token['text'] - return self.renderer.block_html(text) - - def output_open_html(self): - text = self.token['text'] - tag = self.token['tag'] - if self._parse_block_html and tag not in _pre_tags: - text = self.inline(text, rules=self.inline.inline_html_rules) - extra = self.token.get('extra') or '' - html = '<%s%s>%s' % (tag, extra, text, tag) - return self.renderer.block_html(html) - - def output_paragraph(self): - return self.renderer.paragraph(self.inline(self.token['text'])) - - def output_text(self): - return self.renderer.paragraph(self.tok_text()) - - -def markdown(text, escape=True, **kwargs): - """Render markdown formatted text to html. - - :param text: markdown formatted text content. - :param escape: if set to False, all html tags will not be escaped. - :param use_xhtml: output with xhtml tags. - :param hard_wrap: if set to True, it will use the GFM line breaks feature. - :param parse_block_html: parse text only in block level html. - :param parse_inline_html: parse text only in inline level html. - """ - return Markdown(escape=escape, **kwargs)(text) diff --git a/devel/moth.py b/devel/moth.py deleted file mode 100644 index f1841f2..0000000 --- a/devel/moth.py +++ /dev/null @@ -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=('�', '⌷')): - 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('
      ')
      -        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('
      ') - - 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) diff --git a/devel/mothballer.py b/devel/mothballer.py deleted file mode 100755 index e6afbd9..0000000 --- a/devel/mothballer.py +++ /dev/null @@ -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) diff --git a/devel/parse.py b/devel/parse.py deleted file mode 100644 index 80ca637..0000000 --- a/devel/parse.py +++ /dev/null @@ -1,1335 +0,0 @@ -# -*- encoding: utf-8 -*- -r'''Parse strings using a specification based on the Python format() syntax. - - ``parse()`` is the opposite of ``format()`` - -The module is set up to only export ``parse()``, ``search()``, ``findall()``, -and ``with_pattern()`` when ``import \*`` is used: - ->>> from parse import * - -From there it's a simple thing to parse a string: - ->>> parse("It's {}, I love it!", "It's spam, I love it!") - ->>> _[0] -'spam' - -Or to search a string for some pattern: - ->>> search('Age: {:d}\n', 'Name: Rufus\nAge: 42\nColor: red\n') - - -Or find all the occurrences of some pattern in a string: - ->>> ''.join(r.fixed[0] for r in findall(">{}<", "

      the bold text

      ")) -'the bold text' - -If you're going to use the same pattern to match lots of strings you can -compile it once: - ->>> from parse import compile ->>> p = compile("It's {}, I love it!") ->>> print(p) - ->>> p.parse("It's spam, I love it!") - - -("compile" is not exported for ``import *`` usage as it would override the -built-in ``compile()`` function) - -The default behaviour is to match strings case insensitively. You may match with -case by specifying `case_sensitive=True`: - ->>> parse('SPAM', 'spam', case_sensitive=True) is None -True - - -Format Syntax -------------- - -A basic version of the `Format String Syntax`_ is supported with anonymous -(fixed-position), named and formatted fields:: - - {[field name]:[format spec]} - -Field names must be a valid Python identifiers, including dotted names; -element indexes imply dictionaries (see below for example). - -Numbered fields are also not supported: the result of parsing will include -the parsed fields in the order they are parsed. - -The conversion of fields to types other than strings is done based on the -type in the format specification, which mirrors the ``format()`` behaviour. -There are no "!" field conversions like ``format()`` has. - -Some simple parse() format string examples: - ->>> parse("Bring me a {}", "Bring me a shrubbery") - ->>> r = parse("The {} who say {}", "The knights who say Ni!") ->>> print(r) - ->>> print(r.fixed) -('knights', 'Ni!') ->>> r = parse("Bring out the holy {item}", "Bring out the holy hand grenade") ->>> print(r) - ->>> print(r.named) -{'item': 'hand grenade'} ->>> print(r['item']) -hand grenade ->>> 'item' in r -True - -Note that `in` only works if you have named fields. Dotted names and indexes -are possible though the application must make additional sense of the result: - ->>> r = parse("Mmm, {food.type}, I love it!", "Mmm, spam, I love it!") ->>> print(r) - ->>> print(r.named) -{'food.type': 'spam'} ->>> print(r['food.type']) -spam ->>> r = parse("My quest is {quest[name]}", "My quest is to seek the holy grail!") ->>> print(r) - ->>> print(r['quest']) -{'name': 'to seek the holy grail!'} ->>> print(r['quest']['name']) -to seek the holy grail! - -If the text you're matching has braces in it you can match those by including -a double-brace ``{{`` or ``}}`` in your format string, just like format() does. - - -Format Specification --------------------- - -Most often a straight format-less ``{}`` will suffice where a more complex -format specification might have been used. - -Most of `format()`'s `Format Specification Mini-Language`_ is supported: - - [[fill]align][0][width][.precision][type] - -The differences between `parse()` and `format()` are: - -- The align operators will cause spaces (or specified fill character) to be - stripped from the parsed value. The width is not enforced; it just indicates - there may be whitespace or "0"s to strip. -- Numeric parsing will automatically handle a "0b", "0o" or "0x" prefix. - That is, the "#" format character is handled automatically by d, b, o - and x formats. For "d" any will be accepted, but for the others the correct - prefix must be present if at all. -- Numeric sign is handled automatically. -- The thousands separator is handled automatically if the "n" type is used. -- The types supported are a slightly different mix to the format() types. Some - format() types come directly over: "d", "n", "%", "f", "e", "b", "o" and "x". - In addition some regular expression character group types "D", "w", "W", "s" - and "S" are also available. -- The "e" and "g" types are case-insensitive so there is not need for - the "E" or "G" types. - -===== =========================================== ======== -Type Characters Matched Output -===== =========================================== ======== -l Letters (ASCII) str -w Letters, numbers and underscore str -W Not letters, numbers and underscore str -s Whitespace str -S Non-whitespace str -d Digits (effectively integer numbers) int -D Non-digit str -n Numbers with thousands separators (, or .) int -% Percentage (converted to value/100.0) float -f Fixed-point numbers float -F Decimal numbers Decimal -e Floating-point numbers with exponent float - e.g. 1.1e-10, NAN (all case insensitive) -g General number format (either d, f or e) float -b Binary numbers int -o Octal numbers int -x Hexadecimal numbers (lower and upper case) int -ti ISO 8601 format date/time datetime - e.g. 1972-01-20T10:21:36Z ("T" and "Z" - optional) -te RFC2822 e-mail format date/time datetime - e.g. Mon, 20 Jan 1972 10:21:36 +1000 -tg Global (day/month) format date/time datetime - e.g. 20/1/1972 10:21:36 AM +1:00 -ta US (month/day) format date/time datetime - e.g. 1/20/1972 10:21:36 PM +10:30 -tc ctime() format date/time datetime - e.g. Sun Sep 16 01:03:52 1973 -th HTTP log format date/time datetime - e.g. 21/Nov/2011:00:07:11 +0000 -ts Linux system log format date/time datetime - e.g. Nov 9 03:37:44 -tt Time time - e.g. 10:21:36 PM -5:30 -===== =========================================== ======== - -Some examples of typed parsing with ``None`` returned if the typing -does not match: - ->>> parse('Our {:d} {:w} are...', 'Our 3 weapons are...') - ->>> parse('Our {:d} {:w} are...', 'Our three weapons are...') ->>> parse('Meet at {:tg}', 'Meet at 1/2/2011 11:00 PM') - - -And messing about with alignment: - ->>> parse('with {:>} herring', 'with a herring') - ->>> parse('spam {:^} spam', 'spam lovely spam') - - -Note that the "center" alignment does not test to make sure the value is -centered - it just strips leading and trailing whitespace. - -Width and precision may be used to restrict the size of matched text -from the input. Width specifies a minimum size and precision specifies -a maximum. For example: - ->>> parse('{:.2}{:.2}', 'look') # specifying precision - ->>> parse('{:4}{:4}', 'look at that') # specifying width - ->>> parse('{:4}{:.4}', 'look at that') # specifying both - ->>> parse('{:2d}{:2d}', '0440') # parsing two contiguous numbers - - -Some notes for the date and time types: - -- the presence of the time part is optional (including ISO 8601, starting - at the "T"). A full datetime object will always be returned; the time - will be set to 00:00:00. You may also specify a time without seconds. -- when a seconds amount is present in the input fractions will be parsed - to give microseconds. -- except in ISO 8601 the day and month digits may be 0-padded. -- the date separator for the tg and ta formats may be "-" or "/". -- named months (abbreviations or full names) may be used in the ta and tg - formats in place of numeric months. -- as per RFC 2822 the e-mail format may omit the day (and comma), and the - seconds but nothing else. -- hours greater than 12 will be happily accepted. -- the AM/PM are optional, and if PM is found then 12 hours will be added - to the datetime object's hours amount - even if the hour is greater - than 12 (for consistency.) -- in ISO 8601 the "Z" (UTC) timezone part may be a numeric offset -- timezones are specified as "+HH:MM" or "-HH:MM". The hour may be one or two - digits (0-padded is OK.) Also, the ":" is optional. -- the timezone is optional in all except the e-mail format (it defaults to - UTC.) -- named timezones are not handled yet. - -Note: attempting to match too many datetime fields in a single parse() will -currently result in a resource allocation issue. A TooManyFields exception -will be raised in this instance. The current limit is about 15. It is hoped -that this limit will be removed one day. - -.. _`Format String Syntax`: - http://docs.python.org/library/string.html#format-string-syntax -.. _`Format Specification Mini-Language`: - http://docs.python.org/library/string.html#format-specification-mini-language - - -Result and Match Objects ------------------------- - -The result of a ``parse()`` and ``search()`` operation is either ``None`` (no match), a -``Result`` instance or a ``Match`` instance if ``evaluate_result`` is False. - -The ``Result`` instance has three attributes: - -fixed - A tuple of the fixed-position, anonymous fields extracted from the input. -named - A dictionary of the named fields extracted from the input. -spans - A dictionary mapping the names and fixed position indices matched to a - 2-tuple slice range of where the match occurred in the input. - The span does not include any stripped padding (alignment or width). - -The ``Match`` instance has one method: - -evaluate_result() - Generates and returns a ``Result`` instance for this ``Match`` object. - - - -Custom Type Conversions ------------------------ - -If you wish to have matched fields automatically converted to your own type you -may pass in a dictionary of type conversion information to ``parse()`` and -``compile()``. - -The converter will be passed the field string matched. Whatever it returns -will be substituted in the ``Result`` instance for that field. - -Your custom type conversions may override the builtin types if you supply one -with the same identifier. - ->>> def shouty(string): -... return string.upper() -... ->>> parse('{:shouty} world', 'hello world', dict(shouty=shouty)) - - -If the type converter has the optional ``pattern`` attribute, it is used as -regular expression for better pattern matching (instead of the default one). - ->>> def parse_number(text): -... return int(text) ->>> parse_number.pattern = r'\d+' ->>> parse('Answer: {number:Number}', 'Answer: 42', dict(Number=parse_number)) - ->>> _ = parse('Answer: {:Number}', 'Answer: Alice', dict(Number=parse_number)) ->>> assert _ is None, "MISMATCH" - -You can also use the ``with_pattern(pattern)`` decorator to add this -information to a type converter function: - ->>> from parse import with_pattern ->>> @with_pattern(r'\d+') -... def parse_number(text): -... return int(text) ->>> parse('Answer: {number:Number}', 'Answer: 42', dict(Number=parse_number)) - - -A more complete example of a custom type might be: - ->>> yesno_mapping = { -... "yes": True, "no": False, -... "on": True, "off": False, -... "true": True, "false": False, -... } ->>> @with_pattern(r"|".join(yesno_mapping)) -... def parse_yesno(text): -... return yesno_mapping[text.lower()] - - -If the type converter ``pattern`` uses regex-grouping (with parenthesis), -you should indicate this by using the optional ``regex_group_count`` parameter -in the ``with_pattern()`` decorator: - ->>> @with_pattern(r'((\d+))', regex_group_count=2) -... def parse_number2(text): -... return int(text) ->>> parse('Answer: {:Number2} {:Number2}', 'Answer: 42 43', dict(Number2=parse_number2)) - - -Otherwise, this may cause parsing problems with unnamed/fixed parameters. - - -Potential Gotchas ------------------ - -`parse()` will always match the shortest text necessary (from left to right) -to fulfil the parse pattern, so for example: - ->>> pattern = '{dir1}/{dir2}' ->>> data = 'root/parent/subdir' ->>> sorted(parse(pattern, data).named.items()) -[('dir1', 'root'), ('dir2', 'parent/subdir')] - -So, even though `{'dir1': 'root/parent', 'dir2': 'subdir'}` would also fit -the pattern, the actual match represents the shortest successful match for -`dir1`. - ----- - -**Version history (in brief)**: - -- 1.11.0 Implement `__contains__` for Result instances. -- 1.10.0 Introduce a "letters" matcher, since "w" matches numbers - also. -- 1.9.1 Fix deprecation warnings around backslashes in regex strings - (thanks Mickaël Schoentgen). Also fix some documentation formatting - issues. -- 1.9.0 We now honor precision and width specifiers when parsing numbers - and strings, allowing parsing of concatenated elements of fixed width - (thanks Julia Signell) -- 1.8.4 Add LICENSE file at request of packagers. - Correct handling of AM/PM to follow most common interpretation. - Correct parsing of hexadecimal that looks like a binary prefix. - Add ability to parse case sensitively. - Add parsing of numbers to Decimal with "F" (thanks John Vandenberg) -- 1.8.3 Add regex_group_count to with_pattern() decorator to support - user-defined types that contain brackets/parenthesis (thanks Jens Engel) -- 1.8.2 add documentation for including braces in format string -- 1.8.1 ensure bare hexadecimal digits are not matched -- 1.8.0 support manual control over result evaluation (thanks Timo Furrer) -- 1.7.0 parse dict fields (thanks Mark Visser) and adapted to allow - more than 100 re groups in Python 3.5+ (thanks David King) -- 1.6.6 parse Linux system log dates (thanks Alex Cowan) -- 1.6.5 handle precision in float format (thanks Levi Kilcher) -- 1.6.4 handle pipe "|" characters in parse string (thanks Martijn Pieters) -- 1.6.3 handle repeated instances of named fields, fix bug in PM time - overflow -- 1.6.2 fix logging to use local, not root logger (thanks Necku) -- 1.6.1 be more flexible regarding matched ISO datetimes and timezones in - general, fix bug in timezones without ":" and improve docs -- 1.6.0 add support for optional ``pattern`` attribute in user-defined types - (thanks Jens Engel) -- 1.5.3 fix handling of question marks -- 1.5.2 fix type conversion error with dotted names (thanks Sebastian Thiel) -- 1.5.1 implement handling of named datetime fields -- 1.5 add handling of dotted field names (thanks Sebastian Thiel) -- 1.4.1 fix parsing of "0" in int conversion (thanks James Rowe) -- 1.4 add __getitem__ convenience access on Result. -- 1.3.3 fix Python 2.5 setup.py issue. -- 1.3.2 fix Python 3.2 setup.py issue. -- 1.3.1 fix a couple of Python 3.2 compatibility issues. -- 1.3 added search() and findall(); removed compile() from ``import *`` - export as it overwrites builtin. -- 1.2 added ability for custom and override type conversions to be - provided; some cleanup -- 1.1.9 to keep things simpler number sign is handled automatically; - significant robustification in the face of edge-case input. -- 1.1.8 allow "d" fields to have number base "0x" etc. prefixes; - fix up some field type interactions after stress-testing the parser; - implement "%" type. -- 1.1.7 Python 3 compatibility tweaks (2.5 to 2.7 and 3.2 are supported). -- 1.1.6 add "e" and "g" field types; removed redundant "h" and "X"; - removed need for explicit "#". -- 1.1.5 accept textual dates in more places; Result now holds match span - positions. -- 1.1.4 fixes to some int type conversion; implemented "=" alignment; added - date/time parsing with a variety of formats handled. -- 1.1.3 type conversion is automatic based on specified field types. Also added - "f" and "n" types. -- 1.1.2 refactored, added compile() and limited ``from parse import *`` -- 1.1.1 documentation improvements -- 1.1.0 implemented more of the `Format Specification Mini-Language`_ - and removed the restriction on mixing fixed-position and named fields -- 1.0.0 initial release - -This code is copyright 2012-2019 Richard Jones -See the end of the source file for the license of use. -''' - -from __future__ import absolute_import -__version__ = '1.11.0' - -# yes, I now have two problems -import re -import sys -from datetime import datetime, time, tzinfo, timedelta -from decimal import Decimal -from functools import partial -import logging - -__all__ = 'parse search findall with_pattern'.split() - -log = logging.getLogger(__name__) - - -def with_pattern(pattern, regex_group_count=None): - """Attach a regular expression pattern matcher to a custom type converter - function. - - This annotates the type converter with the :attr:`pattern` attribute. - - EXAMPLE: - >>> import parse - >>> @parse.with_pattern(r"\d+") - ... def parse_number(text): - ... return int(text) - - is equivalent to: - - >>> def parse_number(text): - ... return int(text) - >>> parse_number.pattern = r"\d+" - - :param pattern: regular expression pattern (as text) - :param regex_group_count: Indicates how many regex-groups are in pattern. - :return: wrapped function - """ - def decorator(func): - func.pattern = pattern - func.regex_group_count = regex_group_count - return func - return decorator - - -def int_convert(base): - '''Convert a string to an integer. - - The string may start with a sign. - - It may be of a base other than 10. - - If may start with a base indicator, 0#nnnn, which we assume should - override the specified base. - - It may also have other non-numeric characters that we can ignore. - ''' - CHARS = '0123456789abcdefghijklmnopqrstuvwxyz' - - def f(string, match, base=base): - if string[0] == '-': - sign = -1 - else: - sign = 1 - - if string[0] == '0' and len(string) > 2: - if string[1] in 'bB': - base = 2 - elif string[1] in 'oO': - base = 8 - elif string[1] in 'xX': - base = 16 - else: - # just go with the base specifed - pass - - chars = CHARS[:base] - string = re.sub('[^%s]' % chars, '', string.lower()) - return sign * int(string, base) - return f - - -def percentage(string, match): - return float(string[:-1]) / 100. - - -class FixedTzOffset(tzinfo): - """Fixed offset in minutes east from UTC. - """ - ZERO = timedelta(0) - - def __init__(self, offset, name): - self._offset = timedelta(minutes=offset) - self._name = name - - def __repr__(self): - return '<%s %s %s>' % (self.__class__.__name__, self._name, - self._offset) - - def utcoffset(self, dt): - return self._offset - - def tzname(self, dt): - return self._name - - def dst(self, dt): - return self.ZERO - - def __eq__(self, other): - return self._name == other._name and self._offset == other._offset - - -MONTHS_MAP = dict( - Jan=1, January=1, - Feb=2, February=2, - Mar=3, March=3, - Apr=4, April=4, - May=5, - Jun=6, June=6, - Jul=7, July=7, - Aug=8, August=8, - Sep=9, September=9, - Oct=10, October=10, - Nov=11, November=11, - Dec=12, December=12 -) -DAYS_PAT = r'(Mon|Tue|Wed|Thu|Fri|Sat|Sun)' -MONTHS_PAT = r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)' -ALL_MONTHS_PAT = r'(%s)' % '|'.join(MONTHS_MAP) -TIME_PAT = r'(\d{1,2}:\d{1,2}(:\d{1,2}(\.\d+)?)?)' -AM_PAT = r'(\s+[AP]M)' -TZ_PAT = r'(\s+[-+]\d\d?:?\d\d)' - - -def date_convert(string, match, ymd=None, mdy=None, dmy=None, - d_m_y=None, hms=None, am=None, tz=None, mm=None, dd=None): - '''Convert the incoming string containing some date / time info into a - datetime instance. - ''' - groups = match.groups() - time_only = False - if mm and dd: - y=datetime.today().year - m=groups[mm] - d=groups[dd] - elif ymd is not None: - y, m, d = re.split(r'[-/\s]', groups[ymd]) - elif mdy is not None: - m, d, y = re.split(r'[-/\s]', groups[mdy]) - elif dmy is not None: - d, m, y = re.split(r'[-/\s]', groups[dmy]) - elif d_m_y is not None: - d, m, y = d_m_y - d = groups[d] - m = groups[m] - y = groups[y] - else: - time_only = True - - H = M = S = u = 0 - if hms is not None and groups[hms]: - t = groups[hms].split(':') - if len(t) == 2: - H, M = t - else: - H, M, S = t - if '.' in S: - S, u = S.split('.') - u = int(float('.' + u) * 1000000) - S = int(S) - H = int(H) - M = int(M) - - if am is not None: - am = groups[am] - if am: - am = am.strip() - if am == 'AM' and H == 12: - # correction for "12" hour functioning as "0" hour: 12:15 AM = 00:15 by 24 hr clock - H -= 12 - elif am == 'PM' and H == 12: - # no correction needed: 12PM is midday, 12:00 by 24 hour clock - pass - elif am == 'PM': - H += 12 - - if tz is not None: - tz = groups[tz] - if tz == 'Z': - tz = FixedTzOffset(0, 'UTC') - elif tz: - tz = tz.strip() - if tz.isupper(): - # TODO use the awesome python TZ module? - pass - else: - sign = tz[0] - if ':' in tz: - tzh, tzm = tz[1:].split(':') - elif len(tz) == 4: # 'snnn' - tzh, tzm = tz[1], tz[2:4] - else: - tzh, tzm = tz[1:3], tz[3:5] - offset = int(tzm) + int(tzh) * 60 - if sign == '-': - offset = -offset - tz = FixedTzOffset(offset, tz) - - if time_only: - d = time(H, M, S, u, tzinfo=tz) - else: - y = int(y) - if m.isdigit(): - m = int(m) - else: - m = MONTHS_MAP[m] - d = int(d) - d = datetime(y, m, d, H, M, S, u, tzinfo=tz) - - return d - - -class TooManyFields(ValueError): - pass - - -class RepeatedNameError(ValueError): - pass - - -# note: {} are handled separately -# note: I don't use r'' here because Sublime Text 2 syntax highlight has a fit -REGEX_SAFETY = re.compile(r'([?\\\\.[\]()*+\^$!\|])') - -# allowed field types -ALLOWED_TYPES = set(list('nbox%fFegwWdDsSl') + - ['t' + c for c in 'ieahgcts']) - - -def extract_format(format, extra_types): - '''Pull apart the format [[fill]align][0][width][.precision][type] - ''' - fill = align = None - if format[0] in '<>=^': - align = format[0] - format = format[1:] - elif len(format) > 1 and format[1] in '<>=^': - fill = format[0] - align = format[1] - format = format[2:] - - zero = False - if format and format[0] == '0': - zero = True - format = format[1:] - - width = '' - while format: - if not format[0].isdigit(): - break - width += format[0] - format = format[1:] - - if format.startswith('.'): - # Precision isn't needed but we need to capture it so that - # the ValueError isn't raised. - format = format[1:] # drop the '.' - precision = '' - while format: - if not format[0].isdigit(): - break - precision += format[0] - format = format[1:] - - # the rest is the type, if present - type = format - if type and type not in ALLOWED_TYPES and type not in extra_types: - raise ValueError('format spec %r not recognised' % type) - - return locals() - - -PARSE_RE = re.compile(r"""({{|}}|{\w*(?:(?:\.\w+)|(?:\[[^\]]+\]))*(?::[^}]+)?})""") - - -class Parser(object): - '''Encapsulate a format string that may be used to parse other strings. - ''' - def __init__(self, format, extra_types=None, case_sensitive=False): - # a mapping of a name as in {hello.world} to a regex-group compatible - # name, like hello__world Its used to prevent the transformation of - # name-to-group and group to name to fail subtly, such as in: - # hello_.world-> hello___world->hello._world - self._group_to_name_map = {} - # also store the original field name to group name mapping to allow - # multiple instances of a name in the format string - self._name_to_group_map = {} - # and to sanity check the repeated instances store away the first - # field type specification for the named field - self._name_types = {} - - self._format = format - if extra_types is None: - extra_types = {} - self._extra_types = extra_types - if case_sensitive: - self._re_flags = re.DOTALL - else: - self._re_flags = re.IGNORECASE | re.DOTALL - self._fixed_fields = [] - self._named_fields = [] - self._group_index = 0 - self._type_conversions = {} - self._expression = self._generate_expression() - self.__search_re = None - self.__match_re = None - - log.debug('format %r -> %r', format, self._expression) - - def __repr__(self): - if len(self._format) > 20: - return '<%s %r>' % (self.__class__.__name__, - self._format[:17] + '...') - return '<%s %r>' % (self.__class__.__name__, self._format) - - @property - def _search_re(self): - if self.__search_re is None: - try: - self.__search_re = re.compile(self._expression, self._re_flags) - except AssertionError: - # access error through sys to keep py3k and backward compat - e = str(sys.exc_info()[1]) - if e.endswith('this version only supports 100 named groups'): - raise TooManyFields('sorry, you are attempting to parse ' - 'too many complex fields') - return self.__search_re - - @property - def _match_re(self): - if self.__match_re is None: - expression = r'^%s$' % self._expression - try: - self.__match_re = re.compile(expression, self._re_flags) - except AssertionError: - # access error through sys to keep py3k and backward compat - e = str(sys.exc_info()[1]) - if e.endswith('this version only supports 100 named groups'): - raise TooManyFields('sorry, you are attempting to parse ' - 'too many complex fields') - except re.error: - raise NotImplementedError("Group names (e.g. (?P) can " - "cause failure, as they are not escaped properly: '%s'" % - expression) - return self.__match_re - - def parse(self, string, evaluate_result=True): - '''Match my format to the string exactly. - - Return a Result or Match instance or None if there's no match. - ''' - m = self._match_re.match(string) - if m is None: - return None - - if evaluate_result: - return self.evaluate_result(m) - else: - return Match(self, m) - - def search(self, string, pos=0, endpos=None, evaluate_result=True): - '''Search the string for my format. - - Optionally start the search at "pos" character index and limit the - search to a maximum index of endpos - equivalent to - search(string[:endpos]). - - If the ``evaluate_result`` argument is set to ``False`` a - Match instance is returned instead of the actual Result instance. - - Return either a Result instance or None if there's no match. - ''' - if endpos is None: - endpos = len(string) - m = self._search_re.search(string, pos, endpos) - if m is None: - return None - - if evaluate_result: - return self.evaluate_result(m) - else: - return Match(self, m) - - def findall(self, string, pos=0, endpos=None, extra_types=None, evaluate_result=True): - '''Search "string" for all occurrences of "format". - - Optionally start the search at "pos" character index and limit the - search to a maximum index of endpos - equivalent to - search(string[:endpos]). - - Returns an iterator that holds Result or Match instances for each format match - found. - ''' - if endpos is None: - endpos = len(string) - return ResultIterator(self, string, pos, endpos, evaluate_result=evaluate_result) - - def _expand_named_fields(self, named_fields): - result = {} - for field, value in named_fields.items(): - # split 'aaa[bbb][ccc]...' into 'aaa' and '[bbb][ccc]...' - basename, subkeys = re.match(r'([^\[]+)(.*)', field).groups() - - # create nested dictionaries {'aaa': {'bbb': {'ccc': ...}}} - d = result - k = basename - - if subkeys: - for subkey in re.findall(r'\[[^\]]+\]', subkeys): - d = d.setdefault(k,{}) - k = subkey[1:-1] - - # assign the value to the last key - d[k] = value - - return result - - def evaluate_result(self, m): - '''Generate a Result instance for the given regex match object''' - # ok, figure the fixed fields we've pulled out and type convert them - fixed_fields = list(m.groups()) - for n in self._fixed_fields: - if n in self._type_conversions: - fixed_fields[n] = self._type_conversions[n](fixed_fields[n], m) - fixed_fields = tuple(fixed_fields[n] for n in self._fixed_fields) - - # grab the named fields, converting where requested - groupdict = m.groupdict() - named_fields = {} - name_map = {} - for k in self._named_fields: - korig = self._group_to_name_map[k] - name_map[korig] = k - if k in self._type_conversions: - value = self._type_conversions[k](groupdict[k], m) - else: - value = groupdict[k] - - named_fields[korig] = value - - # now figure the match spans - spans = dict((n, m.span(name_map[n])) for n in named_fields) - spans.update((i, m.span(n + 1)) - for i, n in enumerate(self._fixed_fields)) - - # and that's our result - return Result(fixed_fields, self._expand_named_fields(named_fields), spans) - - def _regex_replace(self, match): - return '\\' + match.group(1) - - def _generate_expression(self): - # turn my _format attribute into the _expression attribute - e = [] - for part in PARSE_RE.split(self._format): - if not part: - continue - elif part == '{{': - e.append(r'\{') - elif part == '}}': - e.append(r'\}') - elif part[0] == '{': - # this will be a braces-delimited field to handle - e.append(self._handle_field(part)) - else: - # just some text to match - e.append(REGEX_SAFETY.sub(self._regex_replace, part)) - return ''.join(e) - - def _to_group_name(self, field): - # return a version of field which can be used as capture group, even - # though it might contain '.' - group = field.replace('.', '_').replace('[', '_').replace(']', '_') - - # make sure we don't collide ("a.b" colliding with "a_b") - n = 1 - while group in self._group_to_name_map: - n += 1 - if '.' in field: - group = field.replace('.', '_' * n) - elif '_' in field: - group = field.replace('_', '_' * n) - else: - raise KeyError('duplicated group name %r' % (field,)) - - # save off the mapping - self._group_to_name_map[group] = field - self._name_to_group_map[field] = group - return group - - def _handle_field(self, field): - # first: lose the braces - field = field[1:-1] - - # now figure whether this is an anonymous or named field, and whether - # there's any format specification - format = '' - if field and field[0].isalpha(): - if ':' in field: - name, format = field.split(':') - else: - name = field - if name in self._name_to_group_map: - if self._name_types[name] != format: - raise RepeatedNameError('field type %r for field "%s" ' - 'does not match previous seen type %r' % (format, - name, self._name_types[name])) - group = self._name_to_group_map[name] - # match previously-seen value - return r'(?P=%s)' % group - else: - group = self._to_group_name(name) - self._name_types[name] = format - self._named_fields.append(group) - # this will become a group, which must not contain dots - wrap = r'(?P<%s>%%s)' % group - else: - self._fixed_fields.append(self._group_index) - wrap = r'(%s)' - if ':' in field: - format = field[1:] - group = self._group_index - - # simplest case: no type specifier ({} or {name}) - if not format: - self._group_index += 1 - return wrap % r'.+?' - - # decode the format specification - format = extract_format(format, self._extra_types) - - # figure type conversions, if any - type = format['type'] - is_numeric = type and type in 'n%fegdobh' - if type in self._extra_types: - type_converter = self._extra_types[type] - s = getattr(type_converter, 'pattern', r'.+?') - regex_group_count = getattr(type_converter, 'regex_group_count', 0) - if regex_group_count is None: - regex_group_count = 0 - self._group_index += regex_group_count - - def f(string, m): - return type_converter(string) - self._type_conversions[group] = f - elif type == 'n': - s = r'\d{1,3}([,.]\d{3})*' - self._group_index += 1 - self._type_conversions[group] = int_convert(10) - elif type == 'b': - s = r'(0[bB])?[01]+' - self._type_conversions[group] = int_convert(2) - self._group_index += 1 - elif type == 'o': - s = r'(0[oO])?[0-7]+' - self._type_conversions[group] = int_convert(8) - self._group_index += 1 - elif type == 'x': - s = r'(0[xX])?[0-9a-fA-F]+' - self._type_conversions[group] = int_convert(16) - self._group_index += 1 - elif type == '%': - s = r'\d+(\.\d+)?%' - self._group_index += 1 - self._type_conversions[group] = percentage - elif type == 'f': - s = r'\d+\.\d+' - self._type_conversions[group] = lambda s, m: float(s) - elif type == 'F': - s = r'\d+\.\d+' - self._type_conversions[group] = lambda s, m: Decimal(s) - elif type == 'e': - s = r'\d+\.\d+[eE][-+]?\d+|nan|NAN|[-+]?inf|[-+]?INF' - self._type_conversions[group] = lambda s, m: float(s) - elif type == 'g': - s = r'\d+(\.\d+)?([eE][-+]?\d+)?|nan|NAN|[-+]?inf|[-+]?INF' - self._group_index += 2 - self._type_conversions[group] = lambda s, m: float(s) - elif type == 'd': - if format.get('width'): - width = r'{1,%s}' % int(format['width']) - else: - width = '+' - s = r'\d{w}|0[xX][0-9a-fA-F]{w}|0[bB][01]{w}|0[oO][0-7]{w}'.format(w=width) - self._type_conversions[group] = int_convert(10) - elif type == 'ti': - s = r'(\d{4}-\d\d-\d\d)((\s+|T)%s)?(Z|\s*[-+]\d\d:?\d\d)?' % \ - TIME_PAT - n = self._group_index - self._type_conversions[group] = partial(date_convert, ymd=n + 1, - hms=n + 4, tz=n + 7) - self._group_index += 7 - elif type == 'tg': - s = r'(\d{1,2}[-/](\d{1,2}|%s)[-/]\d{4})(\s+%s)?%s?%s?' % ( - ALL_MONTHS_PAT, TIME_PAT, AM_PAT, TZ_PAT) - n = self._group_index - self._type_conversions[group] = partial(date_convert, dmy=n + 1, - hms=n + 5, am=n + 8, tz=n + 9) - self._group_index += 9 - elif type == 'ta': - s = r'((\d{1,2}|%s)[-/]\d{1,2}[-/]\d{4})(\s+%s)?%s?%s?' % ( - ALL_MONTHS_PAT, TIME_PAT, AM_PAT, TZ_PAT) - n = self._group_index - self._type_conversions[group] = partial(date_convert, mdy=n + 1, - hms=n + 5, am=n + 8, tz=n + 9) - self._group_index += 9 - elif type == 'te': - # this will allow microseconds through if they're present, but meh - s = r'(%s,\s+)?(\d{1,2}\s+%s\s+\d{4})\s+%s%s' % (DAYS_PAT, - MONTHS_PAT, TIME_PAT, TZ_PAT) - n = self._group_index - self._type_conversions[group] = partial(date_convert, dmy=n + 3, - hms=n + 5, tz=n + 8) - self._group_index += 8 - elif type == 'th': - # slight flexibility here from the stock Apache format - s = r'(\d{1,2}[-/]%s[-/]\d{4}):%s%s' % (MONTHS_PAT, TIME_PAT, - TZ_PAT) - n = self._group_index - self._type_conversions[group] = partial(date_convert, dmy=n + 1, - hms=n + 3, tz=n + 6) - self._group_index += 6 - elif type == 'tc': - s = r'(%s)\s+%s\s+(\d{1,2})\s+%s\s+(\d{4})' % ( - DAYS_PAT, MONTHS_PAT, TIME_PAT) - n = self._group_index - self._type_conversions[group] = partial(date_convert, - d_m_y=(n + 4, n + 3, n + 8), hms=n + 5) - self._group_index += 8 - elif type == 'tt': - s = r'%s?%s?%s?' % (TIME_PAT, AM_PAT, TZ_PAT) - n = self._group_index - self._type_conversions[group] = partial(date_convert, hms=n + 1, - am=n + 4, tz=n + 5) - self._group_index += 5 - elif type == 'ts': - s = r'%s(\s+)(\d+)(\s+)(\d{1,2}:\d{1,2}:\d{1,2})?' % MONTHS_PAT - n = self._group_index - self._type_conversions[group] = partial(date_convert, mm=n+1, dd=n+3, - hms=n + 5) - self._group_index += 5 - elif type == 'l': - s = r'[A-Za-z]+' - elif type: - s = r'\%s+' % type - elif format.get('precision'): - if format.get('width'): - s = r'.{%s,%s}?' % (format['width'], format['precision']) - else: - s = r'.{1,%s}?' % format['precision'] - elif format.get('width'): - s = r'.{%s,}?' % format['width'] - else: - s = r'.+?' - - align = format['align'] - fill = format['fill'] - - # handle some numeric-specific things like fill and sign - if is_numeric: - # prefix with something (align "=" trumps zero) - if align == '=': - # special case - align "=" acts like the zero above but with - # configurable fill defaulting to "0" - if not fill: - fill = '0' - s = r'%s*' % fill + s - - # allow numbers to be prefixed with a sign - s = r'[-+ ]?' + s - - if not fill: - fill = ' ' - - # Place into a group now - this captures the value we want to keep. - # Everything else from now is just padding to be stripped off - if wrap: - s = wrap % s - self._group_index += 1 - - if format['width']: - # all we really care about is that if the format originally - # specified a width then there will probably be padding - without - # an explicit alignment that'll mean right alignment with spaces - # padding - if not align: - align = '>' - - if fill in r'.\+?*[](){}^$': - fill = '\\' + fill - - # align "=" has been handled - if align == '<': - s = '%s%s*' % (s, fill) - elif align == '>': - s = '%s*%s' % (fill, s) - elif align == '^': - s = '%s*%s%s*' % (fill, s, fill) - - return s - - -class Result(object): - '''The result of a parse() or search(). - - Fixed results may be looked up using `result[index]`. - - Named results may be looked up using `result['name']`. - - Named results may be tested for existence using `'name' in result`. - ''' - def __init__(self, fixed, named, spans): - self.fixed = fixed - self.named = named - self.spans = spans - - def __getitem__(self, item): - if isinstance(item, int): - return self.fixed[item] - return self.named[item] - - def __repr__(self): - return '<%s %r %r>' % (self.__class__.__name__, self.fixed, - self.named) - - def __contains__(self, name): - return name in self.named - - -class Match(object): - '''The result of a parse() or search() if no results are generated. - - This class is only used to expose internal used regex match objects - to the user and use them for external Parser.evaluate_result calls. - ''' - def __init__(self, parser, match): - self.parser = parser - self.match = match - - def evaluate_result(self): - '''Generate results for this Match''' - return self.parser.evaluate_result(self.match) - - -class ResultIterator(object): - '''The result of a findall() operation. - - Each element is a Result instance. - ''' - def __init__(self, parser, string, pos, endpos, evaluate_result=True): - self.parser = parser - self.string = string - self.pos = pos - self.endpos = endpos - self.evaluate_result = evaluate_result - - def __iter__(self): - return self - - def __next__(self): - m = self.parser._search_re.search(self.string, self.pos, self.endpos) - if m is None: - raise StopIteration() - self.pos = m.end() - - if self.evaluate_result: - return self.parser.evaluate_result(m) - else: - return Match(self.parser, m) - - # pre-py3k compat - next = __next__ - - -def parse(format, string, extra_types=None, evaluate_result=True, case_sensitive=False): - '''Using "format" attempt to pull values from "string". - - The format must match the string contents exactly. If the value - you're looking for is instead just a part of the string use - search(). - - If ``evaluate_result`` is True the return value will be an Result instance with two attributes: - - .fixed - tuple of fixed-position values from the string - .named - dict of named values from the string - - If ``evaluate_result`` is False the return value will be a Match instance with one method: - - .evaluate_result() - This will return a Result instance like you would get - with ``evaluate_result`` set to True - - The default behaviour is to match strings case insensitively. You may match with - case by specifying case_sensitive=True. - - If the format is invalid a ValueError will be raised. - - See the module documentation for the use of "extra_types". - - In the case there is no match parse() will return None. - ''' - p = Parser(format, extra_types=extra_types, case_sensitive=case_sensitive) - return p.parse(string, evaluate_result=evaluate_result) - - -def search(format, string, pos=0, endpos=None, extra_types=None, evaluate_result=True, - case_sensitive=False): - '''Search "string" for the first occurrence of "format". - - The format may occur anywhere within the string. If - instead you wish for the format to exactly match the string - use parse(). - - Optionally start the search at "pos" character index and limit the search - to a maximum index of endpos - equivalent to search(string[:endpos]). - - If ``evaluate_result`` is True the return value will be an Result instance with two attributes: - - .fixed - tuple of fixed-position values from the string - .named - dict of named values from the string - - If ``evaluate_result`` is False the return value will be a Match instance with one method: - - .evaluate_result() - This will return a Result instance like you would get - with ``evaluate_result`` set to True - - The default behaviour is to match strings case insensitively. You may match with - case by specifying case_sensitive=True. - - If the format is invalid a ValueError will be raised. - - See the module documentation for the use of "extra_types". - - In the case there is no match parse() will return None. - ''' - p = Parser(format, extra_types=extra_types, case_sensitive=case_sensitive) - return p.search(string, pos, endpos, evaluate_result=evaluate_result) - - -def findall(format, string, pos=0, endpos=None, extra_types=None, evaluate_result=True, - case_sensitive=False): - '''Search "string" for all occurrences of "format". - - You will be returned an iterator that holds Result instances - for each format match found. - - Optionally start the search at "pos" character index and limit the search - to a maximum index of endpos - equivalent to search(string[:endpos]). - - If ``evaluate_result`` is True each returned Result instance has two attributes: - - .fixed - tuple of fixed-position values from the string - .named - dict of named values from the string - - If ``evaluate_result`` is False each returned value is a Match instance with one method: - - .evaluate_result() - This will return a Result instance like you would get - with ``evaluate_result`` set to True - - The default behaviour is to match strings case insensitively. You may match with - case by specifying case_sensitive=True. - - If the format is invalid a ValueError will be raised. - - See the module documentation for the use of "extra_types". - ''' - p = Parser(format, extra_types=extra_types, case_sensitive=case_sensitive) - return Parser(format, extra_types=extra_types).findall(string, pos, endpos, evaluate_result=evaluate_result) - - -def compile(format, extra_types=None, case_sensitive=False): - '''Create a Parser instance to parse "format". - - The resultant Parser has a method .parse(string) which - behaves in the same manner as parse(format, string). - - The default behaviour is to match strings case insensitively. You may match with - case by specifying case_sensitive=True. - - Use this function if you intend to parse many strings - with the same format. - - See the module documentation for the use of "extra_types". - - Returns a Parser instance. - ''' - return Parser(format, extra_types=extra_types) - - -# Copyright (c) 2012-2019 Richard Jones -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -# vim: set filetype=python ts=4 sw=4 et si tw=75 diff --git a/devel/setup.cfg b/devel/setup.cfg deleted file mode 100644 index 3bf77b8..0000000 --- a/devel/setup.cfg +++ /dev/null @@ -1,8 +0,0 @@ -[flake8] -# flake8 is an automated code formatting pedant. -# Use it, please. -# -# python3 -m flake8 . -# -ignore = E501 -exclude = .git \ No newline at end of file diff --git a/devel/update-words.sh b/devel/update-words.sh deleted file mode 100755 index f64d1cb..0000000 --- a/devel/update-words.sh +++ /dev/null @@ -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} diff --git a/devel/validate.py b/devel/validate.py deleted file mode 100644 index d73dd89..0000000 --- a/devel/validate.py +++ /dev/null @@ -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() diff --git a/docs/administration.md b/docs/administration.md new file mode 100644 index 0000000..aa49c73 --- /dev/null +++ b/docs/administration.md @@ -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! diff --git a/docs/devel-server.md b/docs/devel-server.md deleted file mode 100644 index 2ae34c8..0000000 --- a/docs/devel-server.md +++ /dev/null @@ -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. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..cf7d0ba --- /dev/null +++ b/docs/development.md @@ -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. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..a1a9258 --- /dev/null +++ b/docs/getting-started.md @@ -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. diff --git a/docs/philosophy.md b/docs/philosophy.md index 5320580..5a8b107 100644 --- a/docs/philosophy.md +++ b/docs/philosophy.md @@ -1,7 +1,10 @@ Philosophy ========== -This is just some scattered thoughts by the architect, Neale. +Some scattered thoughts by the architect, Neale. + +Hardening +----------- People are going to try to break this thing. It needs to be bulletproof. @@ -10,23 +13,48 @@ This pretty much set the entire design: * As much as possible is done client-side * Participants can attack their own web browsers as much as they feel like * Also reduces server load - * We will help you create brute-force attacks! + * We even made a puzzle category to walk people through creating brute-force attacks! * Your laptop is faster than our server * We give you the carrot of hashed answers and the hashing function * This removes one incentive to DoS the server * Generate static content whenever possible - * Puzzles are statically compiled before the event even starts - * `points.json` and `puzzles.json` are generated and cached by a maintenance loop + * Puzzles must be statically compiled before the event even starts + * As much content as possible is generated by a maintenance loop * Minimize dynamic handling - * There are only two (2) dynamic handlers + * There are only three (3) dynamic handlers * team registration * answer validation + * server state (open puzzles + event log) * You can disable team registration if you want, just remove `teamids.txt` * I even removed token handling once I realized we replicate the user experience with the `answer` handler and some client-side JavaScript * As much as possible is read-only - * The only rw directory is `state` + * The only read-write directory is `state` + * This plays very well with Docker, which didn't exist when we designed MOTH * Server code should be as tiny as possible * Server should provide highly limited functionality * It should be easy to remember in your head everything it does * Server is also compiled * Static type-checking helps assure no run-time errors +* Server only tracks who scored how many points at what time + * This means the scoreboard program determines rankings + * Want to provide a time bonus for quick answers? I don't, but if you do, you can just modify the scoreboard to do so. + * Maybe you want to show a graph of team rankings over time: just replay the event log. + * Want to do some analysis of what puzzles take the longest to answer? It's all there. + +Fairness +--------- + +We spend a lot of time thinking about whether new content is going to feel fair. +Or, more importantly, if there's a possibility for it to be viewed as unfair. + +It's possible to run fun events that don't focus so much on fairness, +but those aren't the type of events we run. + +* People generally don't mind discovering that they could improve +* People can get furious if they feel like some system is unfairly targeting them +* Every team that does the same amount of work should have the same score + * No time bonuses / decaying points + * No penalties for trying things that don't work out +* No one should ever feel like it's impossible to catch up + * Achievements ("cheevos") work well here + * Time-based awards (flags) don't mesh with this idea diff --git a/docs/tokens.md b/docs/tokens.md index 848a069..8ea1f70 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -1,12 +1,42 @@ Tokens ====== -Tokens are good for a single point in a single category. They are -formed by prepending the category and a colon to the bubblebabble digest -of 3 random octets. A token for the "merfing" category might look like -this: +We used to use tokens extensively for categories outside of MOTH +(like scavenger hunts, Dirtbags Tanks, and other standalone stuff). - merfing:xunap-motex +We still occasionally pull out tokens to deal with oddball categories +that we want to score alongside MOTH categories. + +Here's how they work. + +Description +------------ + +Tokens are a 3-tuple: + +> (category, points, nonce) + +We build a mothball with nothing but `answers.txt`, +and a special 1-point puzzle that uses JavaScript to parse and submit tokens. + +Generally, tokens use colon separators, so they look like this: + + category:12:xunap-motex + +Uniqueness +-------- + +Because they work just like normal categories, +you can't have two distinct tokens worth the same number of points. + +When we need two or more tokens worth the same amount, +we make the point values very high, +so the least significant digit doesn't have much impact on the overall value. +For instance: + + category:1000001:xylep-nanox + category:1000002:xenod-relix + category:1000003:xoter-darox Entropy diff --git a/example-puzzles/example/1/puzzle.moth b/example-puzzles/example/1/puzzle.md similarity index 58% rename from example-puzzles/example/1/puzzle.moth rename to example-puzzles/example/1/puzzle.md index 3b9fc1b..928e312 100644 --- a/example-puzzles/example/1/puzzle.moth +++ b/example-puzzles/example/1/puzzle.md @@ -1,21 +1,26 @@ -Author: neale -Summary: static puzzles -Answer: puzzle.moth - +--- +pre: + authors: + - neale +debug: + summary: static puzzles +answers: + - puzzle.md +--- Puzzle categories are laid out on the filesystem: example/ ├─1 - │ └─puzzle.moth + │ └─puzzle.md ├─2 - │ ├─puzzle.moth + │ ├─puzzle.md │ └─salad.jpg ├─3 - │ └─puzzle.py + │ └─mkpuzzle ├─10 - │ └─puzzle.moth + │ └─puzzle.md └─100 - └─puzzle.py + └─mkpuzzle In this example, there are puzzles with point values 1, 2, 3, 10, and 100. @@ -24,17 +29,22 @@ Puzzles 1, 2, and 10 are "static" puzzles: their content was written by hand. Puzzles 3 and 100 are "dynamic" puzzles: -they are generated from a Python module. +their content is generated by `mkpuzzle`. To create a static puzzle, all you must have is a -`puzzle.moth` file in the puzzle's directory. +`puzzle.md` file in the puzzle's directory. This file is in the following format: - Author: [name of the person who wrote this puzzle] - Summary: [brief description of the puzzle] - Answer: [answer to this puzzle] - Answer: [second acceptable answer to this puzzle] - + --- + pre: + authors: + - name of the person who wrote this puzzle + debug: + summary: brief description of the puzzle + answers: + - answer to this puzzle + - second acceptable answer to this puzzle + --- This is the puzzle body. It is Markdown formatted: you can read more about Markdown on the Internet. diff --git a/example-puzzles/example/10/puzzle.moth b/example-puzzles/example/10/puzzle.md similarity index 96% rename from example-puzzles/example/10/puzzle.moth rename to example-puzzles/example/10/puzzle.md index 3d0e943..ce8e0bb 100644 --- a/example-puzzles/example/10/puzzle.moth +++ b/example-puzzles/example/10/puzzle.md @@ -1,6 +1,12 @@ -Author: neale -Summary: Making excellent puzzles -Answer: moo +--- +pre: + authors: + - neale +debug: + summary: Making excellent puzzles +answers: + - moo +--- Making Excellent Puzzles ==================== diff --git a/example-puzzles/example/100/puzzle.py b/example-puzzles/example/100/puzzle.py deleted file mode 100755 index 6b29a25..0000000 --- a/example-puzzles/example/100/puzzle.py +++ /dev/null @@ -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 escaped, 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)) - diff --git a/example-puzzles/example/2/puzzle.md b/example-puzzles/example/2/puzzle.md new file mode 100644 index 0000000..4836b3d --- /dev/null +++ b/example-puzzles/example/2/puzzle.md @@ -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. diff --git a/example-puzzles/example/2/puzzle.moth b/example-puzzles/example/2/puzzle.moth deleted file mode 100644 index 50d7918..0000000 --- a/example-puzzles/example/2/puzzle.moth +++ /dev/null @@ -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. diff --git a/example-puzzles/example/200/puzzle.py b/example-puzzles/example/200/puzzle.py deleted file mode 100755 index cfb9614..0000000 --- a/example-puzzles/example/200/puzzle.py +++ /dev/null @@ -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)) - diff --git a/example-puzzles/example/200/puzzlelib.py b/example-puzzles/example/200/puzzlelib.py deleted file mode 100644 index 566be76..0000000 --- a/example-puzzles/example/200/puzzlelib.py +++ /dev/null @@ -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 diff --git a/example-puzzles/example/3/mkpuzzle b/example-puzzles/example/3/mkpuzzle new file mode 100755 index 0000000..5863e13 --- /dev/null +++ b/example-puzzles/example/3/mkpuzzle @@ -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": ( + "

      Dynamic puzzles are provided with a JSON-generating mkpuzzles program in the puzzle directory.

      " + "

      You can write mkpuzzles in any language you like. This puzzle was written in Python 3.

      " + "

      Here is some salad:

      " + ), + "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]) diff --git a/example-puzzles/example/3/puzzle.py b/example-puzzles/example/3/puzzle.py deleted file mode 100644 index a5e93df..0000000 --- a/example-puzzles/example/3/puzzle.py +++ /dev/null @@ -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("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)) - diff --git a/example-puzzles/example/4/puzzle.moth b/example-puzzles/example/4/puzzle.moth deleted file mode 100644 index c06a653..0000000 --- a/example-puzzles/example/4/puzzle.moth +++ /dev/null @@ -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! diff --git a/example-puzzles/example/5/helpers.js b/example-puzzles/example/5/helpers.js index f8cf28a..9c2b65f 100644 --- a/example-puzzles/example/5/helpers.js +++ b/example-puzzles/example/5/helpers.js @@ -1,6 +1,6 @@ // jshint asi:true -function helperUpdateAnswer(event) { +async function helperUpdateAnswer(event) { let e = event.currentTarget let value = e.value let inputs = e.querySelectorAll("input") @@ -24,7 +24,11 @@ function helperUpdateAnswer(event) { if (join === undefined) { join = "," } - value = values.join(join) + if (values.length == 0) { + value = "None" + } else { + value = values.join(join) + } } // First make any adjustments to the value @@ -35,6 +39,35 @@ function helperUpdateAnswer(event) { value = value.toUpperCase() } + // "substrings" answers try all substrings. If any are the answer, they're filled in. + if (e.classList.contains("substring")) { + let validated = null + let anchorEnd = e.classList.contains("anchor-end") + let anchorBeg = e.classList.contains("anchor-beg") + + for (let end = 0; end <= value.length; end += 1) { + for (let beg = 0; beg < value.length; beg += 1) { + if (anchorEnd && (end != value.length)) { + continue + } + if (anchorBeg && (beg != 0)) { + continue + } + let sub = value.substring(beg, end) + if (await checkAnswer(sub)) { + validated = sub + } + } + } + + value = validated + } + + // If anything zeroed out value, don't update the answer field + if (!value) { + return + } + let answer = document.querySelector("#answer") answer.value = value answer.dispatchEvent(new InputEvent("input")) @@ -78,15 +111,16 @@ function helperActivate(e) { } } +{ + let init = function(event) { + for (let e of document.querySelectorAll(".answer")) { + helperActivate(e) + } + } -function helperInit(event) { - for (let e of document.querySelectorAll(".answer")) { - helperActivate(e) + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init) + } else { + init() } } - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", helperInit); -} else { - helperInit(); -} diff --git a/example-puzzles/example/5/puzzle.moth b/example-puzzles/example/5/puzzle.md similarity index 69% rename from example-puzzles/example/5/puzzle.moth rename to example-puzzles/example/5/puzzle.md index 1cbdffb..211ef51 100644 --- a/example-puzzles/example/5/puzzle.moth +++ b/example-puzzles/example/5/puzzle.md @@ -1,9 +1,15 @@ -Summary: Using JavaScript Input Helpers -Author: neale -Script: helpers.js -Script: draggable.js -Answer: helper - +--- +pre: + authors: + - neale + scripts: + - filename: helpers.js + - filename: draggable.js +answers: + - helper +debug: + summary: Using JavaScript Input Helpers +--- MOTH only takes static answers: you can't, for instance, write code to check answer correctness. But you can provide as many correct answers as you like in a single puzzle. @@ -18,7 +24,7 @@ This is just a demonstration page. You will probably only want one of these in a page, to avoid confusing people. -RFC3339 Timestamp +### RFC3339 Timestamp
      @@ -26,10 +32,10 @@ RFC3339 Timestamp
      -All lower-case letters +### All lower-case letters -Multiple concatenated values +### Multiple concatenated values
      @@ -37,22 +43,32 @@ Multiple concatenated values
      -Free input, sorted, concatenated values +### Free input, sorted, concatenated values
      -User-draggable values +### User-draggable values
      -Select from an ordered list of options +### Select from an ordered list of options
      • Horns
      • Hooves
      • Antlers
      + +### Substring matches +#### Any substring + + +#### Only if at the beginning + + +#### Only if at the end + diff --git a/example-puzzles/example/categorylib.py b/example-puzzles/example/categorylib.py deleted file mode 100644 index fb5a230..0000000 --- a/example-puzzles/example/categorylib.py +++ /dev/null @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f5692d5 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..516ffc9 --- /dev/null +++ b/go.sum @@ -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= diff --git a/pkg/award/award.go b/pkg/award/award.go new file mode 100644 index 0000000..f7600bd --- /dev/null +++ b/pkg/award/award.go @@ -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 +} diff --git a/pkg/award/award_test.go b/pkg/award/award_test.go new file mode 100644 index 0000000..d1ca716 --- /dev/null +++ b/pkg/award/award_test.go @@ -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") + } +} diff --git a/pkg/jsend/jsend.go b/pkg/jsend/jsend.go new file mode 100644 index 0000000..66dccaa --- /dev/null +++ b/pkg/jsend/jsend.go @@ -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) +} diff --git a/pkg/jsend/jsend_test.go b/pkg/jsend/jsend_test.go new file mode 100644 index 0000000..4f6b920 --- /dev/null +++ b/pkg/jsend/jsend_test.go @@ -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()) + } +} diff --git a/pkg/transpile/basepath.go b/pkg/transpile/basepath.go new file mode 100644 index 0000000..c40a3fd --- /dev/null +++ b/pkg/transpile/basepath.go @@ -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 +} diff --git a/pkg/transpile/category.go b/pkg/transpile/category.go new file mode 100644 index 0000000..887ae61 --- /dev/null +++ b/pkg/transpile/category.go @@ -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 +} diff --git a/pkg/transpile/category_test.go b/pkg/transpile/category_test.go new file mode 100644 index 0000000..80eac5a --- /dev/null +++ b/pkg/transpile/category_test.go @@ -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") + } +} diff --git a/pkg/transpile/common_test.go b/pkg/transpile/common_test.go new file mode 100644 index 0000000..c2c9d94 --- /dev/null +++ b/pkg/transpile/common_test.go @@ -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 +} diff --git a/pkg/transpile/inventory.go b/pkg/transpile/inventory.go new file mode 100644 index 0000000..48abe3f --- /dev/null +++ b/pkg/transpile/inventory.go @@ -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 +} diff --git a/pkg/transpile/inventory_test.go b/pkg/transpile/inventory_test.go new file mode 100644 index 0000000..a172b98 --- /dev/null +++ b/pkg/transpile/inventory_test.go @@ -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) + } +} diff --git a/pkg/transpile/mothball.go b/pkg/transpile/mothball.go new file mode 100644 index 0000000..b393802 --- /dev/null +++ b/pkg/transpile/mothball.go @@ -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 +} diff --git a/pkg/transpile/mothball_test.go b/pkg/transpile/mothball_test.go new file mode 100644 index 0000000..753d536 --- /dev/null +++ b/pkg/transpile/mothball_test.go @@ -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)) + } + } +} diff --git a/pkg/transpile/puzzle.go b/pkg/transpile/puzzle.go new file mode 100644 index 0000000..ff9c2e2 --- /dev/null +++ b/pkg/transpile/puzzle.go @@ -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 +} diff --git a/pkg/transpile/puzzle_test.go b/pkg/transpile/puzzle_test.go new file mode 100644 index 0000000..a6fc813 --- /dev/null +++ b/pkg/transpile/puzzle_test.go @@ -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 != "

      YAML body

      \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 != "

      RFC822 body

      \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") + } +} diff --git a/pkg/transpile/testdata/generated/mkcategory b/pkg/transpile/testdata/generated/mkcategory new file mode 100755 index 0000000..67d96f9 --- /dev/null +++ b/pkg/transpile/testdata/generated/mkcategory @@ -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 <moo.

" + } +} +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 diff --git a/pkg/transpile/testdata/static/1/puzzle.md b/pkg/transpile/testdata/static/1/puzzle.md new file mode 100644 index 0000000..3fdaeee --- /dev/null +++ b/pkg/transpile/testdata/static/1/puzzle.md @@ -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 +* + diff --git a/pkg/transpile/testdata/static/2/moo.js b/pkg/transpile/testdata/static/2/moo.js new file mode 100644 index 0000000..09de201 --- /dev/null +++ b/pkg/transpile/testdata/static/2/moo.js @@ -0,0 +1 @@ +console.log("Moo.") diff --git a/pkg/transpile/testdata/static/2/moo.txt b/pkg/transpile/testdata/static/2/moo.txt new file mode 100644 index 0000000..e31bd82 --- /dev/null +++ b/pkg/transpile/testdata/static/2/moo.txt @@ -0,0 +1 @@ +Moo. diff --git a/pkg/transpile/testdata/static/2/puzzle.md b/pkg/transpile/testdata/static/2/puzzle.md new file mode 100644 index 0000000..540b816 --- /dev/null +++ b/pkg/transpile/testdata/static/2/puzzle.md @@ -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 + diff --git a/pkg/transpile/testdata/static/3/mkpuzzle b/pkg/transpile/testdata/static/3/mkpuzzle new file mode 100755 index 0000000..8c28a89 --- /dev/null +++ b/pkg/transpile/testdata/static/3/mkpuzzle @@ -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 + \ No newline at end of file diff --git a/src/award.go b/src/award.go deleted file mode 100644 index e5dc17b..0000000 --- a/src/award.go +++ /dev/null @@ -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 -} diff --git a/src/award_test.go b/src/award_test.go deleted file mode 100644 index 9ac9097..0000000 --- a/src/award_test.go +++ /dev/null @@ -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") - } -} diff --git a/src/handlers.go b/src/handlers.go deleted file mode 100644 index 7b2d6d2..0000000 --- a/src/handlers.go +++ /dev/null @@ -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) -} diff --git a/src/instance.go b/src/instance.go deleted file mode 100644 index 4c5c876..0000000 --- a/src/instance.go +++ /dev/null @@ -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 -} diff --git a/src/instance_test.go b/src/instance_test.go deleted file mode 100644 index 06ab7d0..0000000 --- a/src/instance_test.go +++ /dev/null @@ -1 +0,0 @@ -package main diff --git a/src/maintenance.go b/src/maintenance.go deleted file mode 100644 index 829c2fc..0000000 --- a/src/maintenance.go +++ /dev/null @@ -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...") - } - } -} diff --git a/src/mothball.go b/src/mothball.go deleted file mode 100644 index c59c195..0000000 --- a/src/mothball.go +++ /dev/null @@ -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 -} diff --git a/src/mothball_test.go b/src/mothball_test.go deleted file mode 100644 index 8115809..0000000 --- a/src/mothball_test.go +++ /dev/null @@ -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 - } - -} diff --git a/src/mothd.go b/src/mothd.go deleted file mode 100644 index a61666e..0000000 --- a/src/mothd.go +++ /dev/null @@ -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)) -} diff --git a/theme/basic.css b/theme/basic.css index e7bb81e..14a5a1e 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -91,8 +91,15 @@ input:invalid { #devel { - background-color: #c88; + background-color: #eee; color: black; + overflow: scroll; +} +#devel .string { + color: #9c27b0; +} +#devel .body { + background-color: #ffc107; } .kvpair { border: solid black 2px; diff --git a/theme/index.html b/theme/index.html index f72f5e1..488e452 100644 --- a/theme/index.html +++ b/theme/index.html @@ -5,7 +5,6 @@ - diff --git a/theme/luna-moth.png b/theme/luna-moth.png new file mode 100644 index 0000000..0e34114 Binary files /dev/null and b/theme/luna-moth.png differ diff --git a/theme/luna-moth.svg b/theme/luna-moth.svg new file mode 100644 index 0000000..f44eaef --- /dev/null +++ b/theme/luna-moth.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/theme/moth-pwa.js b/theme/moth-pwa.js deleted file mode 100644 index 780bd1d..0000000 --- a/theme/moth-pwa.js +++ /dev/null @@ -1,17 +0,0 @@ -function pwa_init() { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.register("./sw.js").then(function(reg) { - }) - .catch(err => { - console.warn("Error while registering service worker", err) - }) - } else { - console.log("Service workers not supported. Some offline functionality may not work") - } -} - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", pwa_init) -} else { - pwa_init() -} diff --git a/theme/moth.js b/theme/moth.js index 41ed728..ccf2080 100644 --- a/theme/moth.js +++ b/theme/moth.js @@ -1,5 +1,6 @@ // jshint asi:true +var devel = false var teamId var heartbeatInterval = 40000 @@ -24,6 +25,8 @@ function renderNotices(obj) { function renderPuzzles(obj) { let puzzlesElement = document.createElement('div') + document.getElementById("login").style.display = "none" + // Create a sorted list of category names let cats = Object.keys(obj) cats.sort() @@ -42,7 +45,7 @@ function renderPuzzles(obj) { h.textContent = cat // Extras if we're running a devel server - if (obj.__devel__) { + if (devel) { let a = document.createElement('a') h.insertBefore(a, h.firstChild) a.textContent = "⬇️" @@ -55,8 +58,13 @@ function renderPuzzles(obj) { let l = document.createElement('ul') pdiv.appendChild(l) for (let puzzle of puzzles) { - let points = puzzle[0] - let id = puzzle[1] + let points = puzzle + let id = puzzle + + if (Array.isArray(puzzle)) { + points = puzzle[0] + id = puzzle[1] + } let i = document.createElement('li') l.appendChild(i) @@ -88,20 +96,26 @@ function renderPuzzles(obj) { container.appendChild(puzzlesElement) } - -function heartbeat(teamId, participantId) { - let noticesUrl = new URL("notices.html", window.location) - fetch(noticesUrl) - .then(resp => { - if (resp.ok) { - resp.text() - .then(renderNotices) - .catch(err => console.log) +function renderState(obj) { + devel = obj.Config.Devel + if (devel) { + let params = new URLSearchParams(window.location.search) + sessionStorage.id = "1" + sessionStorage.pid = "rodney" + } + if (Object.keys(obj.Puzzles).length > 0) { + renderPuzzles(obj.Puzzles) + if (obj.Config.Detachable) { + fetchAll(obj.Puzzles) } - }) - .catch(err => console.log) - - let url = new URL("puzzles.json", window.location) + } + renderNotices(obj.Messages) +} + +function heartbeat() { + let teamId = sessionStorage.id || "" + let participantId = sessionStorage.pid + let url = new URL("state", window.location) url.searchParams.set("id", teamId) if (participantId) { url.searchParams.set("pid", participantId) @@ -112,116 +126,49 @@ function heartbeat(teamId, participantId) { .then(resp => { if (resp.ok) { resp.json() - .then(renderPuzzles) + .then(renderState) .catch(err => { - toast("Error fetching recent puzzles. I'll try again in a moment.") + toast("Error fetching recent state. I'll try again in a moment.") console.log(err) }) } }) .catch(err => { - toast("Error fetching recent puzzles. I'll try again in a moment.") + toast("Error fetching recent state. I'll try again in a moment.") console.log(err) }) } -function showPuzzles(teamId, participantId) { +function showPuzzles() { let spinner = document.createElement("span") spinner.classList.add("spinner") - sessionStorage.setItem("id", teamId) - if (participantId) { - sessionStorage.setItem("pid", participantId) - } - document.getElementById("login").style.display = "none" document.getElementById("puzzles").appendChild(spinner) - heartbeat(teamId, participantId) - setInterval(e => { heartbeat(teamId) }, 40000) - drawCacheButton(teamId) } -function drawCacheButton(teamId) { - let cacher = document.querySelector("#cacheButton") +async function fetchAll(puzzles) { + let teamId = sessionStorage.id - function updateCacheButton() { - let headers = new Headers() - headers.append("pragma", "no-cache") - headers.append("cache-control", "no-cache") - let url = new URL("current_manifest.json", window.location) - url.searchParams.set("id", teamId) - fetch(url, {method: "HEAD", headers: headers}) - .then( resp => { - if (resp.ok) { - cacher.classList.remove("disabled") - } else { - cacher.classList.add("disabled") - } - }) - .catch(ex => { - cacher.classList.add("disabled") - }) - } + console.log("Caching all currently-open content") - setInterval (updateCacheButton , 30000) - updateCacheButton() -} - -async function fetchAll() { - let teamId = sessionStorage.getItem("id") - let headers = new Headers() - headers.append("pragma", "no-cache") - headers.append("cache-control", "no-cache") - requests = [] - let url = new URL("current_manifest.json", window.location) - url.searchParams.set("id", teamId) - - toast("Caching all currently-open content") - requests.push( fetch(url, {headers: headers}) - .then( resp => { - if (resp.ok) { - resp.json() - .then(contents => { - console.log("Processing manifest") - for (let resource of contents) { - if (resource == "puzzles.json") { - continue - } - fetch(resource) - .then(e => { - console.log("Fetched " + resource) - }) - } - }) - } - })) - - let resp = await fetch("puzzles.json?id=" + teamId, {headers: headers}) - - if (resp.ok) { - let categories = await resp.json() - let cat_names = Object.keys(categories) - cat_names.sort() - for (let cat_name of cat_names) { - if (cat_name.startsWith("__")) { - // Skip metadata + for (let cat in puzzles) { + for (let points of puzzles[cat]) { + let resp = await fetch(cat + "/" + points + "/") + if (! resp.ok) { continue } - let puzzles = categories[cat_name] - for (let puzzle of puzzles) { - let url = new URL("puzzle.html", window.location) - url.searchParams.set("cat", cat_name) - url.searchParams.set("points", puzzle[0]) - url.searchParams.set("pid", puzzle[1]) - requests.push( fetch(url) - .then(e => { - console.log("Fetched " + url) - })) + let obj = await resp.json() + for (let file of obj.files) { + fetch(cat + "/" + points + "/" + file.name) + } + for (let file of obj.scripts) { + fetch(cat + "/" + points + "/" + file.name) } } } - await Promise.all(requests) - toast("Done caching content") + + console.log("Done caching content") } function login(e) { @@ -229,7 +176,7 @@ function login(e) { let name = document.querySelector("[name=name]").value let teamId = document.querySelector("[name=id]").value let pide = document.querySelector("[name=pid]") - let participantId = pide?pide.value:"" + let participantId = pide?pide.value:Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) fetch("register", { method: "POST", @@ -239,12 +186,12 @@ function login(e) { if (resp.ok) { resp.json() .then(obj => { - if (obj.status == "success") { - toast("Team registered") - showPuzzles(teamId, participantId) - } else if (obj.data.short == "Already registered") { - toast("Logged in with previously-registered team name") - showPuzzles(teamId, participantId) + if ((obj.status == "success") || (obj.data.short == "Already registered")) { + toast("Logged in") + sessionStorage.id = teamId + sessionStorage.pid = participantId + showPuzzles() + heartbeat() } else { toast(obj.data.description) } @@ -265,12 +212,8 @@ function login(e) { } function init() { - // Already signed in? - let teamId = sessionStorage.getItem("id") - let participantId = sessionStorage.getItem("pid") - if (teamId) { - showPuzzles(teamId, participantId) - } + heartbeat() + setInterval(e => heartbeat(), 40000) document.getElementById("login").addEventListener("submit", login) } diff --git a/theme/puzzle.html b/theme/puzzle.html index a7be166..37206f6 100644 --- a/theme/puzzle.html +++ b/theme/puzzle.html @@ -5,7 +5,6 @@ - diff --git a/theme/scoreboard.js b/theme/scoreboard.js index 0c4d836..c5b64f1 100644 --- a/theme/scoreboard.js +++ b/theme/scoreboard.js @@ -18,20 +18,20 @@ function scoreboardInit() { } let element = document.getElementById("rankings") - let teamNames = state.teams - let pointsLog = state.points + let teamNames = state.TeamNames + let pointsLog = state.PointsLog // Every machine that's displaying the scoreboard helpfully stores the last 20 values of // points.json for us, in case of catastrophe. Thanks, y'all! // // We have been doing some variation on this "everybody backs up the server state" trick since 2009. // We have needed it 0 times. - let pointsHistory = JSON.parse(localStorage.getItem("pointsHistory")) || [] - if (pointsHistory.length >= 20) { - pointsHistory.shift() + let stateHistory = JSON.parse(localStorage.getItem("stateHistory")) || [] + if (stateHistory.length >= 20) { + stateHistory.shift() } - pointsHistory.push(pointsLog) - localStorage.setItem("pointsHistory", JSON.stringify(pointsHistory)) + stateHistory.push(state) + localStorage.setItem("stateHistory", JSON.stringify(stateHistory)) let teams = {} let highestCategoryScore = {} // map[string]int @@ -216,7 +216,7 @@ function scoreboardInit() { } function refresh() { - fetch("points.json") + fetch("state") .then(resp => { return resp.json() }) diff --git a/theme/sw.js b/theme/sw.js deleted file mode 100644 index 8bdb2b0..0000000 --- a/theme/sw.js +++ /dev/null @@ -1,49 +0,0 @@ -var cacheName = "moth:v1" -var content = [ - "index.html", - "basic.css", - "puzzle.js", - "puzzle.html", - "scoreboard.html", - "moth.js", - "sw.js", - "points.json", -] - -self.addEventListener("install", function(e) { - e.waitUntil( - caches.open(cacheName).then(function(cache) { - return cache.addAll(content).then( - function() { - self.skipWaiting() - }) - }) - ) -}) - -/* Attempt to fetch live resources, first, then fall back to cache */ -self.addEventListener('fetch', function(event) { - let cache_used = false - - event.respondWith( - fetch(event.request) - .catch(function(evt) { - //console.log("Falling back to cache for " + event.request.url) - cache_used = true - return caches.match(event.request, {ignoreSearch: true}) - }).then(function(res) { - if (res && res.ok) { - let res_clone = res.clone() - if (! cache_used && event.request.method == "GET" ) { - caches.open(cacheName).then(function(cache) { - cache.put(event.request, res_clone) - //console.log("Storing " + event.request.url + " in cache") - }) - } - return res - } else { - console.log("Failed to retrieve resource") - } - }) - ) -})