Merge branch 'libmoth' into github/fork/knewbetter/scoreboard-js-dependency-loading

This commit is contained in:
Neale Pickett 2023-09-27 17:15:37 -06:00
commit 34e51848be
57 changed files with 51629 additions and 1340 deletions

View File

@ -1,70 +0,0 @@
name: Build/Test/Push
on:
push:
branches:
- v3
- devel
- main
tags:
- 'v*.*.*'
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@v2
- name: Test
run: go test ./...
publish:
name: Publish container images
runs-on: ubuntu-latest
steps:
- name: Retrieve code
uses: actions/checkout@v2
- name: Gitlab variables
id: vars
run: build/ci/gitlab-vars
- name: Login to GitHub Packages Docker Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }}
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: neale
password: ${{ secrets.DOCKER_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
# Currently required, because buildx doesn't support auto-push from docker
- name: Set up builder
uses: docker/setup-buildx-action@v1
id: buildx
- name: Build and push moth image
uses: docker/build-push-action@v2
with:
builder: ${{ steps.buildx.outputs.name }}
target: moth
file: build/package/Containerfile
push: true
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
tags: |
dirtbags/moth:${{ steps.vars.outputs.tag }}
ghcr.io/dirtbags/moth:${{ steps.vars.outputs.tag }}

49
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,49 @@
stages:
- test
- push
Run unit tests:
stage: test
image: &goimage golang:1.21
only:
refs:
- main
- tags
- merge_requests
script:
- go test -coverprofile=coverage.txt -covermode=atomic -race ./...
- go tool cover -html=coverage.txt -o coverage.html
- go tool cover -func coverage.txt
coverage: /\(statements\)(?:\s+)?(\d+(?:\.\d+)?%)/
artifacts:
paths:
- coverage.html
- coverage.txt
Generage coverage XML:
stage: test
image: *goimage
needs: ["Run unit tests"]
script:
- go get github.com/boumenot/gocover-cobertura
- go run github.com/boumenot/gocover-cobertura < coverage.txt > coverage.xml
only:
refs:
- main
- tags
- merge_requests
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
push:
stage: push
needs: ["Run unit tests"]
rules:
- if: $CI_COMMIT_TAG
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
script:
- mkdir ~/.docker
- echo "$DOCKER_AUTH_CONFIG" | tee ~/.docker/config.json | md5sum
- sh build/ci/ci.sh publish

View File

@ -4,7 +4,60 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v4.2.1] - unreleased ## [v4.6.0] - unreleased
### Changed
- Answer hashes are now the first 4 characters of the hex-encoded SHA1 digest
- Reworked the built-in theme
- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript
- Devel mode no longer accepts an empty team ID
## [v4.4.9] - 2022-05-12
### Changed
- Added a performance optimization for events with a large number of teams
backed by NFS
## [v4.4.8] - 2022-05-10
### Changed
- You can now join with a team ID not appearing in `teamids.txt`,
as long as it is registered (in the `teams/` directory)
## [v4.4.7] - 2022-05-10
### Changed
- Initializing an instance now truncates `events.csv`
## [v4.4.6] - 2021-10-26
### Added
- State is now cached in memory, in an attempt to reduce filesystem metadata operations,
which kill NFS.
## [v4.4.5] - 2021-10-26
### Added
- Images deploying to docker hub too. We're now at capacity for our Docker Hub team.
## [v4.4.4] - 2021-10-20
### Changed
- Trying to get CI push of built images. I expect this to fail, too. But in a way that can help me debug the issue.
## [v4.3.3] - 2021-10-20
### Fixed
- Points awarded while scoring is paused are now correctly sorted (#168)
- Writing a new mothball with the same name is now detected and the new mothball loaded (#172)
- Regression test for issue where URL path leading directories were ignored (#144)
- A few other very minor bugs were closed when I couldn't reproduce them or decided they weren't actually bugs.
### Changed
- Many error messages were changed to start with a lower-case letter,
in order to satisfy a new linter check.
- CI/CD moved to our Cyber Fire Gitlab instance
- I attempted to have the build thingy automatically build moth:v4 and moth:v4.3 and moth:v4.3.3 images,
but I can't test it without tagging a release.
So v4.3.4 might come out very soon after this ;)
## [v4.2.2] - 2021-09-30
### Added
- `debug.notes` front matter field
## [v4.2.1] - 2021-04-13
### Fixed ### Fixed
- Transpiled KSAs no longer dropped - Transpiled KSAs no longer dropped

View File

@ -1,8 +1,7 @@
Dirtbags Monarch Of The Hill Server Dirtbags Monarch Of The Hill Server
===================== =====================
![Build badge](https://github.com/dirtbags/moth/workflows/Build/Test/Push/badge.svg) [![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)](https://goreportcard.com/report/github.com/dirtbags/moth)
![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)
Monarch Of The Hill (MOTH) is a puzzle server. Monarch Of The Hill (MOTH) is a puzzle server.
We (the authors) have used it for instructional and contest events called We (the authors) have used it for instructional and contest events called
@ -33,7 +32,7 @@ You can read more about why we made these decisions in [philosophy](docs/philoso
Run in demonstration mode Run in demonstration mode
=========== ===========
docker run --rm -it -p 8080:8080 dirtbags/moth-devel docker run --rm -it -p 8080:8080 ghcr.io/dirtbags/moth-devel
Then open http://localhost:8080/ and check out the example puzzles. Then open http://localhost:8080/ and check out the example puzzles.

51
build/ci/ci.sh Executable file
View File

@ -0,0 +1,51 @@
#! /bin/sh
set -e
images="ghcr.io/dirtbags/moth dirtbags/moth"
ACTION=$1
if [ -z "$ACTION" ]; then
echo "Usage: $0 ACTION"
exit 1
fi
log () {
printf "=== %s\n" "$*" 1>&2
}
fail () {
printf "\033[31;1m=== FAIL: %s\033[0m\n" "$*" 1>&2
exit 1
}
run () {
printf "\033[32m\$\033[0m %s\n" "$*" 1>&2
"$@"
}
tags () {
pfx=$1
for base in $images; do
echo $pfx $base:${CI_COMMIT_REF_NAME}
echo $pfx $base:${CI_COMMIT_REF_NAME%.*}
echo $pfx $base:${CI_COMMIT_REF_NAME%.*.*}
done | uniq
}
case $ACTION in
publish)
run docker build \
--file build/package/Containerfile \
$(tags --tag) \
.
tags | while read image; do
run docker push $image
done
;;
*)
echo "Unknown action: $1" 1>&2
exit 1
;;
esac

View File

@ -7,7 +7,7 @@ COPY example-puzzles /target/puzzles/
COPY LICENSE.md /target/ COPY LICENSE.md /target/
RUN mkdir -p /target/state RUN mkdir -p /target/state
WORKDIR /src/ WORKDIR /src/
RUN CGO_ENABLED=0 GOOS=linux go install -i -a -ldflags '-extldflags "-static"' ./... RUN CGO_ENABLED=0 GOOS=linux go install -a -ldflags '-extldflags "-static"' ./...
# I can't use /target/bin: doing so would cause the devel server to overwrite Ubuntu's /bin # I can't use /target/bin: doing so would cause the devel server to overwrite Ubuntu's /bin
########## ##########

View File

@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dirtbags/moth/pkg/jsend" "github.com/dirtbags/moth/v4/pkg/jsend"
) )
// HTTPServer is a MOTH HTTP server // HTTPServer is a MOTH HTTP server
@ -44,9 +44,8 @@ func (h *HTTPServer) HandleMothFunc(
mothHandler func(MothRequestHandler, http.ResponseWriter, *http.Request), mothHandler func(MothRequestHandler, http.ResponseWriter, *http.Request),
) { ) {
handler := func(w http.ResponseWriter, req *http.Request) { handler := func(w http.ResponseWriter, req *http.Request) {
participantID := req.FormValue("pid")
teamID := req.FormValue("id") teamID := req.FormValue("id")
mh := h.server.NewHandler(participantID, teamID) mh := h.server.NewHandler(teamID)
mothHandler(mh, w, req) mothHandler(mh, w, req)
} }
h.HandleFunc(h.base+pattern, handler) h.HandleFunc(h.base+pattern, handler)
@ -117,11 +116,11 @@ func (h *HTTPServer) RegisterHandler(mh MothRequestHandler, w http.ResponseWrite
} }
if err := mh.Register(teamName); err == ErrAlreadyRegistered { if err := mh.Register(teamName); err == ErrAlreadyRegistered {
jsend.Sendf(w, jsend.Success, "already registered", "Team ID has already been registered") jsend.Sendf(w, jsend.Success, "already registered", "team ID has already been registered")
} else if err != nil { } else if err != nil {
jsend.Sendf(w, jsend.Fail, "not registered", err.Error()) jsend.Sendf(w, jsend.Fail, "not registered", err.Error())
} else { } else {
jsend.Sendf(w, jsend.Success, "registered", "Team ID registered") jsend.Sendf(w, jsend.Success, "registered", "team ID registered")
} }
} }

View File

@ -7,21 +7,15 @@ import (
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"testing" "testing"
"time"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
const TestParticipantID = "shipox"
func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest.ResponseRecorder { func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest.ResponseRecorder {
vals := url.Values{} vals := url.Values{}
vals.Set("pid", TestParticipantID)
vals.Set("id", TestTeamID) vals.Set("id", TestTeamID)
if args != nil { for k, v := range args {
for k, v := range args { vals.Set(k, v)
vals.Set(k, v)
}
} }
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
@ -35,7 +29,8 @@ func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest
} }
func TestHttpd(t *testing.T) { func TestHttpd(t *testing.T) {
hs := NewHTTPServer("/", NewTestServer()) server := NewTestServer()
hs := NewHTTPServer("/", server.MothServer)
if r := hs.TestRequest("/", nil); r.Result().StatusCode != 200 { if r := hs.TestRequest("/", nil); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())
@ -56,22 +51,24 @@ func TestHttpd(t *testing.T) {
if r := hs.TestRequest("/register", map[string]string{"id": "bad team id", "name": "GoTeam"}); r.Result().StatusCode != 200 { if r := hs.TestRequest("/register", map[string]string{"id": "bad team id", "name": "GoTeam"}); r.Result().StatusCode != 200 {
t.Error(r.Result()) 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"}}` { } 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") t.Error("Register bad team ID failed")
} }
if r := hs.TestRequest("/register", map[string]string{"name": "GoTeam"}); r.Result().StatusCode != 200 { if r := hs.TestRequest("/register", map[string]string{"name": "GoTeam"}); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())
} else if r.Body.String() != `{"status":"success","data":{"short":"registered","description":"Team ID registered"}}` { } else if r.Body.String() != `{"status":"success","data":{"short":"registered","description":"team ID registered"}}` {
t.Error("Register failed") t.Error("Register failed")
} }
if r := hs.TestRequest("/register", map[string]string{"name": "GoTeam"}); r.Result().StatusCode != 200 { if r := hs.TestRequest("/register", map[string]string{"name": "GoTeam"}); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())
} else if r.Body.String() != `{"status":"success","data":{"short":"already registered","description":"Team ID has already been registered"}}` { } else if r.Body.String() != `{"status":"success","data":{"short":"already registered","description":"team ID has already been registered"}}` {
t.Error("Register failed", r.Body.String()) t.Error("Register failed", r.Body.String())
} }
server.refresh()
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 { if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())
} else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{"self":"GoTeam"},"PointsLog":[],"Puzzles":{"pategory":[1]}}` { } else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{"self":"GoTeam"},"PointsLog":[],"Puzzles":{"pategory":[1]}}` {
@ -102,7 +99,7 @@ func TestHttpd(t *testing.T) {
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "moo"}); r.Result().StatusCode != 200 { if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "moo"}); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"Incorrect answer"}}` { } else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"incorrect answer"}}` {
t.Error("Unexpected body", r.Body.String()) t.Error("Unexpected body", r.Body.String())
} }
@ -112,7 +109,7 @@ func TestHttpd(t *testing.T) {
t.Error("Unexpected body", r.Body.String()) t.Error("Unexpected body", r.Body.String())
} }
time.Sleep(TestMaintenanceInterval) server.refresh()
if r := hs.TestRequest("/content/pategory/2/puzzle.json", nil); r.Result().StatusCode != 200 { if r := hs.TestRequest("/content/pategory/2/puzzle.json", nil); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())
@ -124,14 +121,14 @@ func TestHttpd(t *testing.T) {
} else if err := json.Unmarshal(r.Body.Bytes(), &state); err != nil { } else if err := json.Unmarshal(r.Body.Bytes(), &state); err != nil {
t.Error(err) t.Error(err)
} else if len(state.PointsLog) != 1 { } else if len(state.PointsLog) != 1 {
t.Error("Points log wrong length") t.Errorf("Points log wrong length. Wanted 1, got %v (length %d)", state.PointsLog, len(state.PointsLog))
} else if len(state.Puzzles["pategory"]) != 2 { } else if len(state.Puzzles["pategory"]) != 2 {
t.Error("Didn't unlock next puzzle") 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 { if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
t.Error(r.Result()) 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"}}` { } else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"points already awarded to this team in this category"}}` {
t.Error("Unexpected body", r.Body.String()) t.Error("Unexpected body", r.Body.String())
} }
} }
@ -140,7 +137,7 @@ func TestDevelMemHttpd(t *testing.T) {
srv := NewTestServer() srv := NewTestServer()
{ {
hs := NewHTTPServer("/", srv) hs := NewHTTPServer("/", srv.MothServer)
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 404 { if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 404 {
t.Error("Should have gotten a 404 for mothballer in prod mode") t.Error("Should have gotten a 404 for mothballer in prod mode")
@ -149,7 +146,7 @@ func TestDevelMemHttpd(t *testing.T) {
{ {
srv.Config.Devel = true srv.Config.Devel = true
hs := NewHTTPServer("/", srv) hs := NewHTTPServer("/", srv.MothServer)
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 500 { if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 500 {
t.Log(r.Body.String()) t.Log(r.Body.String())

28
cmd/mothd/issues_test.go Normal file
View File

@ -0,0 +1,28 @@
package main
import (
"testing"
"github.com/spf13/afero"
)
func TestIssue156(t *testing.T) {
puzzles := NewTestMothballs()
state := NewTestState()
theme := NewTestTheme()
server := NewMothServer(Configuration{}, theme, state, puzzles)
afero.WriteFile(state, "teams/bloop", []byte("bloop: the team"), 0644)
state.refresh()
handler := server.NewHandler("bloop")
es := handler.ExportState()
if _, ok := es.TeamNames["self"]; !ok {
t.Fail()
}
err := handler.Register("bloop: the other team")
if err != ErrAlreadyRegistered {
t.Fail()
}
}

View File

@ -19,6 +19,7 @@ import (
type zipCategory struct { type zipCategory struct {
afero.Fs afero.Fs
io.Closer io.Closer
mtime time.Time
} }
// Mothballs provides a collection of active mothball files (puzzle categories) // Mothballs provides a collection of active mothball files (puzzle categories)
@ -48,7 +49,7 @@ func (m *Mothballs) getCat(cat string) (zipCategory, bool) {
func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) { func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) {
zc, ok := m.getCat(cat) zc, ok := m.getCat(cat)
if !ok { if !ok {
return nil, time.Time{}, fmt.Errorf("No such category: %s", cat) return nil, time.Time{}, fmt.Errorf("no such category: %s", cat)
} }
f, err := zc.Open(fmt.Sprintf("%d/%s", points, filename)) f, err := zc.Open(fmt.Sprintf("%d/%s", points, filename))
@ -91,12 +92,12 @@ func (m *Mothballs) Inventory() []Category {
func (m *Mothballs) CheckAnswer(cat string, points int, answer string) (bool, error) { func (m *Mothballs) CheckAnswer(cat string, points int, answer string) (bool, error) {
zfs, ok := m.getCat(cat) zfs, ok := m.getCat(cat)
if !ok { if !ok {
return false, fmt.Errorf("No such category: %s", cat) return false, fmt.Errorf("no such category: %s", cat)
} }
af, err := zfs.Open("answers.txt") af, err := zfs.Open("answers.txt")
if err != nil { if err != nil {
return false, fmt.Errorf("No answers.txt file") return false, fmt.Errorf("no answers.txt file")
} }
defer af.Close() defer af.Close()
@ -132,7 +133,18 @@ func (m *Mothballs) refresh() {
categoryName := strings.TrimSuffix(filename, ".mb") categoryName := strings.TrimSuffix(filename, ".mb")
found[categoryName] = true found[categoryName] = true
if _, ok := m.categories[categoryName]; !ok { reopen := false
if existingMothball, ok := m.categories[categoryName]; !ok {
reopen = true
} else if si, err := m.Fs.Stat(filename); err != nil {
log.Println(err)
} else if si.ModTime().After(existingMothball.mtime) {
existingMothball.Close()
delete(m.categories, categoryName)
reopen = true
}
if reopen {
f, err := m.Fs.Open(filename) f, err := m.Fs.Open(filename)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
@ -156,6 +168,7 @@ func (m *Mothballs) refresh() {
m.categories[categoryName] = zipCategory{ m.categories[categoryName] = zipCategory{
Fs: zipfs.New(zrc), Fs: zipfs.New(zrc),
Closer: f, Closer: f,
mtime: fi.ModTime(),
} }
log.Println("Adding category:", categoryName) log.Println("Adding category:", categoryName)
@ -174,7 +187,7 @@ func (m *Mothballs) refresh() {
// Mothball just returns an error // Mothball just returns an error
func (m *Mothballs) Mothball(cat string, w io.Writer) error { func (m *Mothballs) Mothball(cat string, w io.Writer) error {
return fmt.Errorf("Refusing to repackage a compiled mothball") return fmt.Errorf("refusing to repackage a compiled mothball")
} }
// Maintain performs housekeeping for Mothballs. // Maintain performs housekeeping for Mothballs.

View File

@ -3,25 +3,27 @@ package main
import ( import (
"archive/zip" "archive/zip"
"fmt" "fmt"
"io/ioutil"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
var testFiles = []struct { type testFileContents struct {
Name, Body string Name, Body string
}{ }
var testFiles = []testFileContents{
{"puzzles.txt", "1\n3\n2\n"}, {"puzzles.txt", "1\n3\n2\n"},
{"answers.txt", "1 answer123\n1 answer456\n2 wat\n"}, {"answers.txt", "1 answer123\n1 answer456\n2 wat\n"},
{"1/puzzle.json", `{"name": "moo"}`}, {"1/puzzle.json", `{"name": "moo"}`},
{"1/moo.txt", `moo`},
{"2/puzzle.json", `{}`}, {"2/puzzle.json", `{}`},
{"2/moo.txt", `moo`}, {"2/moo.txt", `moo`},
{"3/puzzle.json", `{}`}, {"3/puzzle.json", `{}`},
{"3/moo.txt", `moo`}, {"3/moo.txt", `moo`},
} }
func (m *Mothballs) createMothball(cat string) { func (m *Mothballs) createMothballWithFiles(cat string, contents []testFileContents) {
f, _ := m.Create(fmt.Sprintf("%s.mb", cat)) f, _ := m.Create(fmt.Sprintf("%s.mb", cat))
defer f.Close() defer f.Close()
@ -32,6 +34,19 @@ func (m *Mothballs) createMothball(cat string) {
of, _ := w.Create(file.Name) of, _ := w.Create(file.Name)
of.Write([]byte(file.Body)) of.Write([]byte(file.Body))
} }
for _, file := range contents {
of, _ := w.Create(file.Name)
of.Write([]byte(file.Body))
}
}
func (m *Mothballs) createMothball(cat string) {
m.createMothballWithFiles(
cat,
[]testFileContents{
{"1/moo.txt", "moo"},
},
)
} }
func NewTestMothballs() *Mothballs { func NewTestMothballs() *Mothballs {
@ -92,10 +107,27 @@ func TestMothballs(t *testing.T) {
} }
if ok, err := m.CheckAnswer("nealegory", 1, "moo"); ok { if ok, err := m.CheckAnswer("nealegory", 1, "moo"); ok {
t.Error("Checking answer in non-existent category should fail") t.Error("Checking answer in non-existent category should fail")
} else if err.Error() != "No such category: nealegory" { } else if err.Error() != "no such category: nealegory" {
t.Error("Wrong error message") t.Error("Wrong error message")
} }
goofyText := "bozonics"
//time.Sleep(1 * time.Second) // I don't love this, but we need the mtime to increase, and it's only accurate to 1s
m.createMothballWithFiles(
"pategory",
[]testFileContents{
{"1/moo.txt", goofyText},
},
)
m.refresh()
if f, _, err := m.Open("pategory", 1, "moo.txt"); err != nil {
t.Error("pategory/1/moo.txt", err)
} else if contents, err := ioutil.ReadAll(f); err != nil {
t.Error("read all pategory/1/moo.txt", err)
} else if string(contents) != goofyText {
t.Error("read all replacement pategory/1/moo.txt contents wrong, got", string(contents))
}
m.createMothball("test2") m.createMothball("test2")
m.Fs.Remove("pategory.mb") m.Fs.Remove("pategory.mb")
m.refresh() m.refresh()

View File

@ -15,7 +15,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dirtbags/moth/pkg/transpile" "github.com/dirtbags/moth/v4/pkg/transpile"
) )
// ProviderCommand specifies a command to run for the puzzle API // ProviderCommand specifies a command to run for the puzzle API
@ -125,7 +125,7 @@ func (pc ProviderCommand) CheckAnswer(cat string, points int, answer string) (bo
// Mothball just returns an error // Mothball just returns an error
func (pc ProviderCommand) Mothball(cat string) (*bytes.Reader, error) { func (pc ProviderCommand) Mothball(cat string) (*bytes.Reader, error) {
return nil, fmt.Errorf("Can't package a command-generated category") return nil, fmt.Errorf("can't package a command-generated category")
} }
// Maintain does nothing: a command puzzle ProviderCommand has no housekeeping // Maintain does nothing: a command puzzle ProviderCommand has no housekeeping

View File

@ -6,7 +6,7 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/dirtbags/moth/pkg/award" "github.com/dirtbags/moth/v4/pkg/award"
) )
// Category represents a puzzle category. // Category represents a puzzle category.
@ -58,7 +58,7 @@ type StateProvider interface {
TeamName(teamID string) (string, error) TeamName(teamID string) (string, error)
SetTeamName(teamID, teamName string) error SetTeamName(teamID, teamName string) error
AwardPoints(teamID string, cat string, points int) error AwardPoints(teamID string, cat string, points int) error
LogEvent(event, participantID, teamID, cat string, points int, extra ...string) LogEvent(event, teamID, cat string, points int, extra ...string)
Maintainer Maintainer
} }
@ -68,6 +68,9 @@ type Maintainer interface {
// It will only be called once, when execution begins. // It will only be called once, when execution begins.
// It's okay to just exit if there's no maintenance to be done. // It's okay to just exit if there's no maintenance to be done.
Maintain(updateInterval time.Duration) Maintain(updateInterval time.Duration)
// refresh is a shortcut used internally for testing
refresh()
} }
// MothServer gathers together the providers that make up a MOTH server. // MothServer gathers together the providers that make up a MOTH server.
@ -89,19 +92,17 @@ func NewMothServer(config Configuration, theme ThemeProvider, state StateProvide
} }
// NewHandler returns a new http.RequestHandler for the provided teamID. // NewHandler returns a new http.RequestHandler for the provided teamID.
func (s *MothServer) NewHandler(participantID, teamID string) MothRequestHandler { func (s *MothServer) NewHandler(teamID string) MothRequestHandler {
return MothRequestHandler{ return MothRequestHandler{
MothServer: s, MothServer: s,
participantID: participantID, teamID: teamID,
teamID: teamID,
} }
} }
// MothRequestHandler provides http.RequestHandler for a MothServer. // MothRequestHandler provides http.RequestHandler for a MothServer.
type MothRequestHandler struct { type MothRequestHandler struct {
*MothServer *MothServer
participantID string teamID string
teamID string
} }
// PuzzlesOpen opens a file associated with a puzzle. // PuzzlesOpen opens a file associated with a puzzle.
@ -115,7 +116,7 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (
} }
} }
if !found { if !found {
return nil, time.Time{}, fmt.Errorf("Puzzle does not exist or is locked") return nil, time.Time{}, fmt.Errorf("puzzle does not exist or is locked")
} }
// Try every provider until someone doesn't return an error // Try every provider until someone doesn't return an error
@ -128,7 +129,7 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (
// Log puzzle.json loads // Log puzzle.json loads
if path == "puzzle.json" { if path == "puzzle.json" {
mh.State.LogEvent("load", mh.participantID, mh.teamID, cat, points) mh.State.LogEvent("load", mh.teamID, cat, points)
} }
return return
@ -145,17 +146,17 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string)
} }
} }
if !correct { if !correct {
mh.State.LogEvent("wrong", mh.participantID, mh.teamID, cat, points) mh.State.LogEvent("wrong", mh.teamID, cat, points)
return fmt.Errorf("Incorrect answer") return fmt.Errorf("incorrect answer")
} }
mh.State.LogEvent("correct", mh.participantID, mh.teamID, cat, points) mh.State.LogEvent("correct", mh.teamID, cat, points)
if _, err := mh.State.TeamName(mh.teamID); err != nil { if _, err := mh.State.TeamName(mh.teamID); err != nil {
return fmt.Errorf("Invalid team ID") return fmt.Errorf("invalid team ID")
} }
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil { if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
return fmt.Errorf("Error awarding points: %s", err) return err
} }
return nil return nil
@ -168,11 +169,10 @@ func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time,
// Register associates a team name with a team ID. // Register associates a team name with a team ID.
func (mh *MothRequestHandler) Register(teamName string) error { 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 == "" { if teamName == "" {
return fmt.Errorf("Empty team name") return fmt.Errorf("empty team name")
} }
mh.State.LogEvent("register", mh.participantID, mh.teamID, "", 0) mh.State.LogEvent("register", mh.teamID, "", 0)
return mh.State.SetTeamName(mh.teamID, teamName) return mh.State.SetTeamName(mh.teamID, teamName)
} }
@ -184,12 +184,15 @@ func (mh *MothRequestHandler) ExportState() *StateExport {
return mh.exportStateIfRegistered(false) return mh.exportStateIfRegistered(false)
} }
func (mh *MothRequestHandler) exportStateIfRegistered(override bool) *StateExport { // Export state, replacing the team ID with "self" if the team is registered.
//
// If forceRegistered is true, go ahead and export it anyway
func (mh *MothRequestHandler) exportStateIfRegistered(forceRegistered bool) *StateExport {
export := StateExport{} export := StateExport{}
export.Config = mh.Config export.Config = mh.Config
teamName, err := mh.State.TeamName(mh.teamID) teamName, err := mh.State.TeamName(mh.teamID)
registered := override || mh.Config.Devel || (err == nil) registered := forceRegistered || mh.Config.Devel || (err == nil)
export.Messages = mh.State.Messages() export.Messages = mh.State.Messages()
export.TeamNames = make(map[string]string) export.TeamNames = make(map[string]string)
@ -254,7 +257,7 @@ func (mh *MothRequestHandler) Mothball(cat string, w io.Writer) error {
var err error var err error
if !mh.Config.Devel { if !mh.Config.Devel {
return fmt.Errorf("Cannot mothball in production mode") return fmt.Errorf("cannot mothball in production mode")
} }
for _, provider := range mh.PuzzleProviders { for _, provider := range mh.PuzzleProviders {
if err = provider.Mothball(cat, w); err == nil { if err = provider.Mothball(cat, w); err == nil {

View File

@ -3,34 +3,46 @@ package main
import ( import (
"io/ioutil" "io/ioutil"
"testing" "testing"
"time"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
const TestMaintenanceInterval = time.Millisecond * 1
const TestTeamID = "teamID" const TestTeamID = "teamID"
func NewTestServer() *MothServer { type TestServer struct {
*MothServer
}
// NewTestServer creates a new MothServer with NewTestMothballs and some initial state.
//
// See function definition for details.
func NewTestServer() TestServer {
puzzles := NewTestMothballs() puzzles := NewTestMothballs()
go puzzles.Maintain(TestMaintenanceInterval) puzzles.refresh()
state := NewTestState() state := NewTestState()
afero.WriteFile(state, "teamids.txt", []byte("teamID\n"), 0644) afero.WriteFile(state, "teamids.txt", []byte("teamID\n"), 0644)
afero.WriteFile(state, "messages.html", []byte("messages.html"), 0644) afero.WriteFile(state, "messages.html", []byte("messages.html"), 0644)
go state.Maintain(TestMaintenanceInterval) state.refresh()
theme := NewTestTheme() theme := NewTestTheme()
afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644) afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644)
go theme.Maintain(TestMaintenanceInterval)
return NewMothServer(Configuration{}, theme, state, puzzles) return TestServer{NewMothServer(Configuration{}, theme, state, puzzles)}
}
func (ts TestServer) refresh() {
ts.State.(*State).refresh()
for _, pp := range ts.PuzzleProviders {
pp.(*Mothballs).refresh()
}
ts.Theme.(*Theme).refresh()
} }
func TestDevelServer(t *testing.T) { func TestDevelServer(t *testing.T) {
server := NewTestServer() server := NewTestServer()
server.Config.Devel = true server.Config.Devel = true
anonHandler := server.NewHandler("badParticipantId", "badTeamId") anonHandler := server.NewHandler("badTeamId")
{ {
es := anonHandler.ExportState() es := anonHandler.ExportState()
@ -45,12 +57,11 @@ func TestDevelServer(t *testing.T) {
func TestProdServer(t *testing.T) { func TestProdServer(t *testing.T) {
teamName := "OurTeam" teamName := "OurTeam"
participantID := "participantID"
teamID := TestTeamID teamID := TestTeamID
server := NewTestServer() server := NewTestServer()
handler := server.NewHandler(participantID, teamID) handler := server.NewHandler(teamID)
anonHandler := server.NewHandler("badParticipantId", "badTeamId") anonHandler := server.NewHandler("badTeamId")
{ {
es := handler.ExportState() es := handler.ExportState()
@ -80,13 +91,15 @@ func TestProdServer(t *testing.T) {
t.Error("index.html wrong contents", contents) t.Error("index.html wrong contents", contents)
} }
server.refresh()
{ {
es := handler.ExportState() es := handler.ExportState()
if es.Config.Devel { if es.Config.Devel {
t.Error("Marked as development server", es.Config) t.Error("Marked as development server", es.Config)
} }
if len(es.Puzzles) != 1 { if len(es.Puzzles) != 1 {
t.Error("Puzzle categories wrong length") t.Error("Puzzle categories wrong length", len(es.Puzzles))
} }
if es.Messages != "messages.html" { if es.Messages != "messages.html" {
t.Error("Messages has wrong contents") t.Error("Messages has wrong contents")
@ -131,7 +144,7 @@ func TestProdServer(t *testing.T) {
t.Error("Right answer marked wrong", err) t.Error("Right answer marked wrong", err)
} }
time.Sleep(TestMaintenanceInterval) server.refresh()
{ {
es := handler.ExportState() es := handler.ExportState()
@ -160,7 +173,7 @@ func TestProdServer(t *testing.T) {
t.Error("Right answer marked wrong:", err) t.Error("Right answer marked wrong:", err)
} }
time.Sleep(TestMaintenanceInterval) server.refresh()
{ {
es := anonHandler.ExportState() es := anonHandler.ExportState()
@ -168,9 +181,8 @@ func TestProdServer(t *testing.T) {
t.Error("Anonymous TeamNames is wrong:", es.TeamNames) t.Error("Anonymous TeamNames is wrong:", es.TeamNames)
} }
if len(es.PointsLog) != 2 { if len(es.PointsLog) != 2 {
t.Error("Points log wrong length") t.Errorf("Points log wrong length: got %d, wanted 2", len(es.PointsLog))
} } else if es.PointsLog[1].TeamID != "0" {
if es.PointsLog[1].TeamID != "0" {
t.Error("Second point log didn't anonymize team ID correctly:", es.PointsLog[1]) t.Error("Second point log didn't anonymize team ID correctly:", es.PointsLog[1])
} }
} }

View File

@ -11,9 +11,10 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/dirtbags/moth/pkg/award" "github.com/dirtbags/moth/v4/pkg/award"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -27,7 +28,7 @@ const DistinguishableChars = "34678abcdefhikmnpqrtwxy="
const RFC3339Space = "2006-01-02 15:04:05Z07:00" const RFC3339Space = "2006-01-02 15:04:05Z07:00"
// ErrAlreadyRegistered means a team cannot be registered because it was registered previously. // ErrAlreadyRegistered means a team cannot be registered because it was registered previously.
var ErrAlreadyRegistered = errors.New("Team ID has already been registered") var ErrAlreadyRegistered = errors.New("team ID has already been registered")
// State defines the current state of a MOTH instance. // State defines the current state of a MOTH instance.
// We use the filesystem for synchronization between threads. // We use the filesystem for synchronization between threads.
@ -38,10 +39,18 @@ type State struct {
// Enabled tracks whether the current State system is processing updates // Enabled tracks whether the current State system is processing updates
Enabled bool Enabled bool
enabledWhy string
refreshNow chan bool refreshNow chan bool
eventStream chan []string eventStream chan []string
eventWriter *csv.Writer eventWriter *csv.Writer
eventWriterFile afero.File eventWriterFile afero.File
// Caches, so we're not hammering NFS with metadata operations
teamNamesLastChange time.Time
teamNames map[string]string
pointsLog award.List
messages string
lock sync.RWMutex
} }
// NewState returns a new State struct backed by the given Fs // NewState returns a new State struct backed by the given Fs
@ -51,6 +60,8 @@ func NewState(fs afero.Fs) *State {
Enabled: true, Enabled: true,
refreshNow: make(chan bool, 5), refreshNow: make(chan bool, 5),
eventStream: make(chan []string, 80), eventStream: make(chan []string, 80),
teamNames: make(map[string]string),
} }
if err := s.reopenEventLog(); err != nil { if err := s.reopenEventLog(); err != nil {
log.Fatal(err) log.Fatal(err)
@ -61,11 +72,10 @@ func NewState(fs afero.Fs) *State {
// updateEnabled checks a few things to see if this state directory is "enabled". // updateEnabled checks a few things to see if this state directory is "enabled".
func (s *State) updateEnabled() { func (s *State) updateEnabled() {
nextEnabled := true nextEnabled := true
why := "`state/enabled` present, `state/hours.txt` missing" why := "state/hours.txt has no timestamps before now"
if untilFile, err := s.Open("hours.txt"); err == nil { if untilFile, err := s.Open("hours.txt"); err == nil {
defer untilFile.Close() defer untilFile.Close()
why = "`state/hours.txt` present"
scanner := bufio.NewScanner(untilFile) scanner := bufio.NewScanner(untilFile)
for scanner.Scan() { for scanner.Scan() {
@ -85,59 +95,64 @@ func (s *State) updateEnabled() {
case '#': case '#':
continue continue
default: default:
log.Println("Misformatted line in hours.txt file") log.Println("state/hours.txt has bad line:", line)
} }
line, _, _ = strings.Cut(line, "#") // Remove inline comments
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
until, err := time.Parse(time.RFC3339, line) until := time.Time{}
if err != nil { if len(line) == 0 {
until, err = time.Parse(RFC3339Space, line) // Let it stay as zero time, so it's always before now
} } else if until, err = time.Parse(time.RFC3339, line); err == nil {
if err != nil { // Great, it was RFC 3339
log.Println("Suspended: Unparseable until date:", line) } else if until, err = time.Parse(RFC3339Space, line); err == nil {
// Great, it was RFC 3339 with a space instead of a 'T'
} else {
log.Println("state/hours.txt has bad timestamp:", line)
continue continue
} }
if until.Before(time.Now()) { if until.Before(time.Now()) {
nextEnabled = thisEnabled nextEnabled = thisEnabled
why = fmt.Sprint("state/hours.txt most recent timestamp:", line)
} }
} }
} }
if _, err := s.Stat("enabled"); os.IsNotExist(err) { if (nextEnabled != s.Enabled) || (why != s.enabledWhy) {
nextEnabled = false
why = "`state/enabled` missing"
}
if nextEnabled != s.Enabled {
s.Enabled = nextEnabled s.Enabled = nextEnabled
log.Printf("Setting enabled=%v: %s", s.Enabled, why) s.enabledWhy = why
log.Printf("Setting enabled=%v: %s", s.Enabled, s.enabledWhy)
if s.Enabled { if s.Enabled {
s.LogEvent("enabled", "", "", "", 0, why) s.LogEvent("enabled", "", "", 0, s.enabledWhy)
} else { } else {
s.LogEvent("disabled", "", "", "", 0, why) s.LogEvent("disabled", "", "", 0, s.enabledWhy)
} }
} }
} }
// TeamName returns team name given a team ID. // TeamName returns team name given a team ID.
func (s *State) TeamName(teamID string) (string, error) { func (s *State) TeamName(teamID string) (string, error) {
teamFs := afero.NewBasePathFs(s.Fs, "teams") s.lock.RLock()
teamNameBytes, err := afero.ReadFile(teamFs, teamID) name, ok := s.teamNames[teamID]
if os.IsNotExist(err) { s.lock.RUnlock()
return "", fmt.Errorf("Unregistered team ID: %s", teamID) if !ok {
} else if err != nil { return "", fmt.Errorf("unregistered team ID: %s", teamID)
return "", fmt.Errorf("Unregistered team ID: %s (%s)", teamID, err)
} }
return name, nil
teamName := strings.TrimSpace(string(teamNameBytes))
return teamName, nil
} }
// SetTeamName writes out team name. // SetTeamName writes out team name.
// This can only be done once per team. // This can only be done once per team.
func (s *State) SetTeamName(teamID, teamName string) error { func (s *State) SetTeamName(teamID, teamName string) error {
s.lock.RLock()
_, ok := s.teamNames[teamID]
s.lock.RUnlock()
if ok {
return ErrAlreadyRegistered
}
idsFile, err := s.Open("teamids.txt") idsFile, err := s.Open("teamids.txt")
if err != nil { if err != nil {
return fmt.Errorf("Team IDs file does not exist") return fmt.Errorf("team IDs file does not exist")
} }
defer idsFile.Close() defer idsFile.Close()
found := false found := false
@ -149,7 +164,7 @@ func (s *State) SetTeamName(teamID, teamName string) error {
} }
} }
if !found { if !found {
return fmt.Errorf("Team ID not found in list of valid Team IDs") return fmt.Errorf("team ID not found in list of valid team IDs")
} }
teamFilename := filepath.Join("teams", teamID) teamFilename := filepath.Join("teams", teamID)
@ -163,36 +178,26 @@ func (s *State) SetTeamName(teamID, teamName string) error {
log.Printf("Setting team name [%s] in file %s", teamName, teamFilename) log.Printf("Setting team name [%s] in file %s", teamName, teamFilename)
fmt.Fprintln(teamFile, teamName) fmt.Fprintln(teamFile, teamName)
teamFile.Close() teamFile.Close()
s.refreshNow <- true
return nil return nil
} }
// PointsLog retrieves the current points log. // PointsLog retrieves the current points log.
func (s *State) PointsLog() award.List { func (s *State) PointsLog() award.List {
f, err := s.Open("points.log") s.lock.RLock()
if err != nil { ret := make(award.List, len(s.pointsLog))
log.Println(err) copy(ret, s.pointsLog)
return nil s.lock.RUnlock()
} return ret
defer f.Close()
pointsLog := make(award.List, 0, 200)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
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. // Messages retrieves the current messages.
func (s *State) Messages() string { func (s *State) Messages() string {
bMessages, _ := afero.ReadFile(s, "messages.html") s.lock.RLock() // It's not clear to me that this actually needs to happen
return string(bMessages) defer s.lock.RUnlock()
return s.messages
} }
// AwardPoints gives points to teamID in category. // AwardPoints gives points to teamID in category.
@ -202,8 +207,12 @@ func (s *State) Messages() string {
// It's just a courtesy to the user. // It's just a courtesy to the user.
// The update task makes sure we never have duplicate points in the log. // The update task makes sure we never have duplicate points in the log.
func (s *State) AwardPoints(teamID, category string, points int) error { func (s *State) AwardPoints(teamID, category string, points int) error {
return s.awardPointsAtTime(time.Now().Unix(), teamID, category, points)
}
func (s *State) awardPointsAtTime(when int64, teamID string, category string, points int) error {
a := award.T{ a := award.T{
When: time.Now().Unix(), When: when,
TeamID: teamID, TeamID: teamID,
Category: category, Category: category,
Points: points, Points: points,
@ -211,11 +220,12 @@ func (s *State) AwardPoints(teamID, category string, points int) error {
for _, e := range s.PointsLog() { for _, e := range s.PointsLog() {
if a.Equal(e) { if a.Equal(e) {
return fmt.Errorf("Points already awarded to this team in this category") return fmt.Errorf("points already awarded to this team in this category")
} }
} }
fn := fmt.Sprintf("%s-%s-%d", teamID, category, points) //fn := fmt.Sprintf("%s-%s-%d", a.TeamID, a.Category, a.Points)
fn := a.Filename()
tmpfn := filepath.Join("points.tmp", fn) tmpfn := filepath.Join("points.tmp", fn)
newfn := filepath.Join("points.new", fn) newfn := filepath.Join("points.new", fn)
@ -255,12 +265,14 @@ func (s *State) collectPoints() {
} }
duplicate := false duplicate := false
for _, e := range s.PointsLog() { s.lock.RLock()
for _, e := range s.pointsLog {
if awd.Equal(e) { if awd.Equal(e) {
duplicate = true duplicate = true
break break
} }
} }
s.lock.RUnlock()
if duplicate { if duplicate {
log.Print("Skipping duplicate points: ", awd.String()) log.Print("Skipping duplicate points: ", awd.String())
@ -274,6 +286,11 @@ func (s *State) collectPoints() {
} }
fmt.Fprintln(logf, awd.String()) fmt.Fprintln(logf, awd.String())
logf.Close() logf.Close()
// Stick this on the cache too
s.lock.Lock()
s.pointsLog = append(s.pointsLog, awd)
s.lock.Unlock()
} }
if err := s.Remove(filename); err != nil { if err := s.Remove(filename); err != nil {
@ -295,6 +312,7 @@ func (s *State) maybeInitialize() {
s.Remove("enabled") s.Remove("enabled")
s.Remove("hours.txt") s.Remove("hours.txt")
s.Remove("points.log") s.Remove("points.log")
s.Remove("events.csv")
s.Remove("messages.html") s.Remove("messages.html")
s.Remove("mothd.log") s.Remove("mothd.log")
s.RemoveAll("points.tmp") s.RemoveAll("points.tmp")
@ -305,7 +323,7 @@ func (s *State) maybeInitialize() {
if err := s.reopenEventLog(); err != nil { if err := s.reopenEventLog(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
s.LogEvent("init", "", "", "", 0) s.LogEvent("init", "", "", 0)
// Make sure various subdirectories exist // Make sure various subdirectories exist
s.Mkdir("points.tmp", 0755) s.Mkdir("points.tmp", 0755)
@ -333,21 +351,19 @@ func (s *State) maybeInitialize() {
f.Close() 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 { if f, err := s.Create("hours.txt"); err == nil {
fmt.Fprintln(f, "# hours.txt: when the contest is enabled") fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
fmt.Fprintln(f, "#") fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# Enable: + timestamp") fmt.Fprintln(f, "# Enable: + [timestamp]")
fmt.Fprintln(f, "# Disable: - timestamp") fmt.Fprintln(f, "# Disable: - [timestamp]")
fmt.Fprintln(f, "#") fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# You can have multiple start/stop times.") fmt.Fprintln(f, "# This file, and all files in this directory, are re-read periodically.")
fmt.Fprintln(f, "# Whatever time is the most recent, wins.") fmt.Fprintln(f, "# Default is enabled.")
fmt.Fprintln(f, "# Times in the future are ignored.") fmt.Fprintln(f, "# Rules with only '-' or '+' are also allowed.")
fmt.Fprintln(f, "# Rules apply from the top down.")
fmt.Fprintln(f, "# If you put something in out of order, it's going to be bonkers.")
fmt.Fprintln(f) fmt.Fprintln(f)
fmt.Fprintln(f, "- 1970-01-01T00:00:00Z")
fmt.Fprintln(f, "+", now) fmt.Fprintln(f, "+", now)
fmt.Fprintln(f, "- 2519-10-31T00:00:00Z") fmt.Fprintln(f, "- 2519-10-31T00:00:00Z")
f.Close() f.Close()
@ -363,20 +379,12 @@ func (s *State) maybeInitialize() {
} }
} }
func logstr(s string) string {
if s == "" {
return "-"
}
return s
}
// LogEvent writes to the event log // LogEvent writes to the event log
func (s *State) LogEvent(event, participantID, teamID, cat string, points int, extra ...string) { func (s *State) LogEvent(event, teamID, cat string, points int, extra ...string) {
s.eventStream <- append( s.eventStream <- append(
[]string{ []string{
strconv.FormatInt(time.Now().Unix(), 10), strconv.FormatInt(time.Now().Unix(), 10),
event, event,
participantID,
teamID, teamID,
cat, cat,
strconv.Itoa(points), strconv.Itoa(points),
@ -404,12 +412,72 @@ func (s *State) reopenEventLog() error {
return nil return nil
} }
func (s *State) updateCaches() {
s.lock.Lock()
defer s.lock.Unlock()
if f, err := s.Open("points.log"); err != nil {
log.Println(err)
} else {
defer f.Close()
pointsLog := make(award.List, 0, 200)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
cur, err := award.Parse(line)
if err != nil {
log.Printf("Skipping malformed award line %s: %s", line, err)
continue
}
pointsLog = append(pointsLog, cur)
}
s.pointsLog = pointsLog
}
// Only do this if the teams directory has a newer mtime; directories with
// hundreds of team names can cause NFS I/O storms
{
_, ismmfs := s.Fs.(*afero.MemMapFs) // Tests run so quickly that the time check isn't precise enough
if fi, err := s.Fs.Stat("teams"); err != nil {
log.Printf("Getting modification time of teams directory: %v", err)
} else if ismmfs || s.teamNamesLastChange.Before(fi.ModTime()) {
s.teamNamesLastChange = fi.ModTime()
// The compiler recognizes this as an optimization case
for k := range s.teamNames {
delete(s.teamNames, k)
}
teamsFs := afero.NewBasePathFs(s.Fs, "teams")
if dirents, err := afero.ReadDir(teamsFs, "."); err != nil {
log.Printf("Reading team ids: %v", err)
} else {
for _, dirent := range dirents {
teamID := dirent.Name()
if teamNameBytes, err := afero.ReadFile(teamsFs, teamID); err != nil {
log.Printf("Reading team %s: %v", teamID, err)
} else {
teamName := strings.TrimSpace(string(teamNameBytes))
s.teamNames[teamID] = teamName
}
}
}
}
}
if bMessages, err := afero.ReadFile(s, "messages.html"); err == nil {
s.messages = string(bMessages)
}
}
func (s *State) refresh() { func (s *State) refresh() {
s.maybeInitialize() s.maybeInitialize()
s.updateEnabled() s.updateEnabled()
if s.Enabled { if s.Enabled {
s.collectPoints() s.collectPoints()
} }
s.updateCaches()
} }
// Maintain performs housekeeping on a State struct. // Maintain performs housekeeping on a State struct.
@ -454,6 +522,9 @@ func (ds *DevelState) TeamName(teamID string) (string, error) {
if name, err := ds.StateProvider.TeamName(teamID); err == nil { if name, err := ds.StateProvider.TeamName(teamID); err == nil {
return name, nil return name, nil
} }
if teamID == "" {
return "", fmt.Errorf("Empty team ID")
}
return fmt.Sprintf("«devel:%s»", teamID), nil return fmt.Sprintf("«devel:%s»", teamID), nil
} }

View File

@ -17,8 +17,16 @@ func NewTestState() *State {
return s return s
} }
func slurp(c chan bool) {
for range c {
// Nothing
}
}
func TestState(t *testing.T) { func TestState(t *testing.T) {
s := NewTestState() s := NewTestState()
defer close(s.refreshNow)
go slurp(s.refreshNow)
mustExist := func(path string) { mustExist := func(path string) {
_, err := s.Fs.Stat(path) _, err := s.Fs.Stat(path)
@ -33,7 +41,6 @@ func TestState(t *testing.T) {
} }
mustExist("initialized") mustExist("initialized")
mustExist("enabled")
mustExist("hours.txt") mustExist("hours.txt")
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt") teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
@ -57,11 +64,12 @@ func TestState(t *testing.T) {
teamName := "My Team" teamName := "My Team"
if err := s.SetTeamName(teamID, teamName); err != nil { if err := s.SetTeamName(teamID, teamName); err != nil {
t.Errorf("Setting team name: %w", err) t.Errorf("Setting team name: %v", err)
} }
if err := s.SetTeamName(teamID, "wat"); err == nil { if err := s.SetTeamName(teamID, "wat"); err == nil {
t.Errorf("Registering team a second time didn't fail") t.Errorf("Registering team a second time didn't fail")
} }
s.refresh()
if name, err := s.TeamName(teamID); err != nil { if name, err := s.TeamName(teamID); err != nil {
t.Error(err) t.Error(err)
} else if name != teamName { } else if name != teamName {
@ -73,9 +81,6 @@ func TestState(t *testing.T) {
if err := s.AwardPoints(teamID, category, points); err != nil { if err := s.AwardPoints(teamID, category, points); err != nil {
t.Error(err) 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 // Flex duplicate detection with different timestamp
if f, err := s.Create("points.new/moo"); err != nil { if f, err := s.Create("points.new/moo"); err != nil {
t.Error("Creating duplicate points file:", err) t.Error("Creating duplicate points file:", err)
@ -83,24 +88,34 @@ func TestState(t *testing.T) {
fmt.Fprintln(f, time.Now().Unix()+1, teamID, category, points) fmt.Fprintln(f, time.Now().Unix()+1, teamID, category, points)
f.Close() f.Close()
} }
s.AwardPoints(teamID, category, points)
s.refresh() s.refresh()
pl = s.PointsLog()
if len(pl) != 1 {
for i, award := range pl {
t.Logf("pl[%d] == %s", i, award.String())
}
t.Errorf("After awarding duplicate 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)
}
if err := s.AwardPoints(teamID, category, points); err == nil { if err := s.AwardPoints(teamID, category, points); err == nil {
t.Error("Duplicate points award didn't fail") t.Error("Duplicate points award after refresh didn't fail")
} }
if err := s.AwardPoints(teamID, category, points+1); err != nil { if err := s.AwardPoints(teamID, category, points+1); err != nil {
t.Error("Awarding more points:", err) t.Error("Awarding more points:", err)
} }
pl = s.PointsLog() s.refresh()
if len(pl) != 1 { if len(s.PointsLog()) != 2 {
t.Errorf("After awarding points, points log has length %d", len(pl)) t.Errorf("There should be two awards")
} 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) afero.WriteFile(s, "points.log", []byte("intentional parse error\n"), 0644)
s.refresh()
if len(s.PointsLog()) != 0 { if len(s.PointsLog()) != 0 {
t.Errorf("Intentional parse error breaks pointslog") t.Errorf("Intentional parse error breaks pointslog")
} }
@ -108,7 +123,8 @@ func TestState(t *testing.T) {
t.Error(err) t.Error(err)
} }
s.refresh() s.refresh()
if len(s.PointsLog()) != 2 { if len(s.PointsLog()) != 1 {
t.Log(s.PointsLog())
t.Error("Intentional parse error screws up all parsing") t.Error("Intentional parse error screws up all parsing")
} }
@ -122,18 +138,45 @@ func TestState(t *testing.T) {
} }
// Out of order points insertion, issue #168
func TestStateOutOfOrderAward(t *testing.T) {
s := NewTestState()
category := "meow"
points := 100
now := time.Now().Unix()
if err := s.awardPointsAtTime(now+20, "AA", category, points); err != nil {
t.Error("Awarding points to team ZZ:", err)
}
if err := s.awardPointsAtTime(now+10, "ZZ", category, points); err != nil {
t.Error("Awarding points to team AA:", err)
}
s.refresh()
pl := s.PointsLog()
if len(pl) != 2 {
t.Error("Wrong length for points log")
}
if pl[0].TeamID != "ZZ" {
t.Error("Out of order points insertion not properly sorted in points log")
}
}
func TestStateEvents(t *testing.T) { func TestStateEvents(t *testing.T) {
s := NewTestState() s := NewTestState()
s.LogEvent("moo", "", "", "", 0) s.LogEvent("moo", "", "", 0)
s.LogEvent("moo 2", "", "", "", 0) s.LogEvent("moo 2", "", "", 0)
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "init::::0" { if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "init:::0" {
t.Error("Wrong message from event stream:", msg) t.Error("Wrong message from event stream:", msg)
} }
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo::::0" { if msg := <-s.eventStream; !strings.HasPrefix(msg[5], "state/hours.txt") {
t.Error("Wrong message from event stream:", msg[5])
}
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo:::0" {
t.Error("Wrong message from event stream:", msg) t.Error("Wrong message from event stream:", msg)
} }
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo 2::::0" { if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo 2:::0" {
t.Error("Wrong message from event stream:", msg) t.Error("Wrong message from event stream:", msg)
} }
} }
@ -151,19 +194,37 @@ func TestStateDisabled(t *testing.T) {
t.Error(err) t.Error(err)
} }
defer hoursFile.Close() defer hoursFile.Close()
s.refresh()
if !s.Enabled {
t.Error("Empty hours.txt not enabled")
}
fmt.Fprintln(hoursFile, "- 1970-01-01T01:01:01Z") fmt.Fprintln(hoursFile, "- 1970-01-01T01:01:01Z")
hoursFile.Sync() hoursFile.Sync()
s.refresh() s.refresh()
if s.Enabled { if s.Enabled {
t.Error("Disabling 1970-01-01") t.Error("1970-01-01")
} }
fmt.Fprintln(hoursFile, "+ 1970-01-01 01:01:01+05:00") fmt.Fprintln(hoursFile, "+ 1970-01-02 01:01:01+05:00")
hoursFile.Sync() hoursFile.Sync()
s.refresh() s.refresh()
if !s.Enabled { if !s.Enabled {
t.Error("Enabling 1970-01-02") t.Error("1970-01-02")
}
fmt.Fprintln(hoursFile, "-")
hoursFile.Sync()
s.refresh()
if s.Enabled {
t.Error("bare -")
}
fmt.Fprintln(hoursFile, "+")
hoursFile.Sync()
s.refresh()
if !s.Enabled {
t.Error("bare +")
} }
fmt.Fprintln(hoursFile, "") fmt.Fprintln(hoursFile, "")
@ -171,7 +232,7 @@ func TestStateDisabled(t *testing.T) {
hoursFile.Sync() hoursFile.Sync()
s.refresh() s.refresh()
if !s.Enabled { if !s.Enabled {
t.Error("Comments") t.Error("Comment")
} }
fmt.Fprintln(hoursFile, "intentional parse error") fmt.Fprintln(hoursFile, "intentional parse error")
@ -185,7 +246,7 @@ func TestStateDisabled(t *testing.T) {
hoursFile.Sync() hoursFile.Sync()
s.refresh() s.refresh()
if s.Enabled { if s.Enabled {
t.Error("Disabling 1980-01-01") t.Error("1980-01-01")
} }
if err := s.Remove("hours.txt"); err != nil { if err := s.Remove("hours.txt"); err != nil {
@ -196,14 +257,6 @@ func TestStateDisabled(t *testing.T) {
t.Error("Removing `hours.txt` disabled event") 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.Remove("initialized")
s.refresh() s.refresh()
if !s.Enabled { if !s.Enabled {
@ -233,7 +286,7 @@ func TestStateMaintainer(t *testing.T) {
t.Error("Team ID too short:", teamID) t.Error("Team ID too short:", teamID)
} }
s.LogEvent("Hello!", "", "", "", 0) s.LogEvent("Hello!", "", "", 0)
if len(s.PointsLog()) != 0 { if len(s.PointsLog()) != 0 {
t.Error("Points log is not empty") t.Error("Points log is not empty")
@ -258,11 +311,11 @@ func TestStateMaintainer(t *testing.T) {
eventLog, err := afero.ReadFile(s.Fs, "events.csv") eventLog, err := afero.ReadFile(s.Fs, "events.csv")
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} else if events := strings.Split(string(eventLog), "\n"); len(events) != 3 { } else if events := strings.Split(string(eventLog), "\n"); len(events) != 4 {
t.Log("Events:", events) t.Log("Events:", events)
t.Error("Wrong event log length:", len(events)) t.Error("Wrong event log length:", len(events))
} else if events[2] != "" { } else if events[3] != "" {
t.Error("Event log didn't end with newline") t.Error("Event log didn't end with newline", events)
} }
} }

View File

@ -38,3 +38,7 @@ func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) {
func (t *Theme) Maintain(i time.Duration) { func (t *Theme) Maintain(i time.Duration) {
// No periodic tasks for a theme // No periodic tasks for a theme
} }
func (t *Theme) refresh() {
// Nothing to do for a theme
}

View File

@ -32,6 +32,11 @@ func TestTheme(t *testing.T) {
t.Error("Timestamp compared wrong") t.Error("Timestamp compared wrong")
} }
if f, _, err := s.Open("/foo/bar/index.html"); err == nil {
f.Close()
t.Error("Path is ignored")
}
if f, _, err := s.Open("nofile"); err == nil { if f, _, err := s.Open("nofile"); err == nil {
f.Close() f.Close()
t.Error("Opening non-existent file didn't return an error") t.Error("Opening non-existent file didn't return an error")

View File

@ -7,7 +7,7 @@ import (
"log" "log"
"time" "time"
"github.com/dirtbags/moth/pkg/transpile" "github.com/dirtbags/moth/v4/pkg/transpile"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -79,3 +79,7 @@ func (p TranspilerProvider) Mothball(cat string, w io.Writer) error {
func (p TranspilerProvider) Maintain(updateInterval time.Duration) { func (p TranspilerProvider) Maintain(updateInterval time.Duration) {
// Nothing to do here. // Nothing to do here.
} }
func (p TranspilerProvider) refresh() {
// Nothing to do for a theme
}

View File

@ -9,7 +9,7 @@ import (
"os" "os"
"sort" "sort"
"github.com/dirtbags/moth/pkg/transpile" "github.com/dirtbags/moth/v4/pkg/transpile"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -81,7 +81,7 @@ func (t *T) ParseArgs() (Command, error) {
default: default:
fmt.Fprintln(t.Stderr, "ERROR:", t.Args[1], "is not a valid command") fmt.Fprintln(t.Stderr, "ERROR:", t.Args[1], "is not a valid command")
usage(t.Stderr) usage(t.Stderr)
return nothing, fmt.Errorf("Invalid command") return nothing, fmt.Errorf("invalid command")
} }
if err := flags.Parse(t.Args[2:]); err != nil { if err := flags.Parse(t.Args[2:]); err != nil {

View File

@ -4,11 +4,13 @@ import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"io/ioutil" "io/ioutil"
"os"
"strings" "strings"
"testing" "testing"
"github.com/dirtbags/moth/pkg/transpile" "github.com/dirtbags/moth/v4/pkg/transpile"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -202,3 +204,32 @@ func TestFilesystem(t *testing.T) {
t.Error("Wrong file pulled", stdout.String()) t.Error("Wrong file pulled", stdout.String())
} }
} }
func TestCwd(t *testing.T) {
testwd, err := os.Getwd()
if err != nil {
t.Error("Can't get current working directory!")
return
}
defer os.Chdir(testwd)
stdin := new(bytes.Buffer)
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
tp := T{
Stdin: stdin,
Stdout: stdout,
Stderr: stderr,
BaseFs: afero.NewOsFs(),
}
stdout.Reset()
os.Chdir("/")
if err := tp.Run(
"file",
fmt.Sprintf("-dir=%s/testdata/cat1/1", testwd),
"moo.txt",
); err != nil {
t.Error(err)
}
}

79
docs/FAQ.md Normal file
View File

@ -0,0 +1,79 @@
Frequently Asked Questions
=================
I should probably move this somewhere else,
since most of it is about
Main Application Questions
=====================
## Can we add some instructions to the user interface? It's confusing.
The lack of instruction was a deliberate design decision made about 9 years ago
when we found in A/B testing that college students are a lot more motivated by
vague instruction and mystery than precise instruction. We've since found that
people who are inclined to "play" our events are similarly motivated by
weirdness and mystery: they enjoy fiddling around with things until they've
worked it out experimentally.
Oddly, the group who seems to be the most perturbed by the vagueness is
professionals. This may be because many of these folks spend long amounts of
time trying to make things accessible and precise, and this looks like a train
wreck from that perspective.
Another way to think about it: this is supposed to be a game, like Super Mario
Brothers. We were very careful about designing the puzzles so that you could
learn by playing. The whimsical design is meant to make it feel like trying
things out will not result in a catastrophic failure anywhere, and we've found
that most people figure it out very quickly without any instruction at all,
despite feeling a little confused or disoriented at first.
## Why can't I choose my team from a list when I log in?
We actually started this way, but we quickly learned that there were exploitable
attack avenues available when any participant can join any team. One individual
in 2010, having a bad day, decided to enter every answer they had, for every
team in the contest, as a way of sabotaging the event. It worked: everyone's
motivation to try and solve puzzles tanked, and people were angry that they'd
been working on content only to find that they already had the points.
## Why won't you add this helpful text to the login page?
It has been our experience that the more words we have on that page, the less
likely any of them will be read. We strive now to have no instruction at all,
and to design the interface in a way that it's obvious what you have to do.
## Why aren't we providing a link to the scoreboard?
It's because the scoreboard looks horrible on a mobile phone:
it was designed for a projector.
Once we have a scoreboard that is readable on mobile,
I'll add that link.
## Why can't we show a list of teams to log in to?
At a previous event,
we had a participant log in as other teams and solve every puzzle,
because they were upset about something.
This ruined the event for everyone,
because it took away the challenge of scoring points.
Scoreboard Questions
=================
## Why are there no links or title on the scoreboard?
The scoreboard is supposed to be projected at events, to participants. The current scoreboard isn't something we intend participants to pull up on their mobile devices or laptops.
Think of the scoreboard as sort of like the menu screens at Burger King.
## Will you change the scoreboard color scheme?
The scoreboard colors and layout were carefully chosen to be distinguishable for
all forms of color blindness, and even accessible by users with total blindness
using screen readers. This is why we decided to put the category name inside the
bar and just deal with it being a little weird.
I'm open to suggestions, but they need to work for all users.

View File

@ -45,8 +45,8 @@ Scores
Pausing/resuming scoring Pausing/resuming scoring
------------------- -------------------
rm /srv/moth/state/enabled # Pause scoring echo '-###' >> /srv/moth/state/hours.txt # Suspend scoring
touch /srv/moth/state/enabled # Resume scoring sed -i '/###/d' /srv/moth/state/hours.txt # Resume scoring
When scoring is paused, When scoring is paused,
participants can still submit answers, participants can still submit answers,
@ -54,12 +54,13 @@ and the system will tell them whether the answer is correct.
As soon as you unpause, As soon as you unpause,
all correctly-submitted answers will be scored. all correctly-submitted answers will be scored.
Adjusting scores Adjusting scores
------------------ ------------------
rm /srv/moth/state/enabled # Suspend scoring echo '-###' >> /srv/moth/state/hours.txt # Suspend scoring
nano /srv/moth/state/points.log # Replace nano with your preferred editor nano /srv/moth/state/points.log # Replace nano with your preferred editor
touch /srv/moth/state/enabled # Resume scoring sed -i '/###/d' /srv/moth/state/hours.txt # Resume scoring
We don't warn participants before we do this: 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. any points scored while scoring is suspended are queued up and processed as soon as scoreing is resumed.

View File

@ -25,12 +25,12 @@ so you can watch the access log and any error messages.
### Podman ### Podman
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth-devel podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
### Docker ### Docker
docker run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth-devel docker run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
### Native ### Native

View File

@ -33,11 +33,11 @@ We're going to assume you put everything in `/srv/moth`, like we suggested.
### Podman ### Podman
podman run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth podman run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state ghcr.io/dirtbags/moth
### Docker ### Docker
docker run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth docker run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state ghcr.io/dirtbags/moth
### Native ### Native

View File

@ -41,16 +41,18 @@ Each line has four fields:
1602702913 2255 sequence 16 1602702913 2255 sequence 16
``` ```
`events.log` format `events.csv` format
---------------------- ----------------------
The events log is a space-separated file. The events log is a comma-separated variable (CSV) file.
It ought to import into any spreadsheet program painlessly.
Each line has six fields minimum: Each line has six fields minimum:
| `timestamp` | `event` | `participantID` | `teamID` | `category` | `points` | `extra`... | | `timestamp` | `event` | `teamID` | `category` | `points` | `extra`... |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| int | string | string | string | string | int | string... | | int | string | string | string | int | string... |
| Unix epoch | Event type | Team's unique ID| Participant's (hopefully) unique ID | Name of category, if any | Points awarded, if any | Additional fields, if any | | Unix epoch | Event type | Team's unique ID | Name of category, if any | Points awarded, if any | Additional fields, if any |
Fields after `points` contain extra fields associated with the event. Fields after `points` contain extra fields associated with the event.
@ -61,6 +63,7 @@ These may change in the future.
* init: startup of server * init: startup of server
* disabled: points accumulation disabled * disabled: points accumulation disabled
* enabled: points accumulation re-enabled * enabled: points accumulation re-enabled
* register: team registration
* load: puzzle load * load: puzzle load
* wrong: wrong answer submitted * wrong: wrong answer submitted
* correct: correct answer submitted * correct: correct answer submitted
@ -68,14 +71,14 @@ These may change in the future.
### Example ### Example
``` ```
1602716345 init - - - - 0 1602716345,init,-,-,-,-,0
1602716349 load 2255 player5 sequence 1 1602716349,load,2255,player5,sequence,1
1602716450 load 4824 player3 sequence 1 1602716450,load,4824,player3,sequence,1
1602716359 correct 2255 player5 sequence 1 1602716359,correct,2255,player5,sequence,1
1602716423 wrong 4824 player3 sequence 1 1602716423,wrong,4824,player3,sequence,1
1602716428 correct 4824 player3 sequence 1 1602716428,correct,4824,player3,sequence,1
1602716530 correct 4824 player3 sequence 1 1602716530,correct,4824,player3,sequence,1
1602716546 abduction 4824 player3 - 0 alien FM1490 1602716546,abduction,4824,player3,-,0,alien,FM1490
``` ```
The final entry is a made-up "alien abduction" entry, The final entry is a made-up "alien abduction" entry,

View File

@ -33,24 +33,13 @@ The state directory is also used to communicate actions to mothd.
Remove this file to reset the state. This will blow away team assignments and the points log. Remove this file to reset the state. This will blow away team assignments and the points log.
`disabled` `hours.txt`
----------
Create this file to pause collection of points and other maintenance.
Contestants can still submit answers,
but they won't show up on the scoreboard until you remove this file.
This file does not normally exist.
`until`
------- -------
Put an RFC3337 date/time stamp in here to have the server pause itself at a given time. A list of start and stop hours.
Remember that time zones exist! If all the hours are in the future, the event defaults to running.
I recommend always using Zulu time. "Stop" here just pertains to scoreboard updates and puzzle unlocking.
People can still submit answers and their awards are queued up for the next start.
This file does not normally exist.
`teamids.txt` `teamids.txt`

73
docs/scoring.md Normal file
View File

@ -0,0 +1,73 @@
Scoring
=======
MOTH does not carry any notion of who is winning: we consider this a user
interface issue. The server merely provides a timestamped log of point awards.
The bundled scoreboard provides one way to interpret the scores: this is the
main algorithm we use at Cyber Fire events. We use other views of the scoreboard
in other contexts, though! Here are some ideas:
Percentage of Each Category
---------------------
This is implemented in the scoreboard distributed with MOTH, and is how our
primary score calculation at Cyber Fire.
For each category:
* Divide the team's score in this category by the highest score in this category
* Add that to the team's overall score
This means the highest theoretical score in any event is the number of open
categories.
This algorithm means that point values only matter relative to other point
values within that category. A category with 5 total points is worth the same as
a category with 5000 total points, and a 2 point puzzle in the first category is
worth as much as a 2000 point puzzle in the second.
One interesting effect here is that a team solving a previously-unsolved puzzle
will reduce everybody else's ranking in that category, because it increases the
divisor for calculating that category's score.
Cyber Fire used to not display overall score: we would only show each team's
relative ranking per category. We may go back to this at some point!
Category Completion
----------------
Cyber Fire also has a scoreboard called the "class" scoreboard, which lists each
team, and which puzzles they have completed. This provides instructors with a
graphical overview of how people are progressing through content. We can provide
assistance to the general group when we see that a large number of teams are
stuck on a particular puzzle, and we can provide individual assistance if we see
that someone isn't keeping up with the class.
Monarch Of The Hill
----------------
You could also implement a "winner takes all" approach: any team with the
maximum number of points in a category gets 1 point, and all other teams get 0.
Time Bonuses
-----------
If you wanted to provide extra points to whichever team solves a puzzle first,
this is possible with the log. You could either boost a puzzle's point value or
decay it; either by timestamp, or by how many teams had solved it prior.
Bonkers Scoring
-------------
Other zany options exist:
* The first team to solve a puzzle with point value divisible by 7 gets double
points.
* [Tokens](tokens.md) with negative point values could be introduced, allowing
teams to manipulate other teams' scores, if they know the team ID.

8
go.mod
View File

@ -1,12 +1,12 @@
module github.com/dirtbags/moth module github.com/dirtbags/moth/v4
go 1.13 go 1.13
require ( require (
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/spf13/afero v1.5.1 github.com/spf13/afero v1.8.2
github.com/yuin/goldmark v1.3.1 github.com/yuin/goldmark v1.4.13
golang.org/x/text v0.3.5 // indirect golang.org/x/text v0.3.8 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )

447
go.sum
View File

@ -1,45 +1,454 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/spf13/afero v1.4.0 h1:jsLTaI1zwYO3vjrzHalkVcIHXTNmdQFepW4OI8H3+x8= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/spf13/afero v1.4.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/spf13/afero v1.5.1 h1:VHu76Lk0LSP1x254maIu2bplkWpfBWI+B+6fdoZprcg= github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
github.com/spf13/afero v1.5.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View File

@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
@ -49,7 +50,7 @@ func Parse(s string) (T, error) {
if err != nil { if err != nil {
return ret, err return ret, err
} else if n != 4 { } else if n != 4 {
return ret, fmt.Errorf("Malformed award string: only parsed %d fields", n) return ret, fmt.Errorf("malformed award string: only parsed %d fields", n)
} }
return ret, nil return ret, nil
@ -60,6 +61,17 @@ func (a T) String() string {
return fmt.Sprintf("%d %s %s %d", a.When, a.TeamID, a.Category, a.Points) return fmt.Sprintf("%d %s %s %d", a.When, a.TeamID, a.Category, a.Points)
} }
// Filename returns a string version of an award suitable for a filesystem
func (a T) Filename() string {
return fmt.Sprintf(
"%d-%s-%s-%d.award",
a.When,
url.PathEscape(a.TeamID),
url.PathEscape(a.Category),
a.Points,
)
}
// MarshalJSON returns the award event, encoded as a list. // MarshalJSON returns the award event, encoded as a list.
func (a T) MarshalJSON() ([]byte, error) { func (a T) MarshalJSON() ([]byte, error) {
ao := []interface{}{ ao := []interface{}{

View File

@ -4,7 +4,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"crypto/sha256" "crypto/sha1"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -37,23 +37,45 @@ type PuzzleDebug struct {
Summary string Summary string
} }
// Puzzle contains everything about a puzzle that a client would see. // Puzzle contains everything about a puzzle that a client will see.
type Puzzle struct { type Puzzle struct {
Debug PuzzleDebug // Debug contains debugging information, omitted in mothballs
Authors []string Debug PuzzleDebug
Attachments []string
Scripts []string // Authors names all authors of this puzzle
Body string Authors []string
// Attachments is a list of filenames used by this puzzle
Attachments []string
// Scripts is a list of EMCAScript files needed by the client for this puzzle
Scripts []string
// Body is the HTML rendering of this puzzle
Body string
// AnswerPattern contains the pattern (regular expression?) used to match valid answers
AnswerPattern string AnswerPattern string
AnswerHashes []string
Objective string // AnswerHashes contains hashes of all answers for this puzzle
KSAs []string AnswerHashes []string
Success struct {
// Objective is the learning objective for this puzzle
Objective string
// KSAs lists all KSAs achieved upon successfull completion of this puzzle
KSAs []string
// Success lists the criteria for successfully understanding this puzzle
Success struct {
// Acceptable describes the minimum work required to be considered successfully understanding this puzzle's concepts
Acceptable string Acceptable string
Mastery string
// Mastery describes the work required to be considered mastering this puzzle's conceptss
Mastery string
} }
// Answers will be empty in a mothball // Answers lists all acceptable answers, omitted in mothballs
Answers []string Answers []string
} }
@ -63,9 +85,9 @@ func (puzzle *Puzzle) computeAnswerHashes() {
} }
puzzle.AnswerHashes = make([]string, len(puzzle.Answers)) puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
for i, answer := range puzzle.Answers { for i, answer := range puzzle.Answers {
sum := sha256.Sum256([]byte(answer)) sum := sha1.Sum([]byte(answer))
hexsum := fmt.Sprintf("%x", sum) hexsum := fmt.Sprintf("%x", sum)
puzzle.AnswerHashes[i] = hexsum puzzle.AnswerHashes[i] = hexsum[:4]
} }
} }

View File

@ -23,6 +23,12 @@ func TestPuzzle(t *testing.T) {
if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") { if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") {
t.Error("Answers are wrong", p.Answers) t.Error("Answers are wrong", p.Answers)
} }
if len(p.Answers) != len(p.AnswerHashes) {
t.Error("Answer hashes length does not match answers length")
}
if len(p.AnswerHashes[0]) != 4 {
t.Error("Answer hash is wrong length")
}
if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") { if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") {
t.Error("Authors are wrong", p.Authors) t.Error("Authors are wrong", p.Authors)
} }

175
theme/background.mjs Normal file
View File

@ -0,0 +1,175 @@
function randint(max) {
return Math.floor(Math.random() * max)
}
const Millisecond = 1
const Second = Millisecond * 1000
const FrameRate = 24 / Second // Fast enough for this tomfoolery
class Point {
constructor(x, y) {
this.x = x
this.y = y
}
/**
* Add n to this.
*
* @param {Point} n What to add to this
* @returns {Point}
*/
Add(n) {
return new Point(this.x + n.x, this.y + n.y)
}
/**
* Subtract n from this.
*
* @param {Point} n
* @returns {Point}
*/
Subtract(n) {
return new Point(this.x - n.x, this.y - n.y)
}
/**
* Add velocity, then bounce point off box defined by points at min and max
* @param {Point} velocity
* @param {Point} min
* @param {Point} max
* @returns {Point}
*/
Bounce(velocity, min, max) {
let p = this.Add(velocity)
if (p.x < min.x) {
p.x += (min.x - p.x) * 2
velocity.x *= -1
}
if (p.x > max.x) {
p.x += (max.x - p.x) * 2
velocity.x *= -1
}
if (p.y < min.y) {
p.y += (min.y - p.y) * 2
velocity.y *= -1
}
if (p.y > max.y) {
p.y += (max.y - p.y) * 2
velocity.y *= -1
}
return p
}
/**
*
* @param {Point} p
* @returns {Boolean}
*/
Equal(p) {
return (this.x == p.x) && (this.y == p.y)
}
}
class QixLine {
/**
* @param {Number} hue
* @param {Point} a
* @param {Point} b
*/
constructor(hue, a, b) {
this.hue = hue
this.a = a
this.b = b
}
}
/**
* Draw a line dancing around the screen,
* like the video game "qix"
*/
class QixBackground {
constructor(ctx, frameRate = 6/Second) {
this.ctx = ctx
this.min = new Point(0, 0)
this.max = new Point(this.ctx.canvas.width, this.ctx.canvas.height)
this.box = this.max.Subtract(this.min)
this.lines = [
new QixLine(
Math.random(),
new Point(randint(this.box.x), randint(this.box.y)),
new Point(randint(this.box.x), randint(this.box.y)),
)
]
while (this.lines.length < 18) {
this.lines.push(this.lines[0])
}
this.velocity = new QixLine(
0.001,
new Point(1 + randint(this.box.x / 100), 1 + randint(this.box.y / 100)),
new Point(1 + randint(this.box.x / 100), 1 + randint(this.box.y / 100)),
)
this.frameInterval = Millisecond / frameRate
this.nextFrame = 0
}
/**
* Maybe draw a frame
*/
Animate() {
let now = performance.now()
if (now < this.nextFrame) {
// Not today, satan
return
}
this.nextFrame = now + this.frameInterval
this.lines.shift()
let lastLine = this.lines[this.lines.length - 1]
let nextLine = new QixLine(
(lastLine.hue + this.velocity.hue) % 1.0,
lastLine.a.Bounce(this.velocity.a, this.min, this.max),
lastLine.b.Bounce(this.velocity.b, this.min, this.max),
)
this.lines.push(nextLine)
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
for (let line of this.lines) {
this.ctx.save()
this.ctx.strokeStyle = `hwb(${line.hue}turn 0% 0%)`
this.ctx.beginPath()
this.ctx.moveTo(line.a.x, line.a.y)
this.ctx.lineTo(line.b.x, line.b.y)
this.ctx.stroke()
this.ctx.restore()
}
}
}
function init() {
// Don't like the background animation? You can disable it by setting a
// property in localStorage and reloading.
if (localStorage.disableBackgroundAnimation) {
return
}
let canvas = document.createElement("canvas")
canvas.width = 640
canvas.height = 640
canvas.classList.add("wallpaper")
document.body.insertBefore(canvas, document.body.firstChild)
let ctx = canvas.getContext("2d")
let qix = new QixBackground(ctx)
// window.requestAnimationFrame is overkill for something this silly
setInterval(() => qix.Animate(), Millisecond/FrameRate)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

View File

@ -1,227 +1,133 @@
/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */ /* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
body { body {
font-family: sans-serif; font-family: sans-serif;
background: #010e19 url("bg.png") center fixed;
background-size: cover;
background-blend-mode: soft-light;
background-color: #010e19;
color: #edd488;
}
canvas.wallpaper {
position: fixed;
display: block;
z-index: -1000;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
opacity: 0.2;
image-rendering: pixelated;
}
main {
max-width: 40em; max-width: 40em;
background: #282a33; margin: 1em auto;
color: #f6efdc; padding: 1px 3px;
border-radius: 5px;
background: #000d;
} }
body.wide { h1, h2, h3, h4, h5, h6 {
max-width: 100%; color: #cb2408cc;
}
a:any-link {
color: #8b969a;
} }
h1 { h1 {
background: #5e576b; background: #cb240844;
color: #9e98a8; padding: 3px;
}
.Fail, .Error, #messages {
background: #3a3119;
color: #ffcc98;
}
.Fail:before {
content: "Fail: ";
}
.Error:before {
content: "Error: ";
} }
p { p {
margin: 1em 0em; margin: 1em 0em;
} }
a:any-link {
color: #b9cbd8;
}
form, pre { form, pre {
margin: 1em; margin: 1em;
overflow-x: auto;
} }
input, select { input, select {
padding: 0.6em; padding: 0.6em;
margin: 0.2em; margin: 0.2em;
max-width: 30em; max-width: 30em;
} }
nav { input {
border: solid black 2px; background-color: #ccc4;
color: inherit;
}
.notification, .error {
padding: 0 1em;
border-radius: 8px;
}
.notification {
background: #ac8f3944;
}
.error {
background: red;
color: white;
}
.hidden {
display: none;
}
/** Puzzles list */
.category {
margin: 5px 0;
background: #ccc4;
}
.category h2 {
margin: 0 0.2em;
}
.category .solved {
text-decoration: line-through;
} }
nav ul, .category ul { nav ul, .category ul {
padding: 1em; margin: 0;
padding: 0.2em 1em;
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
} }
nav li, .category li { nav li, .category li {
display: inline; display: inline;
margin: 1em;
} }
iframe#body { .mothball {
border: inherit; float: right;
width: 100%; text-decoration: none;
border-radius: 5px;
background: #ccc;
padding: 4px 8px;
margin: 5px;
} }
img {
/** Puzzle content */
#puzzle {
border-bottom: solid;
padding: 0 0.5em;
}
#puzzle img {
max-width: 100%; max-width: 100%;
} }
input:invalid { input:invalid {
border-color: red; background-color: #800;
color: white;
} }
#messages { .answer_ok {
min-height: 3em; cursor: help;
border: solid black 2px;
}
#rankings {
width: 100%;
position: relative;
}
#rankings span {
font-size: 75%;
display: inline-block;
overflow: hidden;
height: 1.7em;
}
#rankings span.teamname {
font-size: inherit;
color: white;
text-shadow: 0 0 3px black;
opacity: 0.8;
position: absolute;
right: 0.2em;
background-color: #292929;
background-blend-mode: darken;
padding: 0em 0.2em;
border-top-left-radius: 0.5em;
border-bottom-left-radius: 0.5em;
margin:0em;
height: 1.5em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition-property: max-width;
transition-duration: 2s;
transition-delay: 0s;
}
#rankings span.teamname:hover {
max-width: 100%;
}
#rankings span.teampoints {
font-size:100%;
height:1.2em;
margin:0em;
padding:0em;
width:99%;
} }
#rankings div * {white-space: nowrap;} /** Development mode information */
.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;} .debug {
.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;} overflow: auto;
.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;} padding: 1em;
.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;} border-radius: 10px;
.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;} margin: 2em auto;
.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;} background: #cccc;
.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}
/* Responsive design */
/* Defaults */
#rankings span.teampoints {
max-width:89%;
}
#rankings span.teamname {
max-width:10%;
}
/* Monitors with large enough screens to do side by side */
@media only screen and (min-width: 170em) {
#chart, #rankings {
width: 49%;
display:inline-block;
vertical-align:middle;
}
}
/* Monitor
@media only screen and (max-width: 130em) {
#chart, #rankings {
width: 49%;
display:inline-block;
vertical-align: middle;
}
#rankings span.teampoints {
max-width:89%;
}
#rankings span.teamname {
max-width:10%;
}
}
/* Laptop size screen */
@media only screen and (max-width: 100em) {
#rankings span.teampoints {
max-width:84%;
}
#rankings span.teamname {
max-width:15%;
}
}
/* Roughly Tablet size */
@media only screen and (max-width: 70em) {
#rankings span.teampoints {
max-width:79%;
}
#rankings span.teamname {
max-width:20%;
}
}
/* Small screens phone size */
@media only screen and (max-width: 40em) {
#rankings span.teampoints {
max-width:65%;
}
#rankings span.teamname {
max-width:34%;
}
}
#devel {
background-color: #eee;
color: black; color: black;
overflow: scroll;
} }
#devel .string { .debug dt {
color: #9c27b0; font-weight: bold;
}
#devel .body {
background-color: #ffc107;
}
.kvpair {
border: solid black 2px;
}
.spinner {
display: inline-block;
width: 64px;
height: 64px;
display: block;
width: 46px;
height: 46px;
margin: 1px;
border-radius: 50%;
border: 5px solid #fff;
border-color: #fff transparent #fff transparent;
animation: rotate 1.2s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
/** Draggable items, from the draggable plugin */
li[draggable]::before { li[draggable]::before {
content: "↕"; content: "↕";
padding: 0.5em; padding: 0.5em;
@ -239,6 +145,48 @@ li[draggable] {
border: 1px white dashed; border: 1px white dashed;
} }
#cacheButton.disabled {
display: none;
/** Toasts are little pop-up informational messages. */
.toasts {
position: fixed;
z-index: 100;
bottom: 10px;
left: 10px;
text-align: center;
width: calc(100% - 20px);
display: flex;
flex-direction: column;
}
.toast {
border-radius: 0.5em;
padding: 0.2em 2em;
animation: fadeIn ease 1s;
margin: 2px auto;
background: #333;
color: #eee;
box-shadow: 0px 0px 8px 0px #0b0;
}
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
@media (prefers-color-scheme: light) {
/* We uses the alpha channel to apply hue tinting to elements, to get a
* similar effect in light or dark mode. That means there aren't a whole lot of
* things to change between light and dark mode.
*/
body {
background-color: #b9cbd8;
color: black;
}
main {
background-color: #fffd;
}
a:any-link {
color: #092b45;
}
} }

BIN
theme/bg.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

84
theme/common.mjs Normal file
View File

@ -0,0 +1,84 @@
/**
* Common functionality
*/
const Millisecond = 1
const Second = Millisecond * 1000
const Minute = Second * 60
/** URL to the top of this MOTH server */
const BaseURL = new URL(".", location)
/**
* Display a transient message to the user.
*
* @param {String} message Message to display
* @param {Number} timeout How long before removing this message
*/
function Toast(message, timeout=5*Second) {
console.info(message)
for (let toasts of document.querySelectorAll(".toasts")) {
let p = toasts.appendChild(document.createElement("p"))
p.classList.add("toast")
p.textContent = message
setTimeout(() => p.remove(), timeout)
}
}
/**
* Run a function when the DOM has been loaded.
*
* @param {function():void} cb Callback function
*/
function WhenDOMLoaded(cb) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", cb)
} else {
cb()
}
}
/**
* Interprets a String as a Boolean.
*
* Values like "no" or "disabled" to mean false here.
*
* @param {String} s
* @returns {Boolean}
*/
function Truthy(s) {
switch (s.toLowerCase()) {
case "disabled":
case "no":
case "off":
case "false":
return false
}
return true
}
/**
* Fetch the configuration object for this theme.
*
* @returns {Promise.<Object>}
*/
async function Config() {
let resp = await fetch(
new URL("config.json", BaseURL),
{
cache: "no-cache"
},
)
return resp.json()
}
export {
Millisecond,
Second,
Minute,
BaseURL,
Toast,
WhenDOMLoaded,
Truthy,
Config,
}

6
theme/config.json Normal file
View File

@ -0,0 +1,6 @@
{
"TrackSolved": true,
"URLInScoreboard": true,
"Messages": "<!-- Messages can go here (HTML) -->",
"__sentry__": "this is here so you don't have to remember to take the comma off the last item"
}

View File

@ -1,38 +1,44 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title>MOTH</title> <title>MOTH</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<link rel="icon" href="luna-moth.svg">
<link rel="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<script src="moth.js"></script> <script src="index.mjs" type="module" async></script>
<link rel="manifest" href="manifest.json"> <script src="background.mjs" type="module" async></script>
</head> </head>
<body> <body>
<h1 id="title">MOTH</h1> <h1 class="title">MOTH</h1>
<section> <main>
<div id="messages"> <div class="messages notification">
<div id="notices"></div>
</div> </div>
<form id="login"> <form class="login">
<!--
<span id="pid">
Participant ID: <input name="pid"> (optional) <br>
</span>
-->
Team ID: <input name="id"> <br> Team ID: <input name="id"> <br>
Team name: <input name="name"> <br> Team name: <input name="name"> <br>
<input type="submit" value="Sign In"> <input type="submit" value="Sign In">
</form> </form>
<div id="puzzles"></div> <div class="puzzles"></div>
</main>
<div class="notification" data-track-solved="no">
<p>
Solved puzzle tracking: <b>disabled</b>.
</p>
<p>
Your team's Incident Coordinator can help coordinate team activity.
</p>
</div>
<div class="toasts"></div>
</section>
<nav> <nav>
<ul> <ul>
<li><a href="scoreboard.html">Scoreboard</a></li> <li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>
<li><a href="logout.html">Sign Out</a></li> <li><button class="logout">Sign Out</button></li>
</ul> </ul>
</nav> </nav>
</body> </body>

174
theme/index.mjs Normal file
View File

@ -0,0 +1,174 @@
/**
* Functionality for index.html (Login / Puzzles list)
*/
import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
class App {
constructor(basePath=".") {
this.config = {}
this.server = new moth.Server(basePath)
for (let form of document.querySelectorAll("form.login")) {
form.addEventListener("submit", event => this.handleLoginSubmit(event))
}
for (let e of document.querySelectorAll(".logout")) {
e.addEventListener("click", () => this.Logout())
}
setInterval(() => this.UpdateState(), common.Minute/3)
setInterval(() => this.UpdateConfig(), common.Minute* 5)
this.UpdateConfig()
.finally(() => this.UpdateState())
}
handleLoginSubmit(event) {
event.preventDefault()
console.log(event)
}
/**
* Attempt to log in to the server.
*
* @param {string} teamID
* @param {string} teamName
*/
async Login(teamID, teamName) {
try {
await this.server.Login(teamID, teamName)
common.Toast(`Logged in (team id = ${teamID})`)
this.UpdateState()
}
catch (error) {
common.Toast(error)
}
}
/**
* Log out of the server by clearing the saved Team ID.
*/
async Logout() {
try {
this.server.Reset()
common.Toast("Logged out")
this.UpdateState()
}
catch (error) {
common.Toast(error)
}
}
/**
* Update app configuration.
*
* Configuration can be updated less frequently than state, to reduce server
* load, since configuration should (hopefully) change less frequently.
*/
async UpdateConfig() {
this.config = await common.Config()
for (let e of document.querySelectorAll(".messages")) {
e.innerHTML = this.config.Messages || ""
}
}
/**
* Update the entire page.
*
* Fetch a new state, and rebuild all dynamic elements on this bage based on
* what's returned. If we're in development mode and not logged in, auto
* login too.
*/
async UpdateState() {
this.state = await this.server.GetState()
// Update elements with data-track-solved
for (let e of document.querySelectorAll("[data-track-solved]")) {
// Only display if data-track-solved is the same as config.trackSolved
e.classList.toggle("hidden", common.Truthy(e.dataset.trackSolved) != this.config.TrackSolved)
}
for (let e of document.querySelectorAll(".login")) {
this.renderLogin(e, !this.server.LoggedIn())
}
for (let e of document.querySelectorAll(".puzzles")) {
this.renderPuzzles(e, this.server.LoggedIn())
}
if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
let teamID = Math.floor(Math.random() * 1000000).toString(16)
common.Toast("Automatically logging in to devel server")
console.info(`Logging in with generated Team ID: ${teamID}`)
return this.Login(teamID, `Team ${teamID}`)
}
}
/**
* Render a login box.
*
* Just toggles visibility, there's nothing dynamic in a login box.
*/
renderLogin(element, visible) {
element.classList.toggle("hidden", !visible)
}
/**
* Render a puzzles box.
*
* Displays the list of open puzzles, and adds mothball download links
* if the server is in development mode.
*/
renderPuzzles(element, visible) {
element.classList.toggle("hidden", !visible)
while (element.firstChild) element.firstChild.remove()
for (let cat of this.state.Categories()) {
let pdiv = element.appendChild(document.createElement("div"))
pdiv.classList.add("category")
let h = pdiv.appendChild(document.createElement("h2"))
h.textContent = cat
// Extras if we're running a devel server
if (this.state.DevelopmentMode()) {
let a = h.appendChild(document.createElement('a'))
a.classList.add("mothball")
a.textContent = "⬇️"
a.href = this.server.URL(`mothballer/${cat}.mb`)
a.title = "Download a compiled puzzle for this category"
}
// List out puzzles in this category
let l = pdiv.appendChild(document.createElement("ul"))
for (let puzzle of this.state.Puzzles(cat)) {
let i = l.appendChild(document.createElement("li"))
let url = new URL("puzzle.html", common.BaseURL)
url.hash = `${puzzle.Category}:${puzzle.Points}`
let a = i.appendChild(document.createElement("a"))
a.textContent = puzzle.Points
a.href = url
a.target = "_blank"
if (this.config.TrackSolved) {
a.classList.toggle("solved", this.state.IsSolved(puzzle))
}
}
if (!this.state.ContainsUnsolved(cat)) {
l.appendChild(document.createElement("li")).textContent = "✿"
}
element.appendChild(pdiv)
}
}
}
function init() {
window.app = {
server: new App()
}
}
common.WhenDOMLoaded(init)

View File

@ -1,23 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>MOTH</title>
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="basic.css">
<script>
sessionStorage.removeItem("id")
</script>
</head>
<body>
<h1 id="title">MOTH</h1>
<section>
<p>Okay, you've been logged out.</p>
</section>
<nav>
<ul>
<li><a href="index.html">Sign In</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body>
</html>

View File

@ -1,9 +0,0 @@
{
"name": "Monarch of the Hill",
"short_name": "MOTH",
"start_url": ".",
"display": "standalone",
"background_color": "#282a33",
"theme_color": "#ECB",
"description": "The MOTH CTF engine"
}

1
theme/moment.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,203 +0,0 @@
// jshint asi:true
var devel = false
var teamId
var heartbeatInterval = 40000
function toast(message, timeout=5000) {
let p = document.createElement("p")
p.innerText = message
document.getElementById("messages").appendChild(p)
setTimeout(
e => { p.remove() },
timeout
)
}
function renderNotices(obj) {
let ne = document.getElementById("notices")
if (ne) {
ne.innerHTML = 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()
if (cats.length == 0) {
toast("No categories to serve!")
}
for (let cat of cats) {
if (cat.startsWith("__")) {
// Skip metadata
continue
}
let puzzles = obj[cat]
let pdiv = document.createElement('div')
pdiv.className = 'category'
let h = document.createElement('h2')
pdiv.appendChild(h)
h.textContent = cat
// Extras if we're running a devel server
if (devel) {
let a = document.createElement('a')
h.insertBefore(a, h.firstChild)
a.textContent = "⬇️"
a.href = "mothballer/" + cat + ".mb"
a.classList.add("mothball")
a.title = "Download a compiled puzzle for this category"
}
// List out puzzles in this category
let l = document.createElement('ul')
pdiv.appendChild(l)
for (let puzzle of puzzles) {
let points = puzzle
let id = null
if (Array.isArray(puzzle)) {
points = puzzle[0]
id = puzzle[1]
}
let i = document.createElement('li')
l.appendChild(i)
i.textContent = " "
if (points === 0) {
// Sentry: there are no more puzzles in this category
i.textContent = "✿"
} else {
let a = document.createElement('a')
i.appendChild(a)
a.textContent = points
let url = new URL("puzzle.html", window.location)
url.searchParams.set("cat", cat)
url.searchParams.set("points", points)
if (id) { url.searchParams.set("pid", id) }
a.href = url.toString()
}
}
puzzlesElement.appendChild(pdiv)
}
// Drop that thing in
let container = document.getElementById("puzzles")
while (container.firstChild) {
container.firstChild.remove()
}
container.appendChild(puzzlesElement)
}
function renderState(obj) {
window.state = obj
devel = obj.Config.Devel
if (devel) {
let params = new URLSearchParams(window.location.search)
sessionStorage.id = "1"
sessionStorage.pid = "rodney"
renderPuzzles(obj.Puzzles)
} else if (Object.keys(obj.Puzzles).length > 0) {
renderPuzzles(obj.Puzzles)
}
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)
}
let fd = new FormData()
fd.append("id", teamId)
fetch(url)
.then(resp => {
if (resp.ok) {
resp.json()
.then(renderState)
.catch(err => {
toast("Error fetching recent state. I'll try again in a moment.")
console.log(err)
})
}
})
.catch(err => {
toast("Error fetching recent state. I'll try again in a moment.")
console.log(err)
})
}
function showPuzzles() {
let spinner = document.createElement("span")
spinner.classList.add("spinner")
document.getElementById("login").style.display = "none"
document.getElementById("puzzles").appendChild(spinner)
}
function login(e) {
e.preventDefault()
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:Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
fetch("register", {
method: "POST",
body: new FormData(e.target),
})
.then(resp => {
if (resp.ok) {
resp.json()
.then(obj => {
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)
}
})
.catch(err => {
toast("Oops, the server has lost its mind. You probably need to tell someone so they can fix it.")
console.log(err, resp)
})
} else {
toast("Oops, something's wrong with the server. Try again in a few seconds.")
console.log(resp)
}
})
.catch(err => {
toast("Oops, something went wrong. Try again in a few seconds.")
console.log(err)
})
}
function init() {
heartbeat()
setInterval(e => heartbeat(), 40000)
document.getElementById("login").addEventListener("submit", login)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

681
theme/moth.mjs Normal file
View File

@ -0,0 +1,681 @@
/**
* Hash/digest functions
*/
class Hash {
/**
* Dan Bernstein hash
*
* Used until MOTH v3.5
*
* @param {string} buf Input
* @returns {number}
*/
static djb2(buf) {
let h = 5381
for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte
// JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
// So we have to do "unsigned right shift" by zero to get it back to unsigned.
h = ((h * 33) + c) >>> 0
}
return h
}
/**
* Dan Bernstein hash with xor
*
* @param {string} buf Input
* @returns {number}
*/
static djb2xor(buf) {
let h = 5381
for (let c of (new TextEncoder()).encode(buf)) {
h = ((h * 33) ^ c) >>> 0
}
return h
}
/**
* SHA 256
*
* Used until MOTH v4.5
*
* @param {string} buf Input
* @returns {Promise.<string>} hex-encoded digest
*/
static async sha256(buf) {
const msgUint8 = new TextEncoder().encode(buf)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return this.hexlify(hashArray);
}
/**
* SHA 1, but only the first 4 hexits (2 octets).
*
* Git uses this technique with 7 hexits (default) as a "short identifier".
*
* @param {string} buf Input
*/
static async sha1_slice(buf, end=4) {
const msgUint8 = new TextEncoder().encode(buf)
const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hexits = this.hexlify(hashArray)
return hexits.slice(0, end)
}
/**
* Hex-encode a byte array
*
* @param {number[]} buf Byte array
* @returns {string}
*/
static hexlify(buf) {
return buf.map(b => b.toString(16).padStart(2, "0")).join("")
}
/**
* Apply every hash to the input buffer.
*
* @param {string} buf Input
* @returns {Promise.<string[]>}
*/
static async All(buf) {
return [
String(this.djb2(buf)),
await this.sha256(buf),
await this.sha1_slice(buf),
]
}
}
/**
* A point award.
*/
class Award {
constructor(when, teamid, category, points) {
/** Unix epoch timestamp for this award
* @type {number}
*/
this.When = when
/** Team ID this award belongs to
* @type {string}
*/
this.TeamID = teamid
/** Puzzle category for this award
* @type {string}
*/
this.Category = category
/** Points value of this award
* @type {number}
*/
this.Points = points
}
}
/**
* A puzzle.
*
* A new Puzzle only knows its category and point value.
* If you want to populate it with meta-information, you must call Populate().
*
* Parameters created by Populate are described in the server source code:
* {@link https://pkg.go.dev/github.com/dirtbags/moth/v4/pkg/transpile#Puzzle}
*
*/
class Puzzle {
/**
* @param {Server} server
* @param {string} category
* @param {number} points
*/
constructor (server, category, points) {
if (points < 1) {
throw(`Invalid points value: ${points}`)
}
/** Server where this puzzle lives
* @type {Server}
*/
this.server = server
/** Category this puzzle belongs to */
this.Category = String(category)
/** Point value of this puzzle */
this.Points = Number(points)
/** Error returned trying to retrieve this puzzle */
this.Error = {
/** Status code provided by server */
Status: 0,
/** Status text provided by server */
StatusText: "",
/** Full text of server error */
Body: "",
}
}
/**
* Populate this Puzzle object with meta-information from the server.
*/
async Populate() {
let resp = await this.Get("puzzle.json")
if (!resp.ok) {
let body = await resp.text()
this.Error = {
Status: resp.status,
StatusText: resp.statusText,
Body: body,
}
throw(this.Error)
}
let obj = await resp.json()
Object.assign(this, obj)
// Make sure lists are lists
this.AnswerHashes ||= []
this.Answers ||= []
this.Attachments ||= []
this.Authors ||= []
this.Debug.Errors ||= []
this.Debug.Hints ||= []
this.Debug.Log ||= []
this.KSAs ||= []
this.Scripts ||= []
}
/**
* Get a resource associated with this puzzle.
*
* @param {string} filename Attachment/Script to retrieve
* @returns {Promise.<Response>}
*/
Get(filename) {
return this.server.GetContent(this.Category, this.Points, filename)
}
/**
* Check if a string is possibly correct.
*
* The server sends a list of answer hashes with each puzzle: this method
* checks to see if any of those hashes match a hash of the string.
*
* The MOTH development team likes obscure hash functions with a lot of
* collisions, which means that a given input may match another possible
* string's hash. We do this so that if you run a brute force attack against
* the list of hashes, you have to write your own brute force program, and
* you still have to pick through a lot of potentially correct answers when
* it's done.
*
* @param {string} str User-submitted possible answer
* @returns {Promise.<boolean>}
*/
async IsPossiblyCorrect(str) {
let userAnswerHashes = await Hash.All(str)
for (let pah of this.AnswerHashes) {
for (let uah of userAnswerHashes) {
if (pah == uah) {
return true
}
}
}
return false
}
/**
* Submit a proposed answer for points.
*
* The returned promise will fail if anything goes wrong, including the
* proposed answer being rejected.
*
* @param {string} proposed Answer to submit
* @returns {Promise.<string>} Success message
*/
SubmitAnswer(proposed) {
return this.server.SubmitAnswer(this.Category, this.Points, proposed)
}
}
/**
* A snapshot of scores.
*/
class Scores {
constructor() {
/**
* Timestamp of this score snapshot
* @type number
*/
this.Timestamp = 0
/**
* All categories present in this snapshot.
*
* ECMAScript sets preserve order, so iterating over this will yield
* categories as they were added to the points log.
*
* @type {Set.<string>}
*/
this.Categories = new Set()
/**
* All team IDs present in this snapshot
* @type {Set.<string>}
*/
this.TeamIDs = new Set()
/**
* Highest score in each category
* @type {Object.<string,number>}
*/
this.MaxPoints = {}
this.categoryTeamPoints = {}
}
/**
* Return a sorted list of category names
*
* @returns {string[]}
*/
SortedCategories() {
let categories = [...this.Categories]
categories.sort((a,b) => a.localeCompare(b, "en", {sensitivity: "base"}))
return categories
}
/**
* Add an award to a team's score.
*
* Updates this.Timestamp to the award's timestamp.
*
* @param {Award} award
*/
Add(award) {
this.Timestamp = award.Timestamp
this.Categories.add(award.Category)
this.TeamIDs.add(award.TeamID)
let teamPoints = (this.categoryTeamPoints[award.Category] ??= {})
let points = (teamPoints[award.TeamID] || 0) + award.Points
teamPoints[award.TeamID] = points
let max = this.MaxPoints[award.Category] || 0
this.MaxPoints[award.Category] = Math.max(max, points)
}
/**
* Get a team's score within a category.
*
* @param {string} category
* @param {string} teamID
* @returns {number}
*/
GetPoints(category, teamID) {
let teamPoints = this.categoryTeamPoints[category] || {}
return teamPoints[teamID] || 0
}
/**
* Calculate a team's score in a category, using the Cyber Fire algorithm.
*
*@param {string} category
* @param {string} teamID
*/
CyFiCategoryScore(category, teamID) {
return this.GetPoints(category, teamID) / this.MaxPoints[category]
}
/**
* Calculate a team's overall score, using the Cyber Fire algorithm.
*
*@param {string} category
* @param {string} teamID
* @returns {number}
*/
CyFiScore(teamID) {
let score = 0
for (let category of this.Categories) {
score += this.CyFiCategoryScore(category, teamID)
}
return score
}
}
/**
* MOTH instance state.
*/
class State {
/**
* @param {Server} server Server where we got this
* @param {Object} obj Raw state data
*/
constructor(server, obj) {
for (let key of ["Config", "TeamNames", "PointsLog"]) {
if (!obj[key]) {
throw(`Missing state property: ${key}`)
}
}
this.server = server
/** Configuration */
this.Config = {
/** Is the server in development mode?
* @type {boolean}
*/
Devel: obj.Config.Devel,
}
/** Global messages, in HTML
* @type {string}
*/
this.Messages = obj.Messages
/** Map from Team ID to Team Name
* @type {Object.<string,string>}
*/
this.TeamNames = obj.TeamNames
/** Map from category name to puzzle point values
* @type {Object.<string,number>}
*/
this.PointsByCategory = obj.Puzzles
/** Log of points awarded
* @type {Award[]}
*/
this.PointsLog = obj.PointsLog.map(entry => new Award(entry[0], entry[1], entry[2], entry[3]))
}
/**
* Returns a sorted list of open category names
*
* @returns {string[]} List of categories
*/
Categories() {
let ret = []
for (let category in this.PointsByCategory) {
ret.push(category)
}
ret.sort()
return ret
}
/**
* Check whether a category contains unsolved puzzles.
*
* The server adds a puzzle with 0 points in every "solved" category,
* so this just checks whether there is a 0-point puzzle in the category's point list.
*
* @param {string} category
* @returns {boolean}
*/
ContainsUnsolved(category) {
return !this.PointsByCategory[category].includes(0)
}
/**
* Is the server in development mode?
*
* @returns {boolean}
*/
DevelopmentMode() {
return this.Config && this.Config.Devel
}
/**
* Return all open puzzles.
*
* The returned list will be sorted by (category, points).
* If not categories are given, all puzzles will be returned.
*
* @param {string} categories Limit results to these categories
* @returns {Puzzle[]}
*/
Puzzles(...categories) {
if (categories.length == 0) {
categories = this.Categories()
}
let ret = []
for (let category of categories) {
for (let points of this.PointsByCategory[category]) {
if (0 == points) {
// This means all potential puzzles in the category are open
continue
}
let p = new Puzzle(this.server, category, points)
ret.push(p)
}
}
return ret
}
/**
* Has this puzzle been solved by this team?
*
* @param {Puzzle} puzzle
* @param {string} teamID Team to check, default the logged-in team
* @returns {boolean}
*/
IsSolved(puzzle, teamID="self") {
for (let award of this.PointsLog) {
if (
(award.Category == puzzle.Category)
&& (award.Points == puzzle.Points)
&& (award.TeamID == teamID)
) {
return true
}
}
return false
}
/**
* Replay scores.
*
* MOTH has no notion of who is "winning", we consider this a user interface
* decision. There are lots of interesting options: see
* [scoring]{@link ../docs/scoring.md} for more.
*
* @yields {Scores} Snapshot at a point in time
*/
* ScoresHistory() {
let scores = new Scores()
for (let award of this.PointsLog) {
scores.Add(award)
yield scores
}
}
/**
* Calculate the current scores.
*
* @returns {Scores}
*/
CurrentScores() {
let scores
for (scores of this.ScoreHistory());
return scores
}
}
/**
* A MOTH Server interface.
*
* This uses localStorage to remember Team ID,
* and will send a Team ID with every request, if it can find one.
*/
class Server {
/**
* @param {string | URL} baseUrl Base URL to server, for constructing API URLs
*/
constructor(baseUrl) {
if (!baseUrl) {
throw("Must provide baseURL")
}
this.baseUrl = new URL(baseUrl, location)
this.teamIDKey = this.baseUrl.toString() + " teamID"
this.TeamID = localStorage[this.teamIDKey]
}
/**
* Fetch a MOTH resource.
*
* If anything other than a 2xx code is returned,
* this function throws an error.
*
* This always sends teamID.
* If args is set, POST will be used instead of GET
*
* @param {string} path Path to API endpoint
* @param {Object.<string,string>} args Key/Values to send in POST data
* @returns {Promise.<Response>} Response
*/
fetch(path, args={}) {
let body = new URLSearchParams(args)
if (this.TeamID && !body.has("id")) {
body.set("id", this.TeamID)
}
let url = new URL(path, this.baseUrl)
return fetch(url, {
method: "POST",
body,
cache: "no-cache",
})
}
/**
* Send a request to a JSend API endpoint.
*
* @param {string} path Path to API endpoint
* @param {Object.<string,string>} args Key/Values to send in POST
* @returns {Promise.<Object>} JSend Data
*/
async call(path, args={}) {
let resp = await this.fetch(path, args)
let obj = await resp.json()
switch (obj.status) {
case "success":
return obj.data
case "fail":
throw new Error(obj.data.description || obj.data.short || obj.data)
case "error":
throw new Error(obj.message)
default:
throw new Error(`Unknown JSend status: ${obj.status}`)
}
}
/**
* Make a new URL for the given resource.
*
* The returned URL instance will be absolute, and immune to changes to the
* page that would affect relative URLs.
*
* @returns {URL}
*/
URL(url) {
return new URL(url, this.baseUrl)
}
/**
* Are we logged in to the server?
*
* @returns {boolean}
*/
LoggedIn() {
return this.TeamID ? true : false
}
/**
* Forget about any previous Team ID.
*
* This is equivalent to logging out.
*/
Reset() {
localStorage.removeItem(this.teamIDKey)
this.TeamID = null
}
/**
* Fetch current contest state.
*
* @returns {Promise.<State>}
*/
async GetState() {
let resp = await this.fetch("/state")
let obj = await resp.json()
return new State(this, obj)
}
/**
* Log in to a team.
*
* This calls the server's registration endpoint; if the call succeds, or
* fails with "team already exists", the login is returned as successful.
*
* @param {string} teamID
* @param {string} teamName
* @returns {Promise.<string>} Success message from server
*/
async Login(teamID, teamName) {
let data = await this.call("/register", {id: teamID, name: teamName})
this.TeamID = teamID
this.TeamName = teamName
localStorage[this.teamIDKey] = teamID
return data.description || data.short
}
/**
* Submit a proposed answer for points.
*
* The returned promise will fail if anything goes wrong, including the
* proposed answer being rejected.
*
* @param {string} category Category of puzzle
* @param {number} points Point value of puzzle
* @param {string} proposed Answer to submit
* @returns {Promise.<string>} Success message
*/
async SubmitAnswer(category, points, proposed) {
let data = await this.call("/answer", {
cat: category,
points,
answer: proposed,
})
return data.description || data.short
}
/**
* Fetch a file associated with a puzzle.
*
* @param {string} category Category of puzzle
* @param {number} points Point value of puzzle
* @param {string} filename
* @returns {Promise.<Response>}
*/
GetContent(category, points, filename) {
return this.fetch(`/content/${category}/${points}/${filename}`)
}
/**
* Return a Puzzle object.
*
* New Puzzle objects only know their category and point value.
* See docstrings on the Puzzle object for more information.
*
* @param {string} category
* @param {number} points
* @returns {Puzzle}
*/
GetPuzzle(category, points) {
return new Puzzle(this, category, points)
}
}
export {
Hash,
Server,
}

View File

@ -1,37 +1,34 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title>Puzzle</title> <title>Puzzle</title>
<link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<meta charset="utf-8"> <meta charset="utf-8">
<script src="puzzle.js"></script> <link rel="icon" href="luna-moth.svg">
<script> <link rel="stylesheet" href="basic.css">
<script src="background.mjs" type="module" async></script>
</script> <script src="puzzle.mjs" type="module" async></script>
</head> </head>
<body> <body>
<h1>Puzzle</h1> <h1 id="title">[loading]</h1>
<section> <main>
<div id="puzzle"><span class="spinner"></span></div> <section id="puzzle">
<ul id="files"></ul> <p class="notification">
<p>Puzzle by <span id="authors"></span></p> Starting script...
</section> </p>
<div id="messages"></div> </section>
<form> <section class="meta"></section>
<input type="hidden" name="cat"> <ul id="files"></ul>
<input type="hidden" name="points"> <p>Puzzle by <span id="authors">[loading]</span></p>
<input type="hidden" name="xAnswer"> </section>
Team ID: <input type="text" name="id"> <br> <form class="answer">
Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br> <label for="answer">Answer:</label>
<input type="submit" value="Submit"> <input type="text" name="answer" id="answer"> <span class="answer_ok"></span>
</form> <br>
<div id="devel"></div> <input type="submit" value="Submit">
<nav> </form>
<ul> </main>
<li><a href="index.html">Puzzles</a></li> <div class="debug" class="notification"></div>
<li><a href="scoreboard.html">Scoreboard</a></li> <div class="toasts"></div>
</ul>
</nav>
</body> </body>
</html> </html>

View File

@ -1,225 +0,0 @@
// jshint asi:true
// prettify adds classes to various types, returning an HTML string.
function prettify(key, val) {
switch (key) {
case "Body":
return '[HTML]'
}
return val
}
// devel_addin drops a bunch of development extensions into element e.
// It will only modify stuff inside e.
function devel_addin(e) {
let h = e.appendChild(document.createElement("h2"))
h.textContent = "Developer Output"
let log = window.puzzle.Debug.Log || []
if (log.length > 0) {
e.appendChild(document.createElement("h3")).textContent = "Log"
let le = e.appendChild(document.createElement("ul"))
for (entry of log) {
le.appendChild(document.createElement("li")).textContent = entry
}
}
e.appendChild(document.createElement("h3")).textContent = "Puzzle object"
let hobj = JSON.stringify(window.puzzle, prettify, 2)
let d = e.appendChild(document.createElement("pre"))
d.classList.add("object")
d.innerHTML = hobj
e.appendChild(document.createElement("p")).textContent = "This debugging information will not be available to participants."
}
// Hash routine used in v3.4 and earlier
function djb2hash(buf) {
let h = 5381
for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte
// JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
// So we have to do "unsigned right shift" by zero to get it back to unsigned.
h = (((h * 33) + c) & 0xffffffff) >>> 0
}
return h
}
// The routine used to hash answers in compiled puzzle packages
async function sha256Hash(message) {
const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // hash the message
const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string
return hashHex;
}
// Is the provided answer possibly correct?
async function checkAnswer(answer) {
let answerHashes = []
answerHashes.push(djb2hash(answer))
answerHashes.push(await sha256Hash(answer))
for (let hash of answerHashes) {
for (let correctHash of window.puzzle.AnswerHashes) {
if (hash == correctHash) {
return true
}
}
}
return false
}
// Pop up a message
function toast(message, timeout=5000) {
let p = document.createElement("p")
p.innerText = message
document.getElementById("messages").appendChild(p)
setTimeout(
e => { p.remove() },
timeout
)
}
// When the user submits an answer
function submit(e) {
e.preventDefault()
let data = new FormData(e.target)
window.data = data
fetch("answer", {
method: "POST",
body: data,
})
.then(resp => {
if (resp.ok) {
resp.json()
.then(obj => {
toast(obj.data.description)
})
} else {
toast("Error submitting your answer. Try again in a few seconds.")
console.log(resp)
}
})
.catch(err => {
toast("Error submitting your answer. Try again in a few seconds.")
console.log(err)
})
}
async function loadPuzzle(categoryName, points, puzzleId) {
let puzzle = document.getElementById("puzzle")
let base = "content/" + categoryName + "/" + puzzleId + "/"
let resp = await fetch(base + "puzzle.json")
if (! resp.ok) {
console.log(resp)
let err = await resp.text()
Array.from(puzzle.childNodes).map(e => e.remove())
p = puzzle.appendChild(document.createElement("p"))
p.classList.add("Error")
p.textContent = err
return
}
// Make the whole puzzle available
window.puzzle = await resp.json()
// Populate authors
document.getElementById("authors").textContent = window.puzzle.Authors.join(", ")
// If answers are provided, this is the devel server
if (window.puzzle.Answers.length > 0) {
devel_addin(document.getElementById("devel"))
}
// Load scripts
for (let script of (window.puzzle.Scripts || [])) {
let st = document.createElement("script")
document.head.appendChild(st)
st.src = base + script
}
// List associated files
for (let fn of (window.puzzle.Attachments || [])) {
let li = document.createElement("li")
let a = document.createElement("a")
a.href = base + fn
a.innerText = fn
li.appendChild(a)
document.getElementById("files").appendChild(li)
}
// Prefix `base` to relative URLs in the puzzle body
let doc = new DOMParser().parseFromString(window.puzzle.Body, "text/html")
for (let se of doc.querySelectorAll("[src],[href]")) {
se.outerHTML = se.outerHTML.replace(/(src|href)="([^/]+)"/i, "$1=\"" + base + "$2\"")
}
// If a validation pattern was provided, set that
if (window.puzzle.AnswerPattern) {
document.querySelector("#answer").pattern = window.puzzle.AnswerPattern
}
// Replace puzzle children with what's in `doc`
Array.from(puzzle.childNodes).map(e => e.remove())
Array.from(doc.body.childNodes).map(e => puzzle.appendChild(e))
document.title = categoryName + " " + points
document.querySelector("body > h1").innerText = document.title
document.querySelector("input[name=cat]").value = categoryName
document.querySelector("input[name=points]").value = points
}
// Check to see if the answer might be correct
// This might be better done with the "constraint validation API"
// https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation#Validating_forms_using_JavaScript
function answerCheck(e) {
let answer = e.target.value
let ok = document.querySelector("#answer_ok")
// You have to provide someplace to put the check
if (! ok) {
return
}
checkAnswer(answer)
.then (correct => {
if (correct) {
ok.textContent = "⭕"
ok.title = "Possibly correct"
} else {
ok.textContent = "❌"
ok.title = "Definitely not correct"
}
})
}
function init() {
let params = new URLSearchParams(window.location.search)
let categoryName = params.get("cat")
let points = params.get("points")
let puzzleId = params.get("pid")
if (categoryName && points) {
loadPuzzle(categoryName, points, puzzleId || points)
}
let teamId = sessionStorage.getItem("id")
if (teamId) {
document.querySelector("input[name=id]").value = teamId
}
if (document.querySelector("#answer")) {
document.querySelector("#answer").addEventListener("input", answerCheck)
}
document.querySelector("form").addEventListener("submit", submit)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

239
theme/puzzle.mjs Normal file
View File

@ -0,0 +1,239 @@
/**
* Functionality for puzzle.html (Puzzle display / answer form)
*/
import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
const server = new moth.Server(".")
/**
* Handle a submit event on a form.
*
* Called when the user submits the form,
* either by clicking a "submit" button,
* or by some other means provided by the browser,
* like hitting the Enter key.
*
* @param {Event} event
*/
async function formSubmitHandler(event) {
event.preventDefault()
let data = new FormData(event.target)
let proposed = data.get("answer")
let message
console.groupCollapsed("Submit answer")
console.info(`Proposed answer: ${proposed}`)
try {
message = await window.app.puzzle.SubmitAnswer(proposed)
common.Toast(message)
}
catch (err) {
common.Toast(err)
}
console.groupEnd("Submit answer")
}
/**
* Handle an input event on the answer field.
*
* @param {Event} event
*/
async function answerInputHandler(event) {
let answer = event.target.value
let correct = await window.app.puzzle.IsPossiblyCorrect(answer)
for (let ok of event.target.parentElement.querySelectorAll(".answer_ok")) {
if (correct) {
ok.textContent = "⭕"
ok.title = "Possibly correct"
} else {
ok.textContent = "❌"
ok.title = "Definitely not correct"
}
}
}
/**
* Return the puzzle content element, possibly with everything cleared out of it.
*
* @param {boolean} clear Should the element be cleared of children? Default true.
* @returns {Element}
*/
function puzzleElement(clear=true) {
let e = document.querySelector("#puzzle")
if (clear) {
while (e.firstChild) e.firstChild.remove()
}
return e
}
/**
* Display an error in the puzzle area, and also send it to the console.
*
* Errors are rendered in the puzzle area, so the user can see a bit more about
* what the problem is.
*
* @param {string} error
*/
function error(error) {
console.error(error)
let e = puzzleElement().appendChild(document.createElement("pre"))
e.classList.add("error")
e.textContent = error.Body || error
}
/**
* Set the answer and invoke input handlers.
*
* Makes sure the Circle Of Success gets updated.
*
* @param {string} s
*/
function SetAnswer(s) {
let e = document.querySelector("#answer")
e.value = s
e.dispatchEvent(new Event("input"))
}
function writeObject(e, obj) {
let keys = Object.keys(obj)
keys.sort()
for (let key of keys) {
let val = obj[key]
if ((key === "Body") || (!val) || (val.length === 0)) {
continue
}
let d = e.appendChild(document.createElement("dt"))
d.textContent = key
let t = e.appendChild(document.createElement("dd"))
if (Array.isArray(val)) {
let vi = t.appendChild(document.createElement("ul"))
vi.multiple = true
for (let a of val) {
let opt = vi.appendChild(document.createElement("li"))
opt.textContent = a
}
} else if (typeof(val) === "object") {
writeObject(t, val)
} else {
t.textContent = val
}
}
}
/**
* Load the given puzzle.
*
* @param {string} category
* @param {number} points
*/
async function loadPuzzle(category, points) {
console.groupCollapsed("Loading puzzle:", category, points)
let contentBase = new URL(`content/${category}/${points}/`, common.BaseURL)
// Tell user we're loading
puzzleElement().appendChild(document.createElement("progress"))
for (let qs of ["#authors", "#title", "title"]) {
for (let e of document.querySelectorAll(qs)) {
e.textContent = "[loading]"
}
}
let puzzle = server.GetPuzzle(category, points)
console.time("Populate")
try {
await puzzle.Populate()
}
catch {
let error = puzzleElement().appendChild(document.createElement("pre"))
error.classList.add("notification", "error")
error.textContent = puzzle.Error.Body
return
}
finally {
console.timeEnd("Populate")
}
console.info(`Setting base tag to ${contentBase}`)
let baseElement = document.head.appendChild(document.createElement("base"))
baseElement.href = contentBase
console.info("Tweaking HTML...")
let title = `${category} ${points}`
document.querySelector("title").textContent = title
document.querySelector("#title").textContent = title
document.querySelector("#authors").textContent = puzzle.Authors.join(", ")
if (puzzle.AnswerPattern) {
document.querySelector("#answer").pattern = puzzle.AnswerPattern
}
puzzleElement().innerHTML = puzzle.Body
console.info("Adding attached scripts...")
for (let script of (puzzle.Scripts || [])) {
let st = document.createElement("script")
document.head.appendChild(st)
st.src = new URL(script, contentBase)
}
console.info("Listing attached files...")
for (let fn of (puzzle.Attachments || [])) {
let li = document.createElement("li")
let a = document.createElement("a")
a.href = new URL(fn, contentBase)
a.innerText = fn
li.appendChild(a)
document.getElementById("files").appendChild(li)
}
console.info("Filling debug information...")
for (let e of document.querySelectorAll(".debug")) {
if (puzzle.Answers.length > 0) {
writeObject(e, puzzle)
} else {
e.classList.add("hidden")
}
}
window.app.puzzle = puzzle
console.info("window.app.puzzle =", window.app.puzzle)
console.groupEnd()
return puzzle
}
async function init() {
window.app = {}
window.setanswer = (str => SetAnswer(str))
for (let form of document.querySelectorAll("form.answer")) {
form.addEventListener("submit", formSubmitHandler)
for (let e of form.querySelectorAll("[name=answer]")) {
e.addEventListener("input", answerInputHandler)
}
}
// There isn't a more graceful way to "unload" scripts attached to the current puzzle
window.addEventListener("hashchange", () => location.reload())
// Make all links absolute, because we're going to be changing the base URL
for (let e of document.querySelectorAll("[href]")) {
e.href = new URL(e.href, common.BaseURL)
}
let hashpart = location.hash.split("#")[1] || ""
let catpoints = hashpart.split(":")
let category = catpoints[0]
let points = Number(catpoints[1])
if (!category && !points) {
error(`Doesn't look like a puzzle reference: ${hashpart}`)
return
}
window.app.puzzle = await loadPuzzle(category, points)
}
common.WhenDOMLoaded(init)

48340
theme/reports/NICEFramework2017.json Executable file

File diff suppressed because it is too large Load Diff

55
theme/reports/ksa.html Normal file
View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>KSA Report</title>
<meta name="viewport" content="width=device-width">
<meta charset="utf-8">
<script src="ksa.mjs" type="module" async></script>
<script src="../background.mjs" type="module" async></script>
<link rel="stylesheet" href="../basic.css">
</head>
<body>
<h1>KSA Report</h1>
<main>
<p>
This report shows all KSAs covered by this server so far.
This is not a report on your progress, but rather
what you would have covered if you had worked every exercise available.
</p>
<div class="notification">
<p class="doing"></p>
<progress class="doing"></progress>
</div>
<h2>All KSAs across all content</h2>
<ul class="allKSAs"></ul>
<h2>All KSAs by Category</h2>
<div class="KSAsByCategory">
</div>
<h2>KSAs by Puzzle</h2>
<table class="puzzles">
<thead>
<tr>
<th>Category</th>
<th>Points</th>
<th>KSAs</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
<template id="puzzlerow">
<tr>
<td class="category"></td>
<td class="points"></td>
<td class="ksas"></td>
<td><pre class="error"></pre></td>
</tr>
</template>
</tbody>
</table>
</main>
</body>
</html>

140
theme/reports/ksa.mjs Normal file
View File

@ -0,0 +1,140 @@
import * as moth from "../moth.mjs"
import * as common from "../common.mjs"
const server = new moth.Server("../")
/**
* Update "doing" indicators
*
* @param {String | null} what Text to display, or null to not update text
* @param {Number | null} finished Percentage complete to display, or null to not update progress
*/
function doing(what, finished = null) {
for (let e of document.querySelectorAll(".doing")) {
e.classList.remove("hidden")
if (what) {
e.textContent = what
}
if (finished) {
e.value = finished
} else {
e.removeAttribute("value")
}
}
}
function done() {
for (let e of document.querySelectorAll(".doing")) {
e.classList.add("hidden")
}
}
async function GetNice() {
let NiceElementsByIdentifier = {}
let resp = await fetch("NICEFramework2017.json")
let obj = await resp.json()
for (let e of obj.elements) {
NiceElementsByIdentifier[e.element_identifier] = e
}
return NiceElementsByIdentifier
}
/**
* Fetch a puzzle, and fill its KSAs and rows.
*
* This is done once per puzzle, in an asynchronous function, allowing the
* application to perform multiple blocking operations simultaneously.
*/
async function FetchAndFill(puzzle, KSAs, rows) {
try {
await puzzle.Populate()
}
catch (error) {
// Keep on going with whatever Populate was able to fill
}
for (let KSA of (puzzle.KSAs || [])) {
KSAs.add(KSA)
}
for (let row of rows) {
row.querySelector(".category").textContent = puzzle.Category
row.querySelector(".points").textContent = puzzle.Points
row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ")
row.querySelector(".error").textContent = puzzle.Error.Body
}
}
async function init() {
doing("Fetching NICE framework data")
let nicePromise = GetNice()
doing("Retrieving server state")
let state = await server.GetState()
doing("Retrieving all puzzles")
let KSAsByCategory = {}
let puzzlerowTemplate = document.querySelector("template#puzzlerow")
let puzzles = state.Puzzles()
let promises = []
for (let category of state.Categories()) {
KSAsByCategory[category] = new Set()
}
let pending = puzzles.length
for (let puzzle of puzzles) {
// Make space in the table, so everything fills in sorted order
let rows = []
for (let tbody of document.querySelectorAll("tbody")) {
let row = puzzlerowTemplate.content.cloneNode(true).firstElementChild
tbody.appendChild(row)
rows.push(row)
}
// Queue up a fetch, and update progress bar
let promise = FetchAndFill(puzzle, KSAsByCategory[puzzle.Category], rows)
promises.push(promise)
promise.then(() => doing(null, 1 - (--pending / puzzles.length)))
if (promises.length > 50) {
// Chrome runs out of resources if you queue up too many of these at once
await Promise.all(promises)
promises = []
}
}
await Promise.all(promises)
doing("Retrieving NICE identifiers")
let NiceElementsByIdentifier = await nicePromise
doing("Filling KSAs By Category")
let allKSAs = new Set()
for (let div of document.querySelectorAll(".KSAsByCategory")) {
for (let category of state.Categories()) {
doing(`Filling KSAs for category: ${category}`)
let KSAs = [...KSAsByCategory[category]]
KSAs.sort()
div.appendChild(document.createElement("h3")).textContent = category
let ul = div.appendChild(document.createElement("ul"))
for (let k of KSAs) {
let ksa = k.split(/\s+/)[0]
let ne = NiceElementsByIdentifier[ksa] || { text: "???" }
let text = `${ksa}: ${ne.text}`
ul.appendChild(document.createElement("li")).textContent = text
allKSAs.add(text)
}
}
}
doing("Filling KSAs")
for (let e of document.querySelectorAll(".allKSAs")) {
let KSAs = [...allKSAs]
KSAs.sort()
for (let text of KSAs) {
e.appendChild(document.createElement("li")).textContent = text
}
}
done()
}
common.WhenDOMLoaded(init)

114
theme/scoreboard.css Normal file
View File

@ -0,0 +1,114 @@
/* GHC displays: 1024x1820 */
@media screen and (max-aspect-ratio: 4/5) and (min-height: 1600px) {
html {
font-size: 20pt;
}
}
#chart {
background-color: rgba(0, 0, 0, 0.8);
}
.logo {
text-align: center;
background-color: rgba(255, 255, 255, 0.2);
font-family: Montserrat, sans-serif;
font-weight: 500;
border-radius: 10px;
font-size: 1.2em;
}
.cyber {
color: black;
}
.fire {
color: #d94a1f;
}
.announcement.floating {
position: fixed;
bottom: 0;
width: 100hw;
max-width: inherit;
}
.announcement {
background-color: rgba(255,255,255,0.5);
color: black;
padding: 0.25em;
border-radius: 5px;
max-width: 20em;
text-align: center;
display: flex;
align-items: flex-end;
justify-content: space-around;
font-size: 1.3em;
flex-wrap: wrap;
}
.announcement div {
margin: 1em;
max-width: 45vw;
text-align: center;
}
.location {
color: #acf;
background-color: #0008;
position: fixed;
right: 30vw;
bottom: 0;
padding: 1em;
margin: 0;
font-size: 1.2rem;
font-weight:bold;
text-decoration: underline;
}
.qrcode {
width: 30vw;
}
.examples {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.examples > div {
margin: 0.5em;
max-width: 40%;
}
/** Scoreboard */
#rankings {
width: 100%;
position: relative;
background-color: #000c;
}
#rankings div {
height: 1.4rem;
}
#rankings div:nth-child(6n){
background-color: #ccc1;
}
#rankings div:nth-child(6n+3) {
background-color: #0f01;
}
#rankings span {
font-size: 75%;
display: inline-block;
overflow: hidden;
height: 1.4em;
}
#rankings span.teamname {
height: auto;
font-size: inherit;
color: white;
background-color: #000e;
border-radius: 3px;
position: absolute;
right: 0.2em;
}
#rankings div * {white-space: nowrap;}
.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;}
.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;}
.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;}
.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;}
.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;}
.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;}
.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}

View File

@ -3,22 +3,15 @@
<head> <head>
<title>Scoreboard</title> <title>Scoreboard</title>
<link rel="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<link rel="stylesheet" href="scoreboard.css">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<script src="moment.min.js" async></script> <script src="https://cdn.jsdelivr.net/npm/luxon@1.26.0"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" async></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@3.0.2"></script>
<script src="scoreboard.js" async></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.2.1"></script>
<script type="module" src="scoreboard.mjs"></script>
</head> </head>
<body class="wide"> <body>
<h4 id="location"></h4> <div id="rankings"></div>
<section class="rotate"> <div class="location"></div>
<div id="chart"></div>
<div id="rankings"></div>
</section>
<nav>
<ul>
<li><a href="index.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body> </body>
</html> </html>

View File

@ -1,257 +0,0 @@
// jshint asi:true
function scoreboardInit() {
chartColors = [
"rgb(255, 99, 132)",
"rgb(255, 159, 64)",
"rgb(255, 205, 86)",
"rgb(75, 192, 192)",
"rgb(54, 162, 235)",
"rgb(153, 102, 255)",
"rgb(201, 203, 207)"
]
function update(state) {
window.state = state
for (let rotate of document.querySelectorAll(".rotate")) {
rotate.appendChild(rotate.firstElementChild)
}
let element = document.getElementById("rankings")
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 stateHistory = JSON.parse(localStorage.getItem("stateHistory")) || []
if (stateHistory.length >= 20) {
stateHistory.shift()
}
stateHistory.push(state)
localStorage.setItem("stateHistory", JSON.stringify(stateHistory))
let teams = {}
let highestCategoryScore = {} // map[string]int
// Initialize data structures
for (let teamId in teamNames) {
teams[teamId] = {
categoryScore: {}, // map[string]int
overallScore: 0, // int
historyLine: [], // []{x: int, y: int}
name: teamNames[teamId],
id: teamId
}
}
// Dole out points
for (let entry of pointsLog) {
let timestamp = entry[0]
let teamId = entry[1]
let category = entry[2]
let points = entry[3]
let team = teams[teamId]
let score = team.categoryScore[category] || 0
score += points
team.categoryScore[category] = score
let highest = highestCategoryScore[category] || 0
if (score > highest) {
highestCategoryScore[category] = score
}
}
for (let teamId in teamNames) {
teams[teamId].categoryScore = {}
}
for (let entry of pointsLog) {
let timestamp = entry[0]
let teamId = entry[1]
let category = entry[2]
let points = entry[3]
let team = teams[teamId]
let score = team.categoryScore[category] || 0
score += points
team.categoryScore[category] = score
let overall = 0
for (let cat in team.categoryScore) {
overall += team.categoryScore[cat] / highestCategoryScore[cat]
}
team.historyLine.push({t: new Date(timestamp * 1000), y: overall})
}
// Compute overall scores based on current highest
for (let teamId in teams) {
let team = teams[teamId]
team.overallScore = 0
for (let cat in team.categoryScore) {
team.overallScore += team.categoryScore[cat] / highestCategoryScore[cat]
}
}
// Sort by team score
function teamCompare(a, b) {
return a.overallScore - b.overallScore
}
// Figure out how to order each team on the scoreboard
let winners = []
for (let teamId in teams) {
winners.push(teams[teamId])
}
winners.sort(teamCompare)
winners.reverse()
// Let's make some better names for things we've computed
let winningScore = winners[0].overallScore
let numCategories = Object.keys(highestCategoryScore).length
// Clear out the element we're about to populate
Array.from(element.childNodes).map(e => e.remove())
let maxWidth = 100 / winningScore
for (let team of winners) {
let row = document.createElement("div")
let ncat = 0
let teamPoints=document.createElement("span")
teamPoints.classList.add("teampoints")
for (let category in highestCategoryScore) {
let catHigh = highestCategoryScore[category]
let catTeam = team.categoryScore[category] || 0
let catPct = catTeam / catHigh
let width = maxWidth * catPct
let bar = document.createElement("span")
bar.classList.add("category")
bar.classList.add("cat" + ncat)
bar.style.width = width + "%"
bar.textContent = category + ": " + catTeam
bar.title = bar.textContent
teamPoints.appendChild(bar)
ncat += 1
}
row.appendChild(teamPoints)
let te = document.createElement("span")
te.classList.add("teamname")
te.textContent = team.name
row.appendChild(te)
element.appendChild(row)
}
let datasets = []
for (let i in winners) {
if (i > 5) {
break
}
let team = winners[i]
let color = chartColors[i % chartColors.length]
datasets.push({
label: team.name,
backgroundColor: color,
borderColor: color,
data: team.historyLine,
lineTension: 0,
fill: false
})
}
let config = {
type: "line",
data: {
datasets: datasets
},
options: {
responsive: true,
scales: {
xAxes: [{
display: true,
type: "time",
time: {
tooltipFormat: "ll HH:mm"
},
scaleLabel: {
display: true,
labelString: "Time"
}
}],
yAxes: [{
display: true,
scaleLabel: {
display: true,
labelString: "Points"
}
}]
},
tooltips: {
mode: "index",
intersect: false
},
hover: {
mode: "nearest",
intersect: true
}
}
}
let chart = document.querySelector("#chart")
if (chart) {
let canvas = chart.querySelector("canvas")
if (! canvas) {
canvas = document.createElement("canvas")
chart.appendChild(canvas)
}
let myline = new Chart(canvas.getContext("2d"), config)
myline.update()
}
}
function refresh() {
fetch("state")
.then(resp => {
return resp.json()
})
.then(obj => {
update(obj)
})
.catch(err => {
console.log(err)
})
}
function init() {
let base = window.location.href.replace("scoreboard.html", "")
let location = document.querySelector("#location")
if (location) {
location.textContent = base
}
setInterval(refresh, 60000)
refresh()
}
init()
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", scoreboardInit)
} else {
scoreboardInit()
}

95
theme/scoreboard.mjs Normal file
View File

@ -0,0 +1,95 @@
import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
const server = new moth.Server(".")
const ReplayDuration = 0.3 * common.Second
const MaxFrameRate = 60
/** Don't let any team's score exceed this percentage width */
const MaxScoreWidth = 95
/**
* Returns a promise that resolves after timeout.
*
* @param {Number} timeout How long to sleep (milliseconds)
* @returns {Promise}
*/
function sleep(timeout) {
return new Promise(resolve => setTimeout(resolve, timeout));
}
/**
* Pull new points log, and update the scoreboard.
*
* The update is animated, because I think that looks cool.
*/
async function update() {
let config = await common.Config()
for (let e of document.querySelectorAll(".location")) {
e.textContent = common.BaseURL
e.classList.toggle("hidden", !config.URLInScoreboard)
}
let state = await server.GetState()
let rankingsElement = document.querySelector("#rankings")
let logSize = state.PointsLog.length
// Figure out the timing so that we can replay the scoreboard in about
// ReplayDuration, but no more than 24 frames per second.
let frameModulo = 1
let delay = 0
while (delay < (common.Second / MaxFrameRate)) {
frameModulo += 1
delay = ReplayDuration / (logSize / frameModulo)
}
let frame = 0
for (let scores of state.ScoresHistory()) {
frame += 1
if ((frame < state.PointsLog.length) && (frame % frameModulo)) {
continue
}
while (rankingsElement.firstChild) rankingsElement.firstChild.remove()
let sortedTeamIDs = [...scores.TeamIDs]
sortedTeamIDs.sort((a, b) => scores.CyFiScore(a) - scores.CyFiScore(b))
sortedTeamIDs.reverse()
let topScore = scores.CyFiScore(sortedTeamIDs[0])
for (let teamID of sortedTeamIDs) {
let teamName = state.TeamNames[teamID]
let row = rankingsElement.appendChild(document.createElement("div"))
let heading = row.appendChild(document.createElement("span"))
heading.textContent = teamName
heading.classList.add("teamname")
let categoryNumber = 0
for (let category of scores.Categories) {
let score = scores.CyFiCategoryScore(category, teamID)
if (!score) {
continue
}
let block = row.appendChild(document.createElement("span"))
let points = scores.GetPoints(category, teamID)
let width = MaxScoreWidth * score / topScore
block.textContent = category
block.title = `${points} points`
block.style.width = `${width}%`
block.classList.add(`cat${categoryNumber}`)
categoryNumber += 1
}
}
await sleep(delay)
}
}
function init() {
setInterval(update, common.Minute)
update()
}
common.WhenDOMLoaded(init)

View File

@ -1,45 +1,29 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title>Redeem Token</title> <title>Redeem Token</title>
<link rel="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<script src="puzzle.js"></script> <script src="token.mjs" type="module" async></script>
<script>
function tokenInput(e) {
let vals = e.target.value.split(":")
document.querySelector("input[name=cat]").value = vals[0]
document.querySelector("input[name=points]").value = vals[1]
document.querySelector("input[name=answer]").value = vals[2]
}
function tokenInit() {
document.querySelector("input[name=token]").addEventListener("input", tokenInput)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", tokenInit)
} else {
tokenInit()
}
</script>
</head> </head>
<body> <body>
<h1>Redeem Token</h1> <h1>Redeem Token</h1>
<div id="messages"></div> <main>
<form id="tokenForm"> <p>
<input type="hidden" name="cat"> Have you found a token?
<input type="hidden" name="points"> </p>
<input type="hidden" name="answer"> <p></p>
Team ID: <input type="text" name="id"> <br> Tokens look like
Token: <input type="text" name="token"> <br> <code>category:5:xylep-radar-nanox</code>
<p>
Tokens may be redeemed here for points in their category.
Tokens can appear anywhere: online, on slips of paper, projected onto screens…
</p>
</main>
<form class="token"</form>
<label for="token">Token:</label> <input type="text" name="token" id="token"> <br>
<input type="submit" value="Submit"> <input type="submit" value="Submit">
</form> </form>
<nav> <div class="toasts"></div>
<ul>
<li><a href="puzzle-list.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body> </body>
</html> </html>

48
theme/token.mjs Normal file
View File

@ -0,0 +1,48 @@
/**
* Functionality for token.html
*/
import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
const server = new moth.Server(".")
/**
* Handle a submit event on a form.
*
* @param {SubmitEvent} event
*/
async function formSubmitHandler(event) {
event.preventDefault()
let formData = new FormData(event.target)
let token = formData.get("token")
let vals = token.split(":")
let category = vals[0]
let points = Number(vals[1])
let proposed = vals[2]
if (!category || !points || !proposed) {
console.info("Not a token:", vals)
common.Toast("This is not a properly-formed token")
return
}
try {
let message = await server.SubmitAnswer(category, points, proposed)
common.Toast(message)
}
catch (error) {
if (error.message == "incorrect answer") {
common.Toast("Unknown token")
} else {
console.error(error)
common.Toast(error)
}
}
}
function init() {
for (let form of document.querySelectorAll("form.token")) {
form.addEventListener("submit", formSubmitHandler)
}
}
common.WhenDOMLoaded(init)