Compare commits

..

2 Commits

Author SHA1 Message Date
Neale Pickett fa5ea87f22 A ton of half-baked changes 2022-05-10 13:20:54 -06:00
Neale Pickett 55254234bf new SubFS that can tell you the full FS path 2021-12-03 17:58:08 -07:00
100 changed files with 2686 additions and 53202 deletions

9
.gitignore vendored
View File

@ -1,10 +1,5 @@
*~
*#
/.idea
.idea
/vendor/
/__debug_bin
winmoth.*.zip
/*.tar.gz
/transpile
/mothd
/*.exe
__debug_bin

View File

@ -1,59 +1,19 @@
stages:
- test
- build
- push
Run unit tests:
test:
stage: test
image: &goimage golang:1.21
image: golang:1.17
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
winbuild:
stage: build
image: *goimage
needs: ["Run unit tests"]
script:
- GOOS=windows GOARCH=amd64 go build ./cmd/mothd
- cp build/package/moth-devel.bat .
artifacts:
paths:
- mothd.exe
- moth-devel.bat
- theme/*
- go test ./...
push:
stage: push
needs: ["Run unit tests"]
rules:
- if: $CI_COMMIT_TAG
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

View File

@ -4,44 +4,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v4.5.0] - 2024-01-19
### Changed
- Answer hashes are now the first 4 characters of the hex-encoded SHA1 digest
- Reworked the built-in theme
- Devel mode no longer accepts an empty team ID
- messages.html moved into theme
### Added
- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript
- Exported state now includes "Enabled" boolean
- New `Extra` field on puzzles will allow arbitrary metadata on puzzles.
## [v4.4.11] - 2023-04-11
### Changed
- CI/CD now builds tags
## [v4.4.10] - 2022-10-21
### Changed
- `enabled` file is no longer used
- `hours.txt` parsing logs more verbosely
- Participant IDs are no longer used anywhere
- A few changes to CI/CD test reporting
## [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,

View File

@ -129,36 +129,10 @@ Both came with the following license:
> OTHER DEALINGS IN THE FONT SOFTWARE.
Go Fonts
=======
Javascript MD5 Library
======================
The Go fonts were obtained from
https://go.googlesource.com/image
Obtained from <https://github.com/blueimp/JavaScript-MD5>, which says:
Copyright (c) 2009 The Go Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
> The JavaScript MD5 script is released under the
> [MIT license](http://www.opensource.org/licenses/MIT).

View File

@ -1,7 +1,8 @@
Dirtbags Monarch Of The Hill Server
=====================
[![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)](https://goreportcard.com/report/github.com/dirtbags/moth)
![Build badge](https://github.com/dirtbags/moth/workflows/Build/Test/Push/badge.svg)
![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)
Monarch Of The Hill (MOTH) is a puzzle server.
We (the authors) have used it for instructional and contest events called

View File

@ -43,11 +43,6 @@ case $ACTION in
run docker push $image
done
;;
release)
run go build -v ./cmd/mothd
run go build -v ./cmd/transpile
run tar czf moth-$(git tag --contains).$(uname -s)-$(uname -m).tar.gz mothd transpile theme
;;
*)
echo "Unknown action: $1" 1>&2
exit 1

View File

@ -1,5 +1,4 @@
ARG GO_VERSION=1.21-alpine
FROM docker.io/library/golang:${GO_VERSION} AS builder
FROM golang:1 AS builder
COPY go.* /src/
COPY pkg /src/pkg/
COPY cmd /src/cmd/

View File

@ -2,34 +2,19 @@
set -e
cd $(dirname $0)
base=../..
cd $(dirname $0)/../..
VERSION=$(cat $base/CHANGELOG.md | awk -F '[][]' '/^## \[/ {print $2; exit}')
GO_VERSION=$(cat $base/go.mod | sed -n 's/^go //p')
PODMAN=$(command -v podman || echo docker)
VERSION=$(cat CHANGELOG.md | awk -F '[][]' '/^## \[/ {print $2; exit}')
(
zipfile=winmoth.$VERSION.zip
echo "=== Building $zipfile"
mkdir -p winmoth winmoth/state winmoth/puzzles winmoth/mothballs
echo devel > winmoth/state/teamids.txt
cp moth-devel.bat winmoth
cp -a $base/theme winmoth
(
cd winmoth
GOOS=windows GOARCH=amd64 go build ../$base/cmd/mothd/...
)
zip -r $zipfile winmoth
rm -rf winmoth
)
tag=dirtbags/moth:$VERSION
echo "==== Building $tag"
docker build \
--build-arg GO_VERSION=$GO_VERSION \
--build-arg http_proxy --build-arg https_proxy --build-arg no_proxy \
--tag $tag \
-f Containerfile $base
for target in moth; do
tag=dirtbags/$target:$VERSION
echo "==== Building $tag"
$PODMAN build \
--build-arg http_proxy --build-arg https_proxy --build-arg no_proxy \
--tag $tag \
--target $target \
-f build/package/Containerfile .
done
exit 0

View File

@ -1,4 +0,0 @@
mkdir state
mkdir puzzles
echo devel > state/teamids.txt
.\mothd.exe -puzzles puzzles

View File

@ -1,24 +0,0 @@
#! /bin/sh
set -e
cd $(dirname $0)
base=../..
VERSION=$(cat $base/CHANGELOG.md | awk -F '[][]' '/^## \[/ {print $2; exit}')
(
zipfile=winmoth.$VERSION.zip
echo "=== Building $zipfile"
mkdir -p winmoth winmoth/state winmoth/puzzles winmoth/mothballs
echo devel > winmoth/state/teamids.txt
cp moth-devel.bat winmoth
cp -a $base/theme winmoth
(
cd winmoth
GOOS=windows GOARCH=amd64 go build ../$base/cmd/mothd/...
)
zip -r $zipfile winmoth
rm -rf winmoth
)

View File

@ -8,7 +8,7 @@ import (
"strings"
"time"
"github.com/dirtbags/moth/v4/pkg/jsend"
"github.com/dirtbags/moth/pkg/jsend"
)
// HTTPServer is a MOTH HTTP server
@ -44,8 +44,9 @@ func (h *HTTPServer) HandleMothFunc(
mothHandler func(MothRequestHandler, http.ResponseWriter, *http.Request),
) {
handler := func(w http.ResponseWriter, req *http.Request) {
participantID := req.FormValue("pid")
teamID := req.FormValue("id")
mh := h.server.NewHandler(teamID)
mh := h.server.NewHandler(participantID, teamID)
mothHandler(mh, w, req)
}
h.HandleFunc(h.base+pattern, handler)

View File

@ -4,15 +4,19 @@ import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http/httptest"
"net/url"
"os"
"testing"
"github.com/spf13/afero"
"time"
)
const TestParticipantID = "shipox"
func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest.ResponseRecorder {
vals := url.Values{}
vals.Set("pid", TestParticipantID)
vals.Set("id", TestTeamID)
for k, v := range args {
vals.Set(k, v)
@ -29,7 +33,11 @@ func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest
}
func TestHttpd(t *testing.T) {
server := NewTestServer()
server, err := NewTestServer()
if err != nil {
log.Fatal(err)
}
defer server.cleanup()
hs := NewHTTPServer("/", server.MothServer)
if r := hs.TestRequest("/", nil); r.Result().StatusCode != 200 {
@ -45,7 +53,7 @@ func TestHttpd(t *testing.T) {
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if r.Body.String() != `{"Config":{"Devel":false},"Enabled":true,"TeamNames":{},"PointsLog":[],"Puzzles":{}}` {
} else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{},"PointsLog":[],"Puzzles":{}}` {
t.Error("Unexpected state", r.Body.String())
}
@ -67,11 +75,11 @@ func TestHttpd(t *testing.T) {
t.Error("Register failed", r.Body.String())
}
server.refresh()
time.Sleep(TestMaintenanceInterval)
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if r.Body.String() != `{"Config":{"Devel":false},"Enabled":true,"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]}}` {
t.Error("Unexpected state", r.Body.String())
}
@ -109,7 +117,7 @@ func TestHttpd(t *testing.T) {
t.Error("Unexpected body", r.Body.String())
}
server.refresh()
time.Sleep(TestMaintenanceInterval)
if r := hs.TestRequest("/content/pategory/2/puzzle.json", nil); r.Result().StatusCode != 200 {
t.Error(r.Result())
@ -121,20 +129,24 @@ func TestHttpd(t *testing.T) {
} else if err := json.Unmarshal(r.Body.Bytes(), &state); err != nil {
t.Error(err)
} else if len(state.PointsLog) != 1 {
t.Errorf("Points log wrong length. Wanted 1, got %v (length %d)", state.PointsLog, len(state.PointsLog))
t.Error("Points log wrong length")
} else if len(state.Puzzles["pategory"]) != 2 {
t.Error("Didn't unlock next puzzle")
}
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
t.Error(r.Result())
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"points already awarded to this team in this category"}}` {
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"error awarding points: points already awarded to this team in this category"}}` {
t.Error("Unexpected body", r.Body.String())
}
}
func TestDevelMemHttpd(t *testing.T) {
srv := NewTestServer()
srv, err := NewTestServer()
if err != nil {
t.Fatal(err)
}
defer srv.cleanup()
{
hs := NewHTTPServer("/", srv.MothServer)
@ -157,9 +169,9 @@ func TestDevelMemHttpd(t *testing.T) {
}
func TestDevelFsHttps(t *testing.T) {
fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata")
transpilerProvider := NewTranspilerProvider(fs)
srv := NewMothServer(Configuration{Devel: true}, NewTestTheme(), NewTestState(), transpilerProvider)
fsys := os.DirFS("testdata")
transpilerProvider := NewTranspilerProvider(fsys)
srv := NewMothServer(Configuration{Devel: true}, NewTheme("testdata/theme"), NewTestState(), transpilerProvider)
hs := NewHTTPServer("/", srv)
if r := hs.TestRequest("/mothballer/cat0.mb", nil); r.Result().StatusCode != 200 {

View File

@ -1,28 +0,0 @@
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

@ -6,10 +6,7 @@ import (
"log"
"mime"
"os"
"path/filepath"
"time"
"github.com/spf13/afero"
)
func main() {
@ -55,38 +52,20 @@ func main() {
)
flag.Parse()
var theme *Theme
osfs := afero.NewOsFs()
if p, err := filepath.Abs(*themePath); err != nil {
log.Fatal(err)
} else {
theme = NewTheme(afero.NewBasePathFs(osfs, p))
}
theme := NewTheme(*themePath)
config := Configuration{}
var provider PuzzleProvider
if p, err := filepath.Abs(*mothballPath); err != nil {
log.Fatal(err)
} else {
provider = NewMothballs(afero.NewBasePathFs(osfs, p))
}
provider = NewMothballs(os.DirFS(*mothballPath))
if *puzzlePath != "" {
if p, err := filepath.Abs(*puzzlePath); err != nil {
log.Fatal(err)
} else {
provider = NewTranspilerProvider(afero.NewBasePathFs(osfs, p))
}
provider = NewTranspilerProvider(os.DirFS(*puzzlePath))
config.Devel = true
log.Println("-=- You are in development mode, champ! -=-")
}
var state StateProvider
if p, err := filepath.Abs(*statePath); err != nil {
log.Fatal(err)
} else {
state = NewState(afero.NewBasePathFs(osfs, p))
}
state = NewState(*statePath)
if config.Devel {
state = NewDevelState(state)
}

View File

@ -3,36 +3,35 @@ package main
import (
"archive/zip"
"bufio"
"bytes"
"fmt"
"io"
"io/fs"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/spf13/afero"
"github.com/spf13/afero/zipfs"
)
type zipCategory struct {
afero.Fs
zip.Reader
io.Closer
mtime time.Time
}
// Mothballs provides a collection of active mothball files (puzzle categories)
type Mothballs struct {
afero.Fs
fs.FS
categories map[string]zipCategory
categoryLock *sync.RWMutex
}
// NewMothballs returns a new Mothballs structure backed by the provided directory
func NewMothballs(fs afero.Fs) *Mothballs {
func NewMothballs(fsys fs.FS) *Mothballs {
return &Mothballs{
Fs: fs,
FS: fsys,
categories: make(map[string]zipCategory),
categoryLock: new(sync.RWMutex),
}
@ -45,8 +44,8 @@ func (m *Mothballs) getCat(cat string) (zipCategory, bool) {
return ret, ok
}
// Open returns a ReadSeekCloser corresponding to the filename in a puzzle's category and points
func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) {
// Open returns an fs.File corresponding to the filename in a puzzle's category and points
func (m *Mothballs) Open(cat string, points int, filename string) (fs.File, time.Time, error) {
zc, ok := m.getCat(cat)
if !ok {
return nil, time.Time{}, fmt.Errorf("no such category: %s", cat)
@ -112,6 +111,41 @@ func (m *Mothballs) CheckAnswer(cat string, points int, answer string) (bool, er
return false, nil
}
func (m *Mothballs) newZipCategory(f fs.File) (zipCategory, error) {
var zrc *zip.Reader
var err error
var closer io.ReadCloser = f
var zipCat zipCategory
fi, err := f.Stat()
if err != nil {
return zipCat, err
}
zipCat.mtime = fi.ModTime()
switch r := f.(type) {
case io.ReaderAt:
zrc, err = zip.NewReader(r, fi.Size())
default:
log.Println("Does not implement io.ReaderAt, buffering in RAM:", r)
buf := new(bytes.Buffer)
size, err := io.Copy(buf, f)
if err != nil {
return zipCat, err
}
f.Close()
reader := bytes.NewReader(buf.Bytes())
zrc, err = zip.NewReader(reader, size)
closer = io.NopCloser(reader)
}
if err != nil {
return zipCat, err
}
zipCat.Reader = *zrc
zipCat.Closer = closer
return zipCat, nil
}
// refresh refreshes internal state.
// It looks for changes to the directory listing, and caches any new mothballs.
func (m *Mothballs) refresh() {
@ -119,7 +153,7 @@ func (m *Mothballs) refresh() {
defer m.categoryLock.Unlock()
// Any new categories?
files, err := afero.ReadDir(m.Fs, "/")
files, err := fs.ReadDir(m.FS, "/")
if err != nil {
log.Println("Error listing mothballs:", err)
return
@ -136,7 +170,7 @@ func (m *Mothballs) refresh() {
reopen := false
if existingMothball, ok := m.categories[categoryName]; !ok {
reopen = true
} else if si, err := m.Fs.Stat(filename); err != nil {
} else if si, err := fs.Stat(m.FS, filename); err != nil {
log.Println(err)
} else if si.ModTime().After(existingMothball.mtime) {
existingMothball.Close()
@ -145,33 +179,14 @@ func (m *Mothballs) refresh() {
}
if reopen {
f, err := m.Fs.Open(filename)
if err != nil {
if f, err := m.FS.Open(filename); err != nil {
log.Println(err)
continue
}
fi, err := f.Stat()
if err != nil {
f.Close()
} else if zipCat, err := m.newZipCategory(f); err != nil {
log.Println(err)
continue
} else {
m.categories[categoryName] = zipCat
log.Println("Adding category:", categoryName)
}
zrc, err := zip.NewReader(f, fi.Size())
if err != nil {
f.Close()
log.Println(err)
continue
}
m.categories[categoryName] = zipCategory{
Fs: zipfs.New(zrc),
Closer: f,
mtime: fi.ModTime(),
}
log.Println("Adding category:", categoryName)
}
}

View File

@ -2,11 +2,12 @@ package main
import (
"archive/zip"
"bytes"
"fmt"
"io/ioutil"
"testing"
"github.com/spf13/afero"
"testing/fstest"
"time"
)
type testFileContents struct {
@ -23,9 +24,27 @@ var testFiles = []testFileContents{
{"3/moo.txt", `moo`},
}
func (m *Mothballs) createMothballWithFiles(cat string, contents []testFileContents) {
f, _ := m.Create(fmt.Sprintf("%s.mb", cat))
defer f.Close()
type TestMothballs struct {
*Mothballs
fsys fstest.MapFS
now time.Time
}
func NewTestMothballs() TestMothballs {
fsys := make(fstest.MapFS)
m := TestMothballs{
fsys: fsys,
Mothballs: NewMothballs(fsys),
now: time.Now(),
}
m.createMothball("pategory")
m.refresh()
return m
}
func (m *TestMothballs) createMothballWithFiles(cat string, contents []testFileContents) {
f := new(bytes.Buffer)
w := zip.NewWriter(f)
defer w.Close()
@ -38,9 +57,16 @@ func (m *Mothballs) createMothballWithFiles(cat string, contents []testFileConte
of, _ := w.Create(file.Name)
of.Write([]byte(file.Body))
}
filename := fmt.Sprintf("%.mb", cat)
m.now = m.now.Add(time.Millisecond)
m.fsys[filename] = &fstest.MapFile{
Data: f.Bytes(),
Mode: 0x644,
ModTime: m.now,
}
}
func (m *Mothballs) createMothball(cat string) {
func (m *TestMothballs) createMothball(cat string) {
m.createMothballWithFiles(
cat,
[]testFileContents{
@ -49,14 +75,7 @@ func (m *Mothballs) createMothball(cat string) {
)
}
func NewTestMothballs() *Mothballs {
m := NewMothballs(new(afero.MemMapFs))
m.createMothball("pategory")
m.refresh()
return m
}
func TestMothballs(t *testing.T) {
func TestMothballStuff(t *testing.T) {
m := NewTestMothballs()
if _, ok := m.categories["pategory"]; !ok {
t.Error("Didn't create a new category")
@ -129,7 +148,7 @@ func TestMothballs(t *testing.T) {
}
m.createMothball("test2")
m.Fs.Remove("pategory.mb")
delete(m.fsys, "pategory.mb")
m.refresh()
inv = m.Inventory()
if len(inv) != 1 {

View File

@ -15,7 +15,7 @@ import (
"strings"
"time"
"github.com/dirtbags/moth/v4/pkg/transpile"
"github.com/dirtbags/moth/pkg/transpile"
)
// ProviderCommand specifies a command to run for the puzzle API
@ -76,7 +76,7 @@ func (f NullReadSeekCloser) Close() error {
}
// Open passes its arguments to the command with "action=open".
func (pc ProviderCommand) Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) {
func (pc ProviderCommand) Open(cat string, points int, path string) (io.ReadSeekCloser, time.Time, error) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

View File

@ -6,7 +6,7 @@ import (
"strconv"
"time"
"github.com/dirtbags/moth/v4/pkg/award"
"github.com/dirtbags/moth/pkg/award"
)
// Category represents a puzzle category.
@ -15,13 +15,6 @@ type Category struct {
Puzzles []int
}
// ReadSeekCloser defines a struct that can read, seek, and close.
type ReadSeekCloser interface {
io.Reader
io.Seeker
io.Closer
}
// Configuration stores information about server configuration.
type Configuration struct {
Devel bool
@ -30,7 +23,7 @@ type Configuration struct {
// StateExport is given to clients requesting the current state.
type StateExport struct {
Config Configuration
Enabled bool
Messages string
TeamNames map[string]string
PointsLog award.List
Puzzles map[string][]int
@ -38,7 +31,7 @@ type StateExport struct {
// PuzzleProvider defines what's required to provide puzzles.
type PuzzleProvider interface {
Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error)
Open(cat string, points int, path string) (io.ReadSeekCloser, time.Time, error)
Inventory() []Category
CheckAnswer(cat string, points int, answer string) (bool, error)
Mothball(cat string, w io.Writer) error
@ -47,18 +40,18 @@ type PuzzleProvider interface {
// ThemeProvider defines what's required to provide a theme.
type ThemeProvider interface {
Open(path string) (ReadSeekCloser, time.Time, error)
Open(path string) (io.ReadSeekCloser, time.Time, error)
Maintainer
}
// StateProvider defines what's required to provide MOTH state.
type StateProvider interface {
Enabled() bool
Messages() string
PointsLog() award.List
TeamName(teamID string) (string, error)
SetTeamName(teamID, teamName string) error
AwardPoints(teamID string, cat string, points int) error
LogEvent(event, teamID, cat string, points int, extra ...string)
LogEvent(event, participantID, teamID, cat string, points int, extra ...string)
Maintainer
}
@ -68,9 +61,6 @@ type Maintainer interface {
// It will only be called once, when execution begins.
// It's okay to just exit if there's no maintenance to be done.
Maintain(updateInterval time.Duration)
// refresh is a shortcut used internally for testing
refresh()
}
// MothServer gathers together the providers that make up a MOTH server.
@ -92,22 +82,24 @@ func NewMothServer(config Configuration, theme ThemeProvider, state StateProvide
}
// NewHandler returns a new http.RequestHandler for the provided teamID.
func (s *MothServer) NewHandler(teamID string) MothRequestHandler {
func (s *MothServer) NewHandler(participantID, teamID string) MothRequestHandler {
return MothRequestHandler{
MothServer: s,
teamID: teamID,
MothServer: s,
participantID: participantID,
teamID: teamID,
}
}
// MothRequestHandler provides http.RequestHandler for a MothServer.
type MothRequestHandler struct {
*MothServer
teamID string
participantID string
teamID string
}
// PuzzlesOpen opens a file associated with a puzzle.
// BUG(neale): Multiple providers with the same category name are not detected or handled well.
func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r ReadSeekCloser, ts time.Time, err error) {
func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r io.ReadSeekCloser, ts time.Time, err error) {
export := mh.exportStateIfRegistered(true)
found := false
for _, p := range export.Puzzles[cat] {
@ -129,7 +121,7 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (
// Log puzzle.json loads
if path == "puzzle.json" {
mh.State.LogEvent("load", mh.teamID, cat, points)
mh.State.LogEvent("load", mh.participantID, mh.teamID, cat, points)
}
return
@ -146,33 +138,34 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string)
}
}
if !correct {
mh.State.LogEvent("wrong", mh.teamID, cat, points)
mh.State.LogEvent("wrong", mh.participantID, mh.teamID, cat, points)
return fmt.Errorf("incorrect answer")
}
mh.State.LogEvent("correct", mh.teamID, cat, points)
mh.State.LogEvent("correct", mh.participantID, mh.teamID, cat, points)
if _, err := mh.State.TeamName(mh.teamID); err != nil {
return fmt.Errorf("invalid team ID")
}
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
return err
return fmt.Errorf("error awarding points: %s", err)
}
return nil
}
// ThemeOpen opens a file from a theme.
func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, error) {
func (mh *MothRequestHandler) ThemeOpen(path string) (io.ReadSeekCloser, time.Time, error) {
return mh.Theme.Open(path)
}
// Register associates a team name with a team ID.
func (mh *MothRequestHandler) Register(teamName string) error {
// BUG(neale): Register returns an error if a team is already registered; it may make more sense to return success
if teamName == "" {
return fmt.Errorf("empty team name")
}
mh.State.LogEvent("register", mh.teamID, "", 0)
mh.State.LogEvent("register", mh.participantID, mh.teamID, "", 0)
return mh.State.SetTeamName(mh.teamID, teamName)
}
@ -184,17 +177,14 @@ func (mh *MothRequestHandler) ExportState() *StateExport {
return mh.exportStateIfRegistered(false)
}
// 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 {
func (mh *MothRequestHandler) exportStateIfRegistered(override bool) *StateExport {
export := StateExport{}
export.Config = mh.Config
teamName, err := mh.State.TeamName(mh.teamID)
registered := forceRegistered || mh.Config.Devel || (err == nil)
registered := override || mh.Config.Devel || (err == nil)
export.Enabled = mh.State.Enabled()
export.Messages = mh.State.Messages()
export.TeamNames = make(map[string]string)
// Anonymize team IDs in points log, and write out team names

View File

@ -2,46 +2,55 @@ package main
import (
"io/ioutil"
"os"
"testing"
"github.com/spf13/afero"
"time"
)
const TestMaintenanceInterval = time.Millisecond * 1
const TestTeamID = "teamID"
type TestServer struct {
type TestMothServer struct {
*MothServer
stateDir string
}
// NewTestServer creates a new MothServer with NewTestMothballs and some initial state.
//
// See function definition for details.
func NewTestServer() TestServer {
func NewTestServer() (*TestMothServer, error) {
puzzles := NewTestMothballs()
puzzles.refresh()
go puzzles.Maintain(TestMaintenanceInterval)
state := NewTestState()
afero.WriteFile(state, "teamids.txt", []byte("teamID\n"), 0644)
state.refresh()
stateDir, err := ioutil.TempDir("", "state")
if err != nil {
return nil, err
}
state := NewState(stateDir)
os.WriteFile(state.path("teamids.txt"), []byte("teamID\n"), 0644)
os.WriteFile(state.path("messages.html"), []byte("messages.html"), 0644)
go state.Maintain(TestMaintenanceInterval)
theme := NewTestTheme()
afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644)
theme := NewTheme("testdata/theme")
go theme.Maintain(TestMaintenanceInterval)
return TestServer{NewMothServer(Configuration{}, theme, state, puzzles)}
return &TestMothServer{
MothServer: NewMothServer(Configuration{}, theme, state, puzzles),
stateDir: stateDir,
}, nil
}
func (ts TestServer) refresh() {
ts.State.(*State).refresh()
for _, pp := range ts.PuzzleProviders {
pp.(*Mothballs).refresh()
func (m *TestMothServer) cleanup() {
if m.stateDir != "" {
os.RemoveAll(m.stateDir)
}
ts.Theme.(*Theme).refresh()
}
func TestDevelServer(t *testing.T) {
server := NewTestServer()
server, err := NewTestServer()
if err != nil {
t.Fatal(err)
}
defer server.cleanup()
server.Config.Devel = true
anonHandler := server.NewHandler("badTeamId")
anonHandler := server.NewHandler("badParticipantId", "badTeamId")
{
es := anonHandler.ExportState()
@ -56,11 +65,16 @@ func TestDevelServer(t *testing.T) {
func TestProdServer(t *testing.T) {
teamName := "OurTeam"
participantID := "participantID"
teamID := TestTeamID
server := NewTestServer()
handler := server.NewHandler(teamID)
anonHandler := server.NewHandler("badTeamId")
server, err := NewTestServer()
if err != nil {
t.Fatal(err)
}
defer server.cleanup()
handler := server.NewHandler(participantID, teamID)
anonHandler := server.NewHandler("badParticipantId", "badTeamId")
{
es := handler.ExportState()
@ -90,7 +104,8 @@ func TestProdServer(t *testing.T) {
t.Error("index.html wrong contents", contents)
}
server.refresh()
// Wait for refresh to pick everything up
time.Sleep(TestMaintenanceInterval)
{
es := handler.ExportState()
@ -100,6 +115,9 @@ func TestProdServer(t *testing.T) {
if len(es.Puzzles) != 1 {
t.Error("Puzzle categories wrong length", len(es.Puzzles))
}
if es.Messages != "messages.html" {
t.Error("Messages has wrong contents")
}
if len(es.PointsLog) != 0 {
t.Error("Points log not empty")
}
@ -140,7 +158,7 @@ func TestProdServer(t *testing.T) {
t.Error("Right answer marked wrong", err)
}
server.refresh()
time.Sleep(TestMaintenanceInterval)
{
es := handler.ExportState()
@ -169,7 +187,7 @@ func TestProdServer(t *testing.T) {
t.Error("Right answer marked wrong:", err)
}
server.refresh()
time.Sleep(TestMaintenanceInterval)
{
es := anonHandler.ExportState()

View File

@ -14,8 +14,7 @@ import (
"sync"
"time"
"github.com/dirtbags/moth/v4/pkg/award"
"github.com/spf13/afero"
"github.com/dirtbags/moth/pkg/award"
)
// DistinguishableChars are visually unambiguous glyphs.
@ -34,29 +33,28 @@ var ErrAlreadyRegistered = errors.New("team ID has already been registered")
// We use the filesystem for synchronization between threads.
// The only thing State methods need to know is the path to the state directory.
type State struct {
afero.Fs
basedir string
// Enabled tracks whether the current State system is processing updates
enabled bool
Enabled bool
enabledWhy string
refreshNow chan bool
eventStream chan []string
eventWriter *csv.Writer
eventWriterFile afero.File
eventWriterFile *os.File
// Caches, so we're not hammering NFS with metadata operations
teamNamesLastChange time.Time
teamNames map[string]string
pointsLog award.List
lock sync.RWMutex
teamNames map[string]string
pointsLog award.List
messages string
lock sync.RWMutex
}
// NewState returns a new State struct backed by the given Fs
func NewState(fs afero.Fs) *State {
func NewState(basedir string) *State {
s := &State{
Fs: fs,
enabled: true,
basedir: basedir,
Enabled: true,
refreshNow: make(chan bool, 5),
eventStream: make(chan []string, 80),
@ -68,13 +66,19 @@ func NewState(fs afero.Fs) *State {
return s
}
func (s *State) path(elem ...string) string {
elements := append([]string{s.basedir}, elem...)
return filepath.Join(elements...)
}
// updateEnabled checks a few things to see if this state directory is "enabled".
func (s *State) updateEnabled() {
nextEnabled := true
why := "state/hours.txt has no timestamps before now"
why := "`state/enabled` present, `state/hours.txt` missing"
if untilFile, err := s.Open("hours.txt"); err == nil {
if untilFile, err := os.Open(s.path("hours.txt")); err == nil {
defer untilFile.Close()
why = "`state/hours.txt` present"
scanner := bufio.NewScanner(untilFile)
for scanner.Scan() {
@ -94,36 +98,35 @@ func (s *State) updateEnabled() {
case '#':
continue
default:
log.Println("state/hours.txt has bad line:", line)
log.Println("Misformatted line in hours.txt file")
}
line, _, _ = strings.Cut(line, "#") // Remove inline comments
line = strings.TrimSpace(line)
until := time.Time{}
if len(line) == 0 {
// Let it stay as zero time, so it's always before now
} else if until, err = time.Parse(time.RFC3339, line); err == nil {
// Great, it was RFC 3339
} 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)
until, err := time.Parse(time.RFC3339, line)
if err != nil {
until, err = time.Parse(RFC3339Space, line)
}
if err != nil {
log.Println("Suspended: Unparseable until date:", line)
continue
}
if until.Before(time.Now()) {
nextEnabled = thisEnabled
why = fmt.Sprint("state/hours.txt most recent timestamp:", line)
}
}
}
if (nextEnabled != s.enabled) || (why != s.enabledWhy) {
s.enabled = nextEnabled
s.enabledWhy = why
log.Printf("Setting enabled=%v: %s", s.enabled, s.enabledWhy)
if s.enabled {
s.LogEvent("enabled", "", "", 0, s.enabledWhy)
if _, err := os.Stat(s.path("enabled")); os.IsNotExist(err) {
nextEnabled = false
why = "`state/enabled` missing"
}
if nextEnabled != s.Enabled {
s.Enabled = nextEnabled
log.Printf("Setting enabled=%v: %s", s.Enabled, why)
if s.Enabled {
s.LogEvent("enabled", "", "", "", 0, why)
} else {
s.LogEvent("disabled", "", "", 0, s.enabledWhy)
s.LogEvent("disabled", "", "", "", 0, why)
}
}
}
@ -142,14 +145,7 @@ func (s *State) TeamName(teamID string) (string, error) {
// SetTeamName writes out team name.
// This can only be done once per team.
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 := os.Open(s.path("teamids.txt"))
if err != nil {
return fmt.Errorf("team IDs file does not exist")
}
@ -167,7 +163,7 @@ func (s *State) SetTeamName(teamID, teamName string) error {
}
teamFilename := filepath.Join("teams", teamID)
teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
teamFile, err := os.OpenFile(s.path(teamFilename), os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
if os.IsExist(err) {
return ErrAlreadyRegistered
} else if err != nil {
@ -192,9 +188,11 @@ func (s *State) PointsLog() award.List {
return ret
}
// Enabled returns true if the server is in "enabled" state
func (s *State) Enabled() bool {
return s.enabled
// Messages retrieves the current messages.
func (s *State) Messages() string {
s.lock.RLock() // It's not clear to me that this actually needs to happen
defer s.lock.RUnlock()
return s.messages
}
// AwardPoints gives points to teamID in category.
@ -226,11 +224,11 @@ func (s *State) awardPointsAtTime(when int64, teamID string, category string, po
tmpfn := filepath.Join("points.tmp", fn)
newfn := filepath.Join("points.new", fn)
if err := afero.WriteFile(s, tmpfn, []byte(a.String()), 0644); err != nil {
if err := os.WriteFile(s.path(tmpfn), []byte(a.String()), 0644); err != nil {
return err
}
if err := s.Rename(tmpfn, newfn); err != nil {
if err := os.Rename(s.path(tmpfn), newfn); err != nil {
return err
}
@ -243,14 +241,14 @@ func (s *State) awardPointsAtTime(when int64, teamID string, category string, po
// collectPoints gathers up files in points.new/ and appends their contents to points.log,
// removing each points.new/ file as it goes.
func (s *State) collectPoints() {
files, err := afero.ReadDir(s, "points.new")
files, err := os.ReadDir(s.path("points.new"))
if err != nil {
log.Print(err)
return
}
for _, f := range files {
filename := filepath.Join("points.new", f.Name())
awardstr, err := afero.ReadFile(s, filename)
awardstr, err := os.ReadFile(s.path(filename))
if err != nil {
log.Print("Opening new points: ", err)
continue
@ -276,7 +274,7 @@ func (s *State) collectPoints() {
} else {
log.Print("Award: ", awd.String())
logf, err := s.OpenFile("points.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
logf, err := os.OpenFile(s.path("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Print("Can't append to points log: ", err)
return
@ -290,7 +288,7 @@ func (s *State) collectPoints() {
s.lock.Unlock()
}
if err := s.Remove(filename); err != nil {
if err := os.Remove(s.path(filename)); err != nil {
log.Print("Unable to remove new points file: ", err)
}
}
@ -298,7 +296,7 @@ func (s *State) collectPoints() {
func (s *State) maybeInitialize() {
// Are we supposed to re-initialize?
if _, err := s.Stat("initialized"); !os.IsNotExist(err) {
if _, err := os.Stat(s.path("initialized")); !os.IsNotExist(err) {
return
}
@ -306,28 +304,28 @@ func (s *State) maybeInitialize() {
log.Print("initialized file missing, re-initializing")
// Remove any extant control and state files
s.Remove("enabled")
s.Remove("hours.txt")
s.Remove("points.log")
s.Remove("events.csv")
s.Remove("mothd.log")
s.RemoveAll("points.tmp")
s.RemoveAll("points.new")
s.RemoveAll("teams")
os.Remove(s.path("enabled"))
os.Remove(s.path("hours.txt"))
os.Remove(s.path("points.log"))
os.Remove(s.path("messages.html"))
os.Remove(s.path("mothd.log"))
os.RemoveAll(s.path("points.tmp"))
os.RemoveAll(s.path("points.new"))
os.RemoveAll(s.path("teams"))
// Open log file
if err := s.reopenEventLog(); err != nil {
log.Fatal(err)
}
s.LogEvent("init", "", "", 0)
s.LogEvent("init", "", "", "", 0)
// Make sure various subdirectories exist
s.Mkdir("points.tmp", 0755)
s.Mkdir("points.new", 0755)
s.Mkdir("teams", 0755)
os.Mkdir(s.path("points.tmp"), 0755)
os.Mkdir(s.path("points.new"), 0755)
os.Mkdir(s.path("teams"), 0755)
// Preseed available team ids if file doesn't exist
if f, err := s.OpenFile("teamids.txt", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
if f, err := os.OpenFile(s.path("teamids.txt"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
id := make([]byte, 8)
for i := 0; i < 100; i++ {
for i := range id {
@ -340,42 +338,50 @@ func (s *State) maybeInitialize() {
}
// Create some files
if f, err := s.Create("initialized"); err == nil {
if f, err := os.Create(s.path("initialized")); err == nil {
fmt.Fprintln(f, "initialized: remove to re-initialize the contest.")
fmt.Fprintln(f)
fmt.Fprintln(f, "This instance was initialized at", now)
f.Close()
}
if f, err := s.Create("hours.txt"); err == nil {
if f, err := os.Create(s.path("enabled")); err == nil {
fmt.Fprintln(f, "enabled: remove or rename to suspend the contest.")
f.Close()
}
if f, err := os.Create(s.path("hours.txt")); err == nil {
fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# Enable: + [timestamp]")
fmt.Fprintln(f, "# Disable: - [timestamp]")
fmt.Fprintln(f, "# Enable: + timestamp")
fmt.Fprintln(f, "# Disable: - timestamp")
fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# This file, and all files in this directory, are re-read periodically.")
fmt.Fprintln(f, "# Default is enabled.")
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, "# You can have multiple start/stop times.")
fmt.Fprintln(f, "# Whatever time is the most recent, wins.")
fmt.Fprintln(f, "# Times in the future are ignored.")
fmt.Fprintln(f)
fmt.Fprintln(f, "- 1970-01-01T00:00:00Z")
fmt.Fprintln(f, "+", now)
fmt.Fprintln(f, "- 2519-10-31T00:00:00Z")
f.Close()
}
if f, err := s.Create("points.log"); err == nil {
if f, err := os.Create(s.path("messages.html")); err == nil {
fmt.Fprintln(f, "<!-- messages.html: put client broadcast messages here. -->")
f.Close()
}
if f, err := os.Create(s.path("points.log")); err == nil {
f.Close()
}
}
// LogEvent writes to the event log
func (s *State) LogEvent(event, teamID, cat string, points int, extra ...string) {
func (s *State) LogEvent(event, participantID, teamID, cat string, points int, extra ...string) {
s.eventStream <- append(
[]string{
strconv.FormatInt(time.Now().Unix(), 10),
event,
participantID,
teamID,
cat,
strconv.Itoa(points),
@ -394,7 +400,7 @@ func (s *State) reopenEventLog() error {
log.Print(err)
}
}
eventWriterFile, err := s.OpenFile("events.csv", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
eventWriterFile, err := os.OpenFile(s.path("events.csv"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
@ -407,7 +413,7 @@ func (s *State) updateCaches() {
s.lock.Lock()
defer s.lock.Unlock()
if f, err := s.Open("points.log"); err != nil {
if f, err := os.Open(s.path("points.log")); err != nil {
log.Println(err)
} else {
defer f.Close()
@ -426,42 +432,37 @@ func (s *State) updateCaches() {
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)
}
// 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 dirents, err := os.ReadDir(s.path("teams")); err != nil {
log.Printf("Reading team ids: %v", err)
} else {
for _, dirent := range dirents {
teamID := dirent.Name()
if teamNameBytes, err := os.ReadFile(s.path("teams", teamID)); err != nil {
log.Printf("Reading team %s: %v", teamID, err)
} else {
teamName := strings.TrimSpace(string(teamNameBytes))
s.teamNames[teamID] = teamName
}
}
}
}
if bMessages, err := os.ReadFile(s.path("messages.html")); err == nil {
s.messages = string(bMessages)
}
}
func (s *State) refresh() {
s.maybeInitialize()
s.updateEnabled()
if s.enabled {
if s.Enabled {
s.collectPoints()
}
s.updateCaches()
@ -509,9 +510,6 @@ func (ds *DevelState) TeamName(teamID string) (string, error) {
if name, err := ds.StateProvider.TeamName(teamID); err == nil {
return name, nil
}
if teamID == "" {
return "", fmt.Errorf("empty team ID")
}
return fmt.Sprintf("«devel:%s»", teamID), nil
}

View File

@ -17,16 +17,8 @@ func NewTestState() *State {
return s
}
func slurp(c chan bool) {
for range c {
// Nothing
}
}
func TestState(t *testing.T) {
s := NewTestState()
defer close(s.refreshNow)
go slurp(s.refreshNow)
mustExist := func(path string) {
_, err := s.Fs.Stat(path)
@ -41,6 +33,7 @@ func TestState(t *testing.T) {
}
mustExist("initialized")
mustExist("enabled")
mustExist("hours.txt")
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
@ -164,19 +157,16 @@ func TestStateOutOfOrderAward(t *testing.T) {
func TestStateEvents(t *testing.T) {
s := NewTestState()
s.LogEvent("moo", "", "", 0)
s.LogEvent("moo 2", "", "", 0)
s.LogEvent("moo", "", "", "", 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)
}
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" {
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo::::0" {
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)
}
}
@ -185,7 +175,7 @@ func TestStateDisabled(t *testing.T) {
s := NewTestState()
s.refresh()
if !s.Enabled() {
if !s.Enabled {
t.Error("Brand new state is disabled")
}
@ -194,72 +184,62 @@ func TestStateDisabled(t *testing.T) {
t.Error(err)
}
defer hoursFile.Close()
s.refresh()
if !s.Enabled() {
t.Error("Empty hours.txt not enabled")
}
fmt.Fprintln(hoursFile, "- 1970-01-01T01:01:01Z")
hoursFile.Sync()
s.refresh()
if s.Enabled() {
t.Error("1970-01-01")
if s.Enabled {
t.Error("Disabling 1970-01-01")
}
fmt.Fprintln(hoursFile, "+ 1970-01-02 01:01:01+05:00")
fmt.Fprintln(hoursFile, "+ 1970-01-01 01:01:01+05:00")
hoursFile.Sync()
s.refresh()
if !s.Enabled() {
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 +")
if !s.Enabled {
t.Error("Enabling 1970-01-02")
}
fmt.Fprintln(hoursFile, "")
fmt.Fprintln(hoursFile, "# Comment")
hoursFile.Sync()
s.refresh()
if !s.Enabled() {
t.Error("Comment")
if !s.Enabled {
t.Error("Comments")
}
fmt.Fprintln(hoursFile, "intentional parse error")
hoursFile.Sync()
s.refresh()
if !s.Enabled() {
if !s.Enabled {
t.Error("intentional parse error")
}
fmt.Fprintln(hoursFile, "- 1980-01-01T01:01:01Z")
hoursFile.Sync()
s.refresh()
if s.Enabled() {
t.Error("1980-01-01")
if s.Enabled {
t.Error("Disabling 1980-01-01")
}
if err := s.Remove("hours.txt"); err != nil {
t.Error(err)
}
s.refresh()
if !s.Enabled() {
if !s.Enabled {
t.Error("Removing `hours.txt` disabled event")
}
if err := s.Remove("enabled"); err != nil {
t.Error(err)
}
s.refresh()
if s.Enabled {
t.Error("Removing `enabled` didn't disable")
}
s.Remove("initialized")
s.refresh()
if !s.Enabled() {
if !s.Enabled {
t.Error("Re-initializing didn't start event")
}
}
@ -286,7 +266,7 @@ func TestStateMaintainer(t *testing.T) {
t.Error("Team ID too short:", teamID)
}
s.LogEvent("Hello!", "", "", 0)
s.LogEvent("Hello!", "", "", "", 0)
if len(s.PointsLog()) != 0 {
t.Error("Points log is not empty")
@ -311,11 +291,11 @@ func TestStateMaintainer(t *testing.T) {
eventLog, err := afero.ReadFile(s.Fs, "events.csv")
if err != nil {
t.Error(err)
} else if events := strings.Split(string(eventLog), "\n"); len(events) != 4 {
} else if events := strings.Split(string(eventLog), "\n"); len(events) != 3 {
t.Log("Events:", events)
t.Error("Wrong event log length:", len(events))
} else if events[3] != "" {
t.Error("Event log didn't end with newline", events)
} else if events[2] != "" {
t.Error("Event log didn't end with newline")
}
}

1
cmd/mothd/testdata/theme/index.html vendored Normal file
View File

@ -0,0 +1 @@
this is the index

View File

@ -1,26 +1,27 @@
package main
import (
"io"
"os"
"path"
"time"
"github.com/spf13/afero"
)
// Theme defines a filesystem-backed ThemeProvider.
type Theme struct {
afero.Fs
basedir string
}
// NewTheme returns a new Theme, backed by Fs.
func NewTheme(fs afero.Fs) *Theme {
func NewTheme(basedir string) *Theme {
return &Theme{
Fs: fs,
basedir: basedir,
}
}
// Open returns a new opened file.
func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) {
f, err := t.Fs.Open(name)
func (t *Theme) Open(name string) (io.ReadSeekCloser, time.Time, error) {
f, err := os.Open(path.Join(t.basedir, name))
if err != nil {
return nil, time.Time{}, err
}
@ -38,7 +39,3 @@ func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) {
func (t *Theme) Maintain(i time.Duration) {
// No periodic tasks for a theme
}
func (t *Theme) refresh() {
// Nothing to do for a theme
}

View File

@ -2,25 +2,12 @@ package main
import (
"io/ioutil"
"os"
"testing"
"github.com/spf13/afero"
)
func NewTestTheme() *Theme {
return NewTheme(new(afero.MemMapFs))
}
func TestTheme(t *testing.T) {
s := NewTestTheme()
filename := "/index.html"
index := "this is the index"
afero.WriteFile(s.Fs, filename, []byte(index), 0644)
fileInfo, err := s.Fs.Stat(filename)
if err != nil {
t.Error(err)
}
s := NewTheme("testdata/theme")
if f, timestamp, err := s.Open("/index.html"); err != nil {
t.Error(err)
@ -28,7 +15,9 @@ func TestTheme(t *testing.T) {
t.Error(err)
} else if string(buf) != index {
t.Error("Read wrong value from index")
} else if !timestamp.Equal(fileInfo.ModTime()) {
} else if fi, err := os.Stat("testdata/theme/index.html"); err != nil {
t.Error(err)
} else if !timestamp.Equal(fi.ModTime()) {
t.Error("Timestamp compared wrong")
}

View File

@ -4,21 +4,21 @@ import (
"bytes"
"encoding/json"
"io"
"io/fs"
"log"
"time"
"github.com/dirtbags/moth/v4/pkg/transpile"
"github.com/spf13/afero"
"github.com/dirtbags/moth/pkg/transpile"
)
// NewTranspilerProvider returns a new TranspilerProvider.
func NewTranspilerProvider(fs afero.Fs) TranspilerProvider {
func NewTranspilerProvider(fs fs.FS) TranspilerProvider {
return TranspilerProvider{fs}
}
// TranspilerProvider provides puzzles generated from source files on disk
type TranspilerProvider struct {
fs afero.Fs
fs fs.FS
}
// Inventory returns a Category list for this provider.
@ -79,7 +79,3 @@ func (p TranspilerProvider) Mothball(cat string, w io.Writer) error {
func (p TranspilerProvider) Maintain(updateInterval time.Duration) {
// Nothing to do here.
}
func (p TranspilerProvider) refresh() {
// Nothing to do for a theme
}

View File

@ -1,14 +1,12 @@
package main
import (
"os"
"testing"
"github.com/spf13/afero"
)
func TestTranspiler(t *testing.T) {
fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata")
p := NewTranspilerProvider(fs)
p := NewTranspilerProvider(os.DirFS("testdata"))
inv := p.Inventory()
if len(inv) != 1 {

View File

@ -5,13 +5,13 @@ import (
"flag"
"fmt"
"io"
"io/fs"
"log"
"os"
"sort"
"github.com/dirtbags/moth/v4/pkg/transpile"
"github.com/spf13/afero"
"github.com/dirtbags/moth/pkg/namesubfs"
"github.com/dirtbags/moth/pkg/transpile"
)
// T represents the state of things
@ -20,8 +20,8 @@ type T struct {
Stdout io.Writer
Stderr io.Writer
Args []string
BaseFs afero.Fs
fs afero.Fs
BaseFs fs.FS
fs fs.FS
}
// Command is a function invoked by the user
@ -88,7 +88,7 @@ func (t *T) ParseArgs() (Command, error) {
return nothing, err
}
if *directory != "" {
t.fs = afero.NewBasePathFs(t.BaseFs, *directory)
t.fs = namesubfs.Sub(t.BaseFs, *directory)
} else {
t.fs = t.BaseFs
}
@ -121,7 +121,10 @@ func (t *T) PrintInventory() error {
// DumpPuzzle writes a puzzle's JSON to the writer.
func (t *T) DumpPuzzle() error {
puzzle := transpile.NewFsPuzzle(t.fs)
puzzle, err := transpile.NewFsPuzzle(t.fs)
if err != nil {
return err
}
p, err := puzzle.Puzzle()
if err != nil {
@ -142,7 +145,10 @@ func (t *T) DumpFile() error {
filename = t.Args[0]
}
puzzle := transpile.NewFsPuzzle(t.fs)
puzzle, err := transpile.NewFsPuzzle(t.fs)
if err != nil {
return err
}
f, err := puzzle.Open(filename)
if err != nil {
@ -166,7 +172,7 @@ func (t *T) DumpMothball() error {
w = t.Stdout
} else {
filename = t.Args[0]
outf, err := t.BaseFs.Create(filename)
outf, err := os.Create(filename)
if err != nil {
return err
}
@ -177,7 +183,7 @@ func (t *T) DumpMothball() error {
if err := transpile.Mothball(c, w); err != nil {
if filename != "" {
t.BaseFs.Remove(filename)
os.Remove(filename)
}
return err
}
@ -190,8 +196,11 @@ func (t *T) CheckAnswer() error {
if len(t.Args) > 0 {
answer = t.Args[0]
}
c := transpile.NewFsPuzzle(t.fs)
_, err := fmt.Fprintf(t.Stdout, `{"Correct":%v}`, c.Answer(answer))
c, err := transpile.NewFsPuzzle(t.fs)
if err != nil {
return err
}
_, err = fmt.Fprintf(t.Stdout, `{"Correct":%v}`, c.Answer(answer))
return err
}
@ -206,7 +215,7 @@ func main() {
Stdout: os.Stdout,
Stderr: os.Stderr,
Args: os.Args,
BaseFs: afero.NewOsFs(),
BaseFs: os.DirFS(""),
}
cmd, err := t.ParseArgs()
if err != nil {

View File

@ -5,13 +5,15 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/fs"
"io/ioutil"
"os"
"strings"
"testing"
"github.com/dirtbags/moth/v4/pkg/transpile"
"github.com/spf13/afero"
"github.com/dirtbags/moth/pkg/transpile"
"github.com/psanford/memfs"
)
var testMothYaml = []byte(`---
@ -27,20 +29,20 @@ attachments:
YAML body
`)
func newTestFs() afero.Fs {
fs := afero.NewMemMapFs()
afero.WriteFile(fs, "cat0/1/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/1/moo.txt", []byte("Moo."), 0644)
afero.WriteFile(fs, "cat0/2/puzzle.moth", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/3/puzzle.moth", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/4/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/5/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/10/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "unbroken/1/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "unbroken/1/moo.txt", []byte("Moo."), 0644)
afero.WriteFile(fs, "unbroken/2/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "unbroken/2/moo.txt", []byte("Moo."), 0644)
return fs
func newTestFs() fs.FS {
fsys := memfs.New()
fsys.WriteFile("cat0/1/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("cat0/1/moo.txt", []byte("Moo."), 0644)
fsys.WriteFile("cat0/2/puzzle.moth", testMothYaml, 0644)
fsys.WriteFile("cat0/3/puzzle.moth", testMothYaml, 0644)
fsys.WriteFile("cat0/4/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("cat0/5/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("cat0/10/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("unbroken/1/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("unbroken/1/moo.txt", []byte("Moo."), 0644)
fsys.WriteFile("unbroken/2/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("unbroken/2/moo.txt", []byte("Moo."), 0644)
return fsys
}
func (tp T) Run(args ...string) error {
@ -124,8 +126,7 @@ func TestMothballs(t *testing.T) {
return
}
// afero.WriteFile(tp.BaseFs, "unbroken.mb", []byte("moo"), 0644)
fis, err := afero.ReadDir(tp.BaseFs, "/")
fis, err := fs.ReadDir(tp.BaseFs, "/")
if err != nil {
t.Error(err)
}
@ -140,13 +141,24 @@ func TestMothballs(t *testing.T) {
}
defer mb.Close()
info, err := mb.Stat()
if err != nil {
t.Error(err)
return
var zmb *zip.Reader
switch r := mb.(type) {
case io.ReaderAt:
info, err := mb.Stat()
if err != nil {
t.Error(err)
return
}
zmb, err = zip.NewReader(r, info.Size())
default:
t.Log("Doesn't implement ReaderAt, so I'm buffering the whole thing in memory:", r)
buf := new(bytes.Buffer)
size, err := io.Copy(buf, r)
if err != nil {
t.Error(err)
}
zmb, err = zip.NewReader(bytes.NewReader(buf.Bytes()), size)
}
zmb, err := zip.NewReader(mb, info.Size())
if err != nil {
t.Error(err)
return
@ -185,7 +197,7 @@ func TestFilesystem(t *testing.T) {
Stdin: stdin,
Stdout: stdout,
Stderr: stderr,
BaseFs: afero.NewOsFs(),
BaseFs: os.DirFS(""),
}
stdout.Reset()
@ -220,7 +232,7 @@ func TestCwd(t *testing.T) {
Stdin: stdin,
Stdout: stdout,
Stderr: stderr,
BaseFs: afero.NewOsFs(),
BaseFs: os.DirFS(""),
}
stdout.Reset()

View File

@ -1,51 +0,0 @@
#! /bin/sh
url=${1%/}
teamid=$2
case "$url:$teamid" in
*:|-h*|--h*)
cat <<EOD; exit 1
Usage: $0 MOTHURL TEAMID
Downloads all content currently open,
and writes it out to a zip file.
MOTHURL URL to the instance
TEAMID Team ID you used to log in
EOD
;;
esac
tmpdir=$(mktemp -d moth-dl.XXXXXX)
bye () {
echo "bye now"
rm -rf $tmpdir
}
trap bye EXIT
fetch () {
curl -s -d id=$teamid "$@"
}
echo "=== Fetching puzzles and attachments"
fetch $url/state > $tmpdir/state.json
cat $tmpdir/state.json \
| jq -r '.Puzzles | to_entries[] | .key as $k | .value[] | select (. > 0) | "\($k) \(.)"' \
| while read cat points; do
echo " + $cat $points"
dir=$tmpdir/$cat/$points
mkdir -p $dir
fetch $url/content/$cat/$points/puzzle.json > $dir/puzzle.json
cat $dir/puzzle.json | jq .Body > $dir/puzzle.html
cat $dir/puzzle.json | jq -r '.Attachments[]?' | while read attachment; do
echo " - $attachment"
fetch $url/content/$cat/$points/$attachment > $dir/$attachment
done
done
zipfile=$(echo $url | grep -o '[a-z]*\.[a-z.]*').zip
echo "=== Writing $zipfile"
(cd $tmpdir && zip -r - .) > $zipfile
echo "=== Wrote $zipfile"

View File

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

View File

@ -1,163 +0,0 @@
MOTH Client API
===========
MOTH provides a WebDAV interface:
this is described in
[MOTH Client Directory Structure](client-structure.md).
This document explains the WebDAV directory structure
as though it were a REST API.
These endpoints are a subset of the functionality provided,
but should be sufficient for many use cases.
Theme
======
Theme files are served as static content,
just like any standard web server.
### `GET` `/theme/${path}` - Retrieve File
Puzzles
======
Static Files
----------
With the exception of the `answer` file,
puzzle files are served as static content.
The entry point to a puzzle is `index.html`:
see [Puzzle Format](puzzle-format.md)
for details on its structure.
### `GET` `/puzzles/${category}/${points}/${filename}` - Retrieve File
Answer Submission
------------------
### `GET` `/puzzles/${category}/${points}/answer` - not supported
#### Responses
| http code | meaning |
| ---- | ---- |
| 405 | `GET` method is not supported |
### `POST` `/puzzles/${category}/${points}/answer` - Submit Answer
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Answer is correct, and points are awarded |
| 202 | Answer is correct, but points have already been awarded for this puzzle |
| 409 | Answer is incorrect |
| 401 | Authentication is invalid (bad team ID) |
State
====
Points Log
--------
The points log contains a history of correct answer submission.
Each submission is terminated by a newline (`\n`)
and consists of space-separated fields
of the format:
${timestamp} ${team_id} ${category} ${points}
### `GET` `/state/points.log` - Retrieve points log
| http code | meaning |
| ---- | ---- |
| 200 | Points log in payload (text/plain) |
| 401 | Authentication is invalid (bad team ID) |
Team Name
--------
### `GET` `/state/self/name` - Retrieve my team name
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Team ID in payload (text/plain) |
| 401 | Authentication is invalid (bad team ID) |
### `POST` `/state/self/name` - Set my team name
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Team ID is valid, and team name was recorded |
| 202 | Team ID is valid, but team name was previously set and cannot be changed |
| 401 | Authentication is invalid (bad team ID) |
Public Data
--------
Up to 4096 bytes of arbitrary public data per team may be stored on the server.
This data can be viewed by any authenticated team.
There are no restrictions on the content of the data:
clients are free to store whatever they want.
### `GET` `/state/${id}/public.bin` - Retrieve public data
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Data follows (application/octet-stream) |
| 401 | Authentication is invalid (bad team ID) |
### `PUT` `/state/${id}/public.bin` - Upload public data
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Data follows (application/octet-stream) |
| 401 | Authentication is invalid (bad team ID) |
Private Data
--------
Up to 4096 bytes of arbitrary data per team may be stored on the server.
This data is only accessible by an authenticated request,
and is private to the authenticated team.
There are no restrictions on the content of the data:
clients are free to store whatever they want.
### `GET` `/state/self/private.bin` - Retrieve private data
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Data follows (application/octet-stream) |
| 401 | Authentication is invalid (bad team ID) |
### `POST` `/state/self/private.bin` - Upload private data
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Data follows (application/octet-stream) |
| 401 | Authentication is invalid (bad team ID) |

463
docs/api.md Normal file
View File

@ -0,0 +1,463 @@
Moth APIs
=======
This document covers the following interfaces:
* HTTP Endpoints: what the Moth client sends the Moth server
* Puzzle executable: how the transpiler communicates with executables that provide puzzles
* Category executable: how the transpiler communicates with executables that provide categories
* Provider executable: how Moth communicates with things that provide puzzles (like the transpiler)
The Puzzle, Category, and Provider executalbes are all very closely related, since each is a subset of the next.
----
Here's a bad diagram of how this all fits together. I don't know if this is going to help at all. Please submit a merge request with something better.
HTTP provider API mothball API
🡗 🡗 🡗
client - mothd - mothball provider - category1.mb
- custom provider
category API
🡗
- internal transpiler - category2/mkcategory
- category3/1/puzzle.md
- category3/2/mkpuzzle
🡔
puzzle API
# HTTP Endpoints
The Moth server accepts
standard HTTP `GET` and `POST`.
Parameters may be encoded with standard `GET` query parameters
(like `GET /endpoint?a=1&b=2`),
or with `POST` as `application/x-www-form-encoded` data.
## `/state`
Returns the current Moth event state as a JSON object.
### Parameters
* `userid`: user ID (optional)
* `teamid`: team ID (optional)
### Return
```js
{
"Config": {
"Devel": false // true means this is a development server
},
"Messages: "HTML to be rendered as broadcast messages",
"TeamNames": {
"self": "Requesting team name", // Only if regestered team id is a provided
"0": "Team 1 Name",
"1": "Team 2 Name"
// ...
},
"PointsLog": [
[1602679698, "0", "category", 1] // epochTime, teamID, category, points
// ...
],
"Puzzles": {
"category": [1, 2, 3, 6] // list of unlocked puzzles for category
// ...
}
}
```
### Example HTTP transaction
#### Request
```
GET /state HTTP/1.0
```
#### Response
This response has been reflowed for readability:
an actual on-wire response would not have newlines or indentation.
```
HTTP/1.0 200 OK
Content-Type: application/json
{"Config":
{"Devel":false},
"Messages":"<p>Welcome to the event!</p><p>Event ends at 19:00!</p>",
"TeamNames":{
"0":"Mike and Jack",
"12":"Team 2",
"4":"Team 8"
},
"PointsLog":[
[1602702696,"0","nocode",1],
[1602702705,"0","sequence",1],
[1602702787,"0","nocode",2],
[1602702831,"0","sequence",2],
[1602702839,"4","nocode",3],
[1602702896,"0","sequence",8],
[1602702900,"4","nocode",4],
[1602702913,"0","sequence",16]
],
"Puzzles":{
"indy":[12],
"nocode":[1,2,3,4,10],
"sequence":[1,2,8,16,19],
"steg":[1]
}
}
```
## `/register`
Registers a name to a team ID.
This is only required once per team,
but user interfaces may find it less confusing to users
to present a "login" page.
For this reason "this team is already registered"
does not return an error.
### Parameters
* `userid`: user ID (optional)
* `teamid`: team ID
* `teamname`: team name
### Return
An object inspired by [JSend](https://github.com/omniti-labs/jsend):
```json
{
"status": "success/fail/error",
"data": {
"short": "short description",
"description": "long description"
}
}
```
### Example HTTP transaction
#### Request
```
POST /register HTTP/1.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 26
teamid=b387ca98&teamname=dirtbags
```
#### Repsonse
```
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length=86
{"status":"success","data":{"short":"registered","description":"Team ID registered"}}
```
## `/answer`
Submits an answer for points.
If the answer is wrong, no points are awarded 😉
### Parameters
* `userid`: user ID (optional)
* `teamid`: team ID
* `category`: along with `points`, uniquely identifies a puzzle
* `points`: along with `category`, uniquely identifies a puzzle
### Return
An object inspired by [JSend](https://github.com/omniti-labs/jsend):
```json
{
"status": "success/fail/error",
"data": {
"short": "short description",
"description": "long description"
}
}
```
### Example HTTP transaction
#### Request
```
POST /answer HTTP/1.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 62
id=b387ca98&category=sequence&points=2&answer=achilles+turnip
```
#### Repsonse
```
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length=83
{"status":"fail","data":{"short":"not accepted","description":"Incorrect answer"}}
```
## `/content/{category}/{points}/puzzle.json`
Retrieves the JSON object describing a puzzle.
Parameters are all in the URL for this endpoint,
so `curl` and `wget` can be used.
### Parameters
* `userid`: user ID (optional)
* `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle
* `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle
* `{filename}` (in URL): filename to retrieve
### Return
JSON object describing a puzzle.
#### JSON Puzzle Object
```js
{
"Pre": { // Things which appear before the puzzle is solved
"Authors": ["Neale Pickett"], // List of puzzle authors, usually rendered as a footnote
"Attachments": ["tiger.jpg"], // List of files attached to the puzzle
"Scripts": [], // List of scripts which should be included in the HTML render of the puzzle
"Body": "<p>Can you find the hidden text?</p><p><img src=\"tiger.jpg\" alt=\"Grr\" /></p>\n", // HTML puzzle body
"AnswerPattern": "", // Regular expression to include in HTML input tag for validation
"AnswerHashes": [ // List of SHA265 hashes of correct answers, for client-side answer checking
"f91b1fe875cdf9e969e5bccd3e259adec5a987dcafcbc9ca8da62e341a7f29c6"
]
},
"Post": { // Things reveal after the puzzle is solved
"Objective": "Learn to examine images for hidden text", // Learning objective
"Success": { // Measures of learning success
"Acceptable": "Visually examine image to find hidden text",
"Mastery": "Visually examine image to find hidden text"
},
"KSAs": null // Knowledge, Skills, and Abilities covered by this puzzle
},
"Debug": { // Debugging output used in development: all fields are emptied when making mothballs
"Log": [ // Debug message log
"Input image size: 600x400",
"Applying gaussian blur",
"Text width 58, left offset 513",
"Complete in 0.028s"
],
"Errors": [], // Errors encountered generating this puzzzle
"Hints": [ // Hints for instructional assistants to provide to participants
"Zoom in to the image and examine all sections carefully"
],
"Summary": "text in image" // Summary of this puzzle, to help identify it in an overview of puzzles
},
"Answers": ["sandwich"] // List of answers: empty in production
}
```
### Example HTTP transaction
#### Request
```
GET /content/sequence/1/puzzle.json HTTP/1.0
```
#### Repsonse
```
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 397
{"Pre":{"Authors":["neale"],"Attachments":[],"Scripts":[],"Body":"\u003cp\u003e1 2 3 4 5 ⬜\u003c/p\u003e\n","AnswerPattern":"","AnswerHashes":["e7f6c011776e8db7cd330b54174fd76f7d0216b612387a5ffcfb81e6f0919683"]},"Post":{"Objective":"","Success":{"Acceptable":"","Mastery":""},"KSAs":null},"Debug":{"Log":[],"Errors":[],"Hints":[],"Summary":"Simple introduction to how this works"},"Answers":[]}
```
## `/content/{category}/{points}/{filename}`
Retrieves static content associated with a puzzle.
Parameters are all in the URL for this endpoint,
so `curl` and `wget` can be used.
### Parameters
* `userid`: user ID (optional)
* `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle
* `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle
* `{filename}` (in URL): filename to retrieve
### Return
Raw file octets,
with a (hopefully) suitable
`Content-type` HTTP header field.
### Example HTTP transaction
#### Request
```
GET /content/sequence/1/attachment.txt HTTP/1.0
```
#### Repsonse
```
HTTP/1.0 200 OK
Content-Type: text/plain
Content-Length: 98
This is an attachment file! This is just plain text for the example. Many attachments are JPEGs.
```
## `/chat/read`
Reads messages from a chat forum.
This yields [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events),
which allows new messages to be delivered instantly to the client.
### Parameters
* `userid`: user ID
* `since`: timestamp of oldest message to retrieve
* `forum`: chat forum to read (can be specified more than once!)
## `/chat/say`
Send a message to a chat forum.
### Parameters
* `userid`: user ID
* `forum`: chat forum to send to
* `text`: text of message to send
## `/chat/
# Puzzle
A puzzle contains one question and one or more associated answers.
Puzzles are not aware of their point value: this is set by the category they are in.
Puzzle executables must be named `mkpuzzle`.
## `mkpuzzle puzzle`
puzzles/category3/1 $ ./mkpuzzle puzzle
{JSON PUZZLE OBJECT}
Also see [JSON Puzzle Object](#json-puzzle-object)
## `mkpuzzle file {filename}`
puzzles/category3/1 $ ./mkpuzzle file attachment.txt
This is an attachment file! It's just plain text for this example. Many attachments are JPEGs.
## `mkpuzzle answer {answer}`
puzzles/category3/1 $ ./mkpuzzle answer "cow goes moo"
{"Correct":false}
# Category
Categories are collections of puzzles.
Each puzzle has a unique point value, determined by the category.
Category executables must be called `mkcategory`.
## `mkcategory inventory`
puzzles/category2 $ ./mkcategory inventory
{"Puzzles": [1, 2, 3, 5, 10, 20, 30, 50, 100]}
## `mkcategory puzzle {points}`
puzzles/category2 $ ./mkcategory puzzle 1
{JSON PUZZLE OBJECT}
Also see [JSON Puzzle Object](#json-puzzle-object)
## `mkcategory file {points} {filename}`
puzzles/category2 $ ./mkcategory file 1 attachment.txt
This is an attachment file's contents!
## `mkcategory answer {points} {answer}`
puzzles/category2 $ ./mkcategory answer 1 "cow goes moo"
{"Correct":false}
# Provider API
This is how Claire gets her dynamic graders.
*Notice: this is not complete in the code base!*
I'm writing here how it *should* work.
If anybody wants this,
please let me know,
and I'll finish the code.
This could ostensibly be expanded to call HTTP servers,
with the four endpoints described here.
If somebody were to want such a thing.
## `provider inventory`
$ provider inventory
{
"category1": [1, 2, 3, 4, 5, 10, 20, 30],
"category2": [20, 40, 70, 150]
}
## `provider puzzle {category} {points}`
$ provider puzzle category1 20
{JSON PUZZLE OBJECT}
Also see [JSON Puzzle Object](#json-puzzle-object)
## `provider file {category} {points} {filename}`
$ provider file category1 20 attachment.txt
This is an attachment! Yay!
## `provider answer {category} {points} {answer}`
$ provider answer category1 20 "cow goes moo"
{"Correct":true}

View File

@ -1,55 +0,0 @@
MOTH Client Directory Structure
=======
MOTHv5 implements WebDAV.
Depending on the authentication level of a user,
files may be read-only, or read-write.
WebDAV allows you to mount MOTH as a local filesystem.
You are encouraged to do this,
and use this document as a reference.
Directory Structure: Participant
-----------
Here is an example list of the files available to participants.
r- /state/points.log
rw /state/self/name
rw /state/self/private.dat
rw /state/self/public.dat
r- /state/1/name
r- /state/1/public.dat
r- /state/2/name
r- /state/2/public.dat
r- /puzzles/category-a/1/index.html
rw /puzzles/category-a/1/answer
r- /puzzles/category-a/2/index.html
rw /puzzles/category-a/2/answer
r- /puzzles/category-a/2/attachment.jpg
r- /puzzles/category-b/1/index.html
rw /puzzles/category-b/1/answer
r- /theme/*
Directory Structure: Anonymous
-----------
Anonymous (unauthenticated) users
have a restricted view:
r- /state/points.log
rw /state/self/name
rw /state/self/private.dat
rw /state/self/public.dat
r- /state/1/name
r- /state/1/public.dat
r- /state/2/name
r- /state/2/public.dat
Directory Structure: Administrator
------------
Here is an example list of the files available
to an administrator.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View File

@ -1,211 +1,105 @@
Developing Content: Structure
================
Developing Content
============================
![Content Layout](content-layout.png)
The development server shows debugging for each puzzle,
and will compile puzzles on the fly.
MOTH content heirarchy consists of three layers:
categories, puzzles, and attachments.
Category
-------
A category consists of one or more puzzles.
Each puzzle is named by its point value,
and each point value must be unique.
For instance,
you cannot have two 5-point puzzles in a category.
Scoring is usually calculated by summing the
*percentage of available points per category*.
This allows content developers to focus only on point values within a single category:
how points are assigned in other categories doesn't matter.
Puzzle
-----
A puzzle consists of a few static fields,
a main puzzle body,
and optionally attached files.
At a minimum,
a puzzle must contain `puzzle.md`:
a
[CommonMark Markdown](https://spec.commonmark.org/dingus/)
file with metadata.
Example:
```markdown
---
authors:
- Neale Pickett
answers:
- one of its legs are both the same
- One of its legs are both the same
- one of its legs are both the same.
- One of its legs are both the same.
---
This puzzle consists of a joke.
What is the difference between a duck?
```
### Puzzle Metadata
Puzzle metadata is the section of `puzzle.md` between `---`.
This section consists of [YAML](https://yaml.org/),
describing various metadata aspects of the puzzle.
At a minimum,
each puzzle should contain:
* authors: who created the puzzle
* answers: a list of answers that are considered "correct"
Other metadata a puzzle can contain:
* debug: information used only in development mode
* summary: text summarizing what this puzzle is about
* notes: any additional notes you think it's important to record
* hints: a list of hints that could aid an instructor
* objective: what the goal of this puzzle is
* ksas: a list of NICE KSAs covered by this puzzle
* success: criteria for success
* acceptable: criterion for acceptably succeeding at the task
* mastery: criterion for mastery of the task
* attachments: a list of files to attach to this puzzle (see below)
### Body
The body of a puzzle is interpreted as
[CommonMark Markdown](https://spec.commonmark.org/dingus/)
and rendered as HTML.
Attachments
-------
Any filenames listed under `attachments` in the metadata will be included
with the puzzle.
Usually,
these are listed at the bottom of each puzzle,
one by one.
But you can also refer to attachments from the puzzle body
with either links or inline images.
```markdown
This is a [link](attachment-document.html).
And this is an inline image: ![alt text](attachment-image.png)
```
Use it along with a text editor and shell to create new puzzles and categories.
Developing Content: Source vs Mothballs
=============================
As a developer,
you will begin by running MOTH in development mode.
In development mode,
MOTH will provide answers for each puzzle alongside the puzzle.
In order to run in production mode,
each category must be "transpiled" into a "mothball".
This is done to reduce the amount of dynamic code running on a production server,
as a way of decreasing the attack surface of the server.
To obtain a mothball,
simply click the "download" button on the puzzles list of a development server.
Mothballs have the file extension `.mb`.
Setting Up Your Workstation
=====================
You will need two things to develop content:
a MOTH server,
and a text editor.
MOTH Server
-----------
### Windows
Windows users can unzip the zip file provided by the MOTH development team,
and run `moth-devel.bat`.
You can close this window whenever you're done developing.
Once started,
open
http://localhost:8080/
to connect to your development server.
### Linux
Linux users with Docker can run the following to get started
```sh
mkdir -p puzzles
docker run --rm -it -p 8080:8080 -v $(pwd)/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
```
Hit Control-C to terminate MOTH.
Text Editor
Set up some example puzzles
---------
We like Visual Studio Code,
but any text editor will work:
you only need to edit the `puzzle.md` (Markdown) files.
If you don't have puzzles of your own to start with,
you can copy the example puzzles that come with the source:
Your First Category
================
cp -r /path/to/src/moth/example-puzzles /srv/moth/puzzles
Open the `puzzles` directory/folder created where you launched the server.
Create a new directory/folder named `test-category`.
Within that directory/folder,
create a directory/folder named `1`.
And within `1`,
create a new file named `puzzle.md`.
Paste the following text into that file:
```markdown
---
authors:
- YOUR NAME HERE
answers:
- Elephant
- elephant
---
Run the server in development mode
---------------
# Animal Time
These recipes run the server in the foreground,
so you can watch the access log and any error messages.
Do you like animals? I do!
Can you guess my favorite animal?
```
Now,
open http://localhost:8080/ in a web browser.
You should see a "test-category" category,
with a single 1-point puzzle.
Click that puzzle,
and you should see your animal puzzle presented.
### Podman
The two answers we're accepting can be improved,
because some people might use the plural.
Edit `puzzle.md` to add two new answers:
`Elephants` and `elephants`.
Reload the web page,
and check the debug section to verify that all four answers are now accepted.
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
Next Steps
==========
### Docker
docker run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
### Native
I assume you've built and installed the `moth` command from the source tree.
If you don't know how to build Go packages,
please consider using Podman or Docker.
Building Go software is not a skill related to running MOTH or puzzle events,
unless you plan on hacking on the source code.
mkdir -p /srv/moth/state
cp -r /path/to/src/moth/theme /srv/moth/theme
cd /srv/moth
moth -puzzles puzzles
Log In
-----
Point a browser to http://localhost:8080/ (or whatever host is running the server).
You will be logged in automatically.
Browse the example puzzles
------------
The example puzzles are written to demonstrate various features of MOTH,
and serve as documentation of the puzzle format.
Make your own puzzle category
-------------------------
cp -r /srv/moth/puzzles/example /srv/moth/puzzles/my-category
Edit the one point puzzle
--------
nano /srv/moth/puzzles/my-category/1/puzzle.md
I don't use nano, personally,
but if you're advanced enough to have an opinion about nano,
you're advanced enough to know how to use a different editor.
Read our advice
---------------
The [Writing Puzzles](writing-puzzles.md) document
has some tips on how we approach puzzle writing.
There may be something in here that will help you out!
Stop the server
-------
You can hit Control-C in the terminal where you started the server,
and it will exit.
Mothballs
=======
In the list of puzzle categories and puzzles,
there will be a button to download a mothball.
Once your category is set up the way you like it,
download a mothball for it,
and you're ready for [getting started](getting-started.md)
and you're ready to [get started](getting-started.md)
with the production server.

View File

@ -1,30 +0,0 @@
Download All Unlocked Puzzles
========================
We get a lot of requests to "download everything" from an event.
Here's how you could do that:
What You Need
------------
* The URL to your puzzle server. We will call this `$url`.
* Your Team ID. We will call this `$teamid`.
* A way to POST `id=$teamid`
with `Content-Type: x-www-form-encoded` to a URL,
and save the result.
We will call this procedure "Fetch".
* A way to parse JSON files
Steps
-----
1. Fetch `$url/state`. This is the State object.
2. In the State object, `Puzzles` maps category name to a list of open puzzle point values.
3. For each category (we will call this `$category`):
1. For each point value:
1. If the point value is 0, skip it. 0 indicates all puzzles in this category are unlocked.
2. Fetch `$url/content/$category/$points/index.json`. This is the Puzzle object.
3. In the Puzzle object, `Body` contains the HTML body of the puzzle.
4. In the Puzzle object, `Attachments` contains a list of attachments.
For each attachment (we will call this `$attachment`):
1. Fetch `$url/content/$category/$points/$attachment`.

2
docs/internals.md Normal file
View File

@ -0,0 +1,2 @@
# Internal Structures

View File

@ -49,10 +49,10 @@ It ought to import into any spreadsheet program painlessly.
Each line has six fields minimum:
| `timestamp` | `event` | `teamID` | `category` | `points` | `extra`... |
| --- | --- | --- | --- | --- | --- |
| int | string | string | string | int | string... |
| Unix epoch | Event type | Team's unique ID | Name of category, if any | Points awarded, if any | Additional fields, if any |
| `timestamp` | `event` | `participantID` | `teamID` | `category` | `points` | `extra`... |
| --- | --- | --- | --- | --- | --- | --- |
| int | string | string | string | string | int | string... |
| Unix epoch | Event type | Participant's (hopefully) unique ID | 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.

View File

@ -1,50 +0,0 @@
MOTH Metadata
============
Standard Metadata
-----------------
The following are considered "standard" MOTH metadata.
Clients *should* check for,
and take appropriate action on,
all of these metadata names.
| name | description | permitted values | example |
| --- | --- | --- | --- |
| author | Puzzle author(s). | free text [(ref)](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name) | `Neale Pickett` |
| moth.style | Whether the client should inject a style sheet. Default: `inherit` | `override`, `inherit` | `override` |
| moth.answerhash | Answer hash, used for "possibly correct" check in client. | MOTHv5: first 8 characters of answer's SHA1 checksum | `a5b6bb92` |
| moth.answerpattern | Answer pattern, to use as `pattern` attribute of `<input>` element for answer. | Regular Expression [(ref)](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/pattern) | `\w{3,16}`
| moth.ksa | [NICE KSA](https://niccs.cisa.gov/workforce-development/nice-framework) achieved by completing this puzzle. | NICE KSA identifier | `K0052` |
| moth.objective | Learning objective of this puzzle. | free text | `Count in octal` |
| moth.success.acceptable | The minimum work required to be considered successfully understanding this puzzle's concepts | free text | `Recognize pattern` |
| moth.success.mastery | The work required to be considered mastering this puzzle's concepts | free text | `Understand 8s place in octal` |
Standard Debugging Metadata
----------------
These metadata names are for debugging purposes.
The *must not* be present in a production instance.
| name | description | permitted values | example |
| --- | --- | --- | --- |
| moth.debug.answer | An accepted answer | free text | `pink hat horse race` |
| moth.debug.summary | A summary of the puzzle, to help staff remember what it is | free text | `Hidden white text in the rendered image` |
| moth.debug.hint | A hint that staff can provide to participants | free text | `This puzzle can be solved by a grade school student with no special tools` |
| moth.debug.notes | Notes to staff intended to help better understand the puzzle | free text | `We used this image because Scott likes tigers` |
| moth.debug.log | A log message | free text | `iterations: 5` |
| moth.debug.errors | Error messages | free text | `unable to open foo.bin` |
Client Metadata
-----------
Clients wishing to implement additional metadata
*should* either submit a merge request to this document,
or use a `moth/$client.` prefix.
For example, the "tofu" client might use a
`moth/tofu.difficulty` name.

View File

@ -33,13 +33,24 @@ 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.
`hours.txt`
`disabled`
----------
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`
-------
A list of start and stop hours.
If all the hours are in the future, the event defaults to running.
"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.
Put an RFC3337 date/time stamp in here to have the server pause itself at a given time.
Remember that time zones exist!
I recommend always using Zulu time.
This file does not normally exist.
`teamids.txt`

View File

@ -1,183 +0,0 @@
MOTH Puzzle Format
===========
MOTH puzzles are HTML5 documents,
with optional metadata.
Puzzles may contain stylesheets and scripts,
or any other feature made available by HTML5.
Typically, a puzzle will be rendered in an `<object>` tag
in the MOTH client.
Some clients may copy over scripts, stylesheets,
and embed the puzzle's `<body>` in the page.
Within a puzzle directory,
the puzzle itself is named `index.html`.
MOTH Metadata
=============
Puzzles may contain metadata,
which can be used by MOTH clients to alter display of puzzles,
or provide additional information in the UI.
Metadata is provided in HTML `<meta>` elements,
with the `name` attribute specifying the metadata name,
and the `content` attribute specifying the metadata content.
Multiple elements with the same `name` are generally permitted.
Metadata names are defined in detail in
[MOTH Metadata](metadata.md).
For example, the following `<meta>` elements
could appear in the `<head>` section of a puzzle's HTML:
```html
<meta name="author" content="Neale Pickett">
<meta name="moth.answerhash" content="87bcc390">
<meta name="moth.answerhash" content="622fcbe8">
<meta name="moth.objective" content="Understand radix 8 (octal)">
```
Images, Attachments, Scripts, and Style Sheets
===================
Related files can be referenced directly in HTML.
Related files *should* be located in the same directory as `index.html`,
but situations may exist where it makes more sense
to locate a file in the parent directory.
Related files are not hidden:
they can be discovered with an http `PROPFIND` method.
For example, assuming `honey.jpg` exists in the same directory
as `index.html`, a standard `<img>` tag will work:
```html
<img src="honey.jpg"
alt="A clay jar with the word 'honey' printed on the front."
title="Honey jar">
```
Puzzle Events
==============
As HTML5 documents,
MOTH puzzles can communicate with the MOTH client
using HTML5 events.
setAnswer
--------
A MOTH Puzzle may advise the client to fill the answer field with text
by emitting an `setAnswer` custom event.
For example, the following code will advice the client to set the answer field to the string `bloop`:
```javascript
let answerEvent = new CustomEvent(
"setAnswer",
{
detail: {value: 'bloop'},
bubbles: true,
cancelable: true
},
)
document.dispatchEvent(answerEvent)
```
MOTH clients *should* listen for such events,
and fill the answer input field with the event's value.
Puzzles *must* provide the user with a copy/paste-able representation of the answer, in the event the event is not handled correctly by the client.
Example Puzzles
=========
Minimally Valid Puzzle
---------
This puzzle provides the absolute minimum required:
a title, and puzzle contents.
```html
<!DOCTYPE html>
<title>Counting</title>
<p>1 2 3 4 5 _</p>
```
Puzzle with metadata
-----------------
Typically, puzzles will provide metadata,
to enable client features such as "possibly correct" validation,
author display, learning objectives
```html
<!DOCTYPE html>
<html>
<head>
<title>Counting Sheep</title>
<meta name="author" content="Neale Pickett">
<meta name="moth.answerhash" content="089c7244">
<meta name="moth.answerhash" content="92837b4f">
<meta name="moth.objective" content="Recognize the difference between a sheep and a wolf">
<meta name="moth.objective" content="Count to a high number">
<meta name="moth.success.acceptable" content="Count using fingers">
<meta name="moth.success.mastery" content="Count using software tools, and provide answer in hexadecimal">
</head>
<body>
<p>🐑🐑🐑🐑🐑🐑🐑🐑🐺🐑🐑</p>
<p>How many sheep?</p>
</body>
</html>
```
Puzzle with images, scripts, and style
---------------------------
Since they are rendered as HTML documents,
puzzles may include any HTML5 feature.
```html
<!DOCTYPE html>
<html>
<head>
<title>Basic Sight Reading</title>
<meta name="author" content="Neale Pickett">
<meta name="moth.answerhash" content="baabaa08">
<meta name="moth.objective" content="Play a tune provided in sheet music">
<meta name="moth.success.acceptable" content="Play the requested tune with no mistakes">
<link rel="stylesheet" href="style.css">
<script src="midi-transcriber.mjs" type="module"></script>
</head>
<body>
<p>
Using the provided sheet music,
play "May Had A Little Lamb" on your MIDI keyboard.
If you make a mistake,
press the "reset" button and start over.
</p>
<p>
Once you have played from start to finish with no mistakes,
paste the computed answer into the answer box.
</p>
<img src="mary-lamb.png"
alt="Sheet music: |EDCD|EEE.|DDD.|EEE.|"
title="Sheet music for 'Mary Had A Little Lamb">
<label for="notes">Notes Played</label>
<output id="notes"></output>
<button id="reset">Reset</button>
<label for="answer">Answer</label>
<output id="answer"></output>
</body>
</html>
```

View File

@ -1,73 +0,0 @@
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.

70
docs/user-tracking.md Normal file
View File

@ -0,0 +1,70 @@
# User Tracking
We need some way to have track users uniquely.
## Motivation
### Individual progress
We're way too far gone on this one.
I fought it while I could,
but everybody and their dog wants to track individual progress,
so we need to continue providing at least advisory information about who's doing what.
### Attendance
CPE certificates are the biggest driver here.
Doing this client-side won't work,
because people want to fight me about their certificates,
and I need something to fall back on.
The sponsor also has a keen interest in attrition,
and we need attendance data for this as well.
### Chat
We need to integrate a chat system,
and for our big events,
we need the chat system to use the "display name" provided by each participant.
## Requirements
Essentially, we need something like team ID,
but for an individual participant.
### Support drop-in events
One of our big wins right now is our ability to run drop-in events,
like Def Con contests,
high school science cafes,
etc.
We dealt with this by pre-generating authentication tokens and providing a
`/register` API endpoint to set a team name.
This was a good design and we should keep this.
### Run without Internet
Def Con's network is crap,
and we may yet run another event that's disconnected.
We need a way to run events without an Internet connection.
### Minimal storage
If possible, I'd prefer to not even have a password.
Ideally just a token for user, and their display name.
## Solution
I'm realizing the best solution is to do almost nothing.
We already have a client that provides a "participant ID",
which is logged into the event log.
The new chat system could pretty easily cache a mapping of `pid` to display name.
On cache miss, it could use whatever backend is provided to look things up.
This could be alfio, a URL to a CSV file, or something else.

View File

@ -1 +0,0 @@
Boop!

View File

@ -1,23 +0,0 @@
---
authors:
- neale
answers:
- 146
attachments:
- boop.txt
---
Some puzzles can have embedded code.
Your theme may turn this into a full in-browser development environment!
## Python ##
```python
print(open("boop.txt").read())
setanswer(0x58 + 58)
```
## JavaScript ##
```javascript
console.log("moo")
```

16
go.mod
View File

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

444
go.sum
View File

@ -1,445 +1,121 @@
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/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
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/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
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/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
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/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
github.com/psanford/memfs v0.0.0-20210214183328-a001468d78ef h1:NKxTG6GVGbfMXc2mIk+KphcH6hagbVXhcFkbTgYleTI=
github.com/psanford/memfs v0.0.0-20210214183328-a001468d78ef/go.mod h1:tcaRap0jS3eifrEEllL6ZMd9dg8IlDpi2S1oARrQ+NI=
github.com/spf13/afero v1.4.0 h1:jsLTaI1zwYO3vjrzHalkVcIHXTNmdQFepW4OI8H3+x8=
github.com/spf13/afero v1.4.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/afero v1.5.1 h1:VHu76Lk0LSP1x254maIu2bplkWpfBWI+B+6fdoZprcg=
github.com/spf13/afero v1.5.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
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.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 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
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=
github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I=
github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
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-20190820162420-60c769a6c586/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-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/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-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
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-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
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/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/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
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/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/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-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-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/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-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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.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 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
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=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/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/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=

47
pkg/microchat/alfio.go Normal file
View File

@ -0,0 +1,47 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type AlfioUserResolver struct {
apiUrl string
}
// NewAlfioUserResolver returns an AlfioUserResolver for the provided API URL
func NewAlfioUserResolver(apiUrl string) AlfioUserResolver {
return AlfioUserResolver{
apiUrl: apiUrl,
}
}
// AlfioTicket defines the parts of the alfio ticket that we care about
type AlfioTicket struct {
FullName string `json:"fullName"`
TicketCategoryName string `json:"ticketCategoryName"`
}
// Resolve looks up a ticket to resolve into "${fullName} (${ticketCategory})"
func (a AlfioUserResolver) Resolve(event string, user string) (string, error) {
url := fmt.Sprintf("%s/event/%s/ticket/%s", a.apiUrl, event, user)
res, err := http.Get(url)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf(res.Status)
}
var ticket AlfioTicket
decoder := json.NewDecoder(res.Body)
if err := decoder.Decode(&ticket); err != nil {
return "", err
}
username := fmt.Sprintf("%s (%s)", ticket.FullName, ticket.TicketCategoryName)
return username, nil
}

50
pkg/microchat/cache.go Normal file
View File

@ -0,0 +1,50 @@
package main
import (
"context"
"fmt"
"time"
"github.com/go-redis/redis/v8"
)
// CacheResolver is a UserResolver that caches whatever's returned
type CacheResolver struct {
resolver UserResolver
rdb *redis.Client
expiration time.Duration
}
// NewCacheResolver returns a new CacheResolver
//
// Items will be cached in rdb with an expration of expiration.
func NewCacheResolver(resolver UserResolver, rdb *redis.Client, expiration time.Duration) *CacheResolver {
return &CacheResolver{
rdb: rdb,
resolver: resolver,
expiration: expiration,
}
}
// Resolve resolves an eventID and userID.
//
// It checks the cache first. If a match is found, that is returned.
// If not, it passes the request along to the upstream Resolver,
// caches the result, and returns it.
func (cr *CacheResolver) Resolve(eventID string, userID string) (string, error) {
key := fmt.Sprintf("username:%s|%s", eventID, userID)
name, err := cr.rdb.Get(context.TODO(), key).Result()
if err == nil {
// Cache hit
return name, nil
}
name, err = cr.resolver.Resolve(eventID, userID)
if err != nil {
return "", err
}
cr.rdb.Set(context.TODO(), key, name, cr.expiration)
return name, nil
}

51
pkg/microchat/hmac.go Normal file
View File

@ -0,0 +1,51 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"log"
"strings"
)
// HmacResolverSeparator is the string used to separater username from hmac
const HmacResolverSeparator = "::"
// HmacResolver resolves usernames using SHA256 HMAC
type HmacResolver struct {
key string
}
// Resolve resolves usernames using HMAC.
//
// User strings are expected to be the concatenation of:
// desired username, HmacResolverSeparator, MAC
//
// If there is no separator, the correct user string is computed and printed to the log.
// So you can use this to compute the correct usernames.
func (h *HmacResolver) Resolve(event string, user string) (string, error) {
userparts := strings.Split(user, HmacResolverSeparator)
username := userparts[0]
mac := hmac.New(sha256.New, []byte(h.key))
fmt.Fprint(mac, event)
fmt.Fprint(mac, user)
expectedMAC := mac.Sum(nil)
if len(userparts) == 1 {
expectedEnc := base64.URLEncoding.EncodeToString(expectedMAC)
log.Printf("Authenticated username: %s%s%s", username, HmacResolverSeparator, expectedEnc)
return "", fmt.Errorf("No authentication provided")
}
givenMAC, err := base64.URLEncoding.DecodeString(userparts[1])
if err != nil {
return "", err
}
if hmac.Equal(givenMAC, expectedMAC) {
return username, nil
}
return "", fmt.Errorf("Authentication failed")
}

10
pkg/microchat/message.go Normal file
View File

@ -0,0 +1,10 @@
package main
// Message contains everything sent to the client about a single message
type Message struct {
// User is the full ID of the user sending this message
User string
// Text is the message itself
Text string
}

228
pkg/microchat/microchat.go Normal file
View File

@ -0,0 +1,228 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"time"
"github.com/go-redis/redis/v8"
)
// Send something to the client at least this often, no matter what
const Keepalive = 30 * time.Second
// UserResolver can turn event ID and user ID into a username
type UserResolver interface {
// Resolve takes an event ID and user ID, and returns a username
Resolve(string, string) (string, error)
}
// resolver is the UserResolver currently in use for this server instance
var resolver UserResolver
// throttler is our global Throttler
var throttler *Throttler
var rdb *redis.Client
func forumKey(event string, forum string) string {
return fmt.Sprintf("%s|%s", event, forum)
}
type LogEvent struct {
Event string
User string
Username string
Forum string
Text string
}
func sayHandler(w http.ResponseWriter, r *http.Request) {
event := r.FormValue("event")
user := r.FormValue("user")
forum := r.FormValue("forum") // this can be empty
text := r.FormValue("text")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
if (event == "") || (user == "") || (text == "") {
http.Error(w, "Insufficient Arguments", http.StatusBadRequest)
return
}
if len(text) > 4096 {
http.Error(w, "Too Long", http.StatusRequestEntityTooLarge)
return
}
logEvent := LogEvent{
Event: event,
User: user,
Forum: forum,
Text: text,
}
if username, err := resolver.Resolve(event, user); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
log.Println("Rejected say", event, user, text)
return
} else {
logEvent.Username = username
}
if !throttler.CanPost(event, user) {
log.Println("Rejected (too fast)", logEvent)
http.Error(w, "Slow Down", http.StatusTooManyRequests)
return
}
rdb.XAdd(
context.Background(),
&redis.XAddArgs{
Stream: forumKey(event, forum),
ID: "*",
Values: map[string]interface{}{
"user": user,
"text": text,
"client": r.RemoteAddr,
},
},
)
log.Println("Posted", logEvent)
w.WriteHeader(http.StatusOK)
}
func readHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
event := r.FormValue("event")
user := r.FormValue("user")
since := r.FormValue("since")
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
if (event == "") || (user == "") {
http.Error(w, "Insufficient Arguments", http.StatusBadRequest)
return
}
var fora []string
for _, forum := range r.Form["forum"] {
fora = append(fora, forumKey(event, forum))
}
if since == "" {
since = "0"
}
if _, err := resolver.Resolve(event, user); err != nil {
log.Println("Rejected read", event, user)
http.Error(w, err.Error(), http.StatusForbidden)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Cannot flush this connection", http.StatusInternalServerError)
return
}
for {
if err := r.Context().Err(); err != nil {
break
}
var streams []string
for _, forum := range fora {
streams = append(streams, forum, since)
}
results, err := rdb.XRead(
context.Background(),
&redis.XReadArgs{
Streams: streams,
Count: 0,
Block: Keepalive,
},
).Result()
if err == redis.Nil {
// Keepalive timeout was hit with no data
fmt.Fprintln(w, ": ping")
} else if err != nil {
log.Fatalf("XReadStreams(%v) => %v, %v", streams, results, err)
}
for _, res := range results {
for _, rmsg := range res.Messages {
var user string
if val, ok := rmsg.Values["user"]; !ok {
http.Error(w, fmt.Sprintf("user not defined on message %s", rmsg.ID), http.StatusInternalServerError)
return
} else {
user = val.(string)
}
username, err := resolver.Resolve(event, user)
if err != nil {
username = fmt.Sprintf("??? %s", err.Error())
}
ucmsg := Message{
User: username,
Text: rmsg.Values["text"].(string),
}
jmsg, err := json.Marshal(ucmsg)
if err != nil {
http.Error(w, fmt.Sprintf("JSON Marshal: %s", err.Error()), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "id: %s\n", rmsg.ID)
fmt.Fprintf(w, "data: %s\n", string(jmsg))
fmt.Fprintf(w, "\n")
// next loop iteration, only ask for stuff that's happened since the last message
since = rmsg.ID
}
}
flusher.Flush()
}
}
func main() {
redisServer := flag.String("redis", "localhost:6379", "redis server")
alfioAuth := flag.String("alfio", "", "Enable alfio authentication with given API base URL")
hmacAuth := flag.String("hmac", "", "Enable HMAC authentication with given secret")
noAuth := flag.Bool("noauth", false, "Enable lame (aka no) authentication")
flag.Parse()
rdb = redis.NewClient(&redis.Options{Addr: *redisServer})
if *alfioAuth != "" {
alfResolver := NewAlfioUserResolver(*alfioAuth)
resolver = NewCacheResolver(alfResolver, rdb, 15*time.Minute)
} else if *hmacAuth != "" {
resolver = &HmacResolver{key: *hmacAuth}
} else if *noAuth {
resolver = NoAuthResolver{}
} else {
log.Fatal("No resolver specified")
return
}
throttler = &Throttler{
rdb: rdb,
expiration: 2 * time.Second,
}
http.HandleFunc("/say", sayHandler)
http.HandleFunc("/read", readHandler)
http.Handle("/", http.FileServer(http.Dir("static/")))
bind := ":8080"
log.Printf("Listening on %s", bind)
log.Fatal(http.ListenAndServe(bind, nil))
}

18
pkg/microchat/noauth.go Normal file
View File

@ -0,0 +1,18 @@
package main
import "fmt"
// NoAuthResolver is a pass-through resolver
type NoAuthResolver struct {
}
// Resolve just returns user, no authentication whatsover is performed
func (n NoAuthResolver) Resolve(event string, user string) (string, error) {
if (event == "") || (user == "") {
return user, fmt.Errorf("User and event must be specified")
}
if (len(event) > 40) || (len(user) > 40) {
return "", fmt.Errorf("Too large for me to handle!")
}
return user, nil
}

37
pkg/microchat/throttle.go Normal file
View File

@ -0,0 +1,37 @@
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/go-redis/redis/v8"
)
// Throttler provides a per-user timeout on posting
type Throttler struct {
rdb *redis.Client
expiration time.Duration
}
// CanPost returns true if the given userID is okay to post
func (t *Throttler) CanPost(eventID string, userID string) bool {
key := fmt.Sprintf("throttle:%s|%s", eventID, userID)
setargs := t.rdb.SetArgs(
context.TODO(),
key,
true,
redis.SetArgs{
Mode: "NX",
TTL: t.expiration,
},
)
if err := setargs.Err(); err == redis.Nil {
return false
} else if err != nil {
log.Print(err)
}
return true
}

View File

@ -0,0 +1,51 @@
package namesubfs
import (
"io/fs"
"log"
"path"
)
// Sub returns a NameSubFS corresponding to the subtree rooted at fsys's dir.
func NameSub(fsys fs.FS, dir string) (*NameSubFS, error) {
switch f := fsys.(type) {
case *NameSubFS:
return f.NameSub(dir)
default:
baseFS := &NameSubFS{fsys, ""}
return baseFS.NameSub(dir)
}
}
// A NameSubFS is a file system allowing the query of the full path name of entries
type NameSubFS struct {
fs.FS
dir string
}
// FullName returns the path to name.
//
// This is not the absolute path!
// It is relative to whatever was provided to the initial Sub call.
func (f *NameSubFS) FullName(name string) string {
return path.Join(f.dir, name)
}
// NameSub returns a NameSubFS corresponding to the subtree rooted at dir.
func (f *NameSubFS) NameSub(dir string) (*NameSubFS, error) {
log.Println("Sub", f.dir)
newFS, err := fs.Sub(f.FS, dir)
if err != nil {
return nil, err
}
newNameSubFS := NameSubFS{
FS: newFS,
dir: f.FullName(dir),
}
return &newNameSubFS, err
}
// NameSub returns an FS corresponding to the subtree rooted at dir.
func (f *NameSubFS) Sub(dir string) (fs.FS, error) {
return f.NameSub(dir)
}

View File

@ -0,0 +1,39 @@
package namesubfs
import (
"io/fs"
"testing"
"testing/fstest"
)
func TestSubFS(t *testing.T) {
testfs := fstest.MapFS{
"static/moo.txt": &fstest.MapFile{Data: []byte("moo.\n")},
"static/subdir/moo2.txt": &fstest.MapFile{Data: []byte("moo too.\n")},
}
if static, err := NameSub(testfs, "static"); err != nil {
t.Error(err)
} else if buf, err := fs.ReadFile(static, "moo.txt"); err != nil {
t.Error(err)
} else if string(buf) != "moo.\n" {
t.Error("Wrong file contents")
} else if subdir, err := NameSub(static, "subdir"); err != nil {
t.Error(err)
} else if buf, err := fs.ReadFile(subdir, "moo2.txt"); err != nil {
t.Error(err)
} else if string(buf) != "moo too.\n" {
t.Error("Wrong file contents too")
} else if subdir.FullName("glue") != "static/subdir/glue" {
t.Error("Wrong full name", subdir.FullName("glue"))
}
if a, err := NameSub(testfs, "a"); err != nil {
t.Error(err)
} else if b, err := fs.Sub(a, "b"); err != nil {
t.Error(err)
} else if c, err := NameSub(b, "c"); err != nil {
t.Error(err)
} else if c.FullName("d") != "a/b/c/d" {
t.Error(c.FullName("d"))
}
}

28
pkg/subfs/subfs.go Normal file
View File

@ -0,0 +1,28 @@
package transpile
import (
"io/fs"
"path"
)
func Sub(fsys fs.FS, dir string) (*SubFS, error) {
return &SubFS{fsys, dir}, nil
}
type SubFS struct {
fs.FS
dir string
}
func (f *SubFS) FullName(name string) string {
return path.Join(f.dir, name)
}
func (f *SubFS) Sub(dir string) (*SubFS, error) {
newFS, err := fs.Sub(f, dir)
newSubFS := SubFS{
FS: newFS,
dir: f.FullName(dir),
}
return &newSubFS, err
}

26
pkg/subfs/subfs_test.go Normal file
View File

@ -0,0 +1,26 @@
package transpile
import (
"io/fs"
"os"
"testing"
)
func TestSubFS(t *testing.T) {
testdata := os.DirFS("testdata")
if static, err := Sub(testdata, "static"); err != nil {
t.Error(err)
} else if buf, err := fs.ReadFile(static, "moo.txt"); err != nil {
t.Error(err)
} else if string(buf) != "moo.\n" {
t.Error("Wrong file contents")
} else if subdir, err := static.Sub("subdir"); err != nil {
t.Error(err)
} else if buf, err := fs.ReadFile(subdir, "moo2.txt"); err != nil {
t.Error(err)
} else if string(buf) != "moo too.\n" {
t.Error("Wrong file contents too")
} else if subdir.FullName("glue") != "static/subdir/glue" {
t.Error("Wrong full name")
}
}

1
pkg/subfs/testdata/moo.txt vendored Normal file
View File

@ -0,0 +1 @@
moo.

1
pkg/subfs/testdata/subdir/moo2.txt vendored Normal file
View File

@ -0,0 +1 @@
moo too.

View File

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

View File

@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io/fs"
"log"
"os/exec"
"path"
@ -12,6 +13,7 @@ import (
"strings"
"time"
"github.com/dirtbags/moth/pkg/namesubfs"
"github.com/spf13/afero"
)
@ -28,41 +30,20 @@ type Category interface {
// Puzzle provides a Puzzle structure for the given point value.
Puzzle(points int) (Puzzle, error)
// Open returns an io.ReadCloser for the given filename.
Open(points int, filename string) (ReadSeekCloser, error)
// Answer returns whether the given answer is correct.
Answer(points int, answer string) bool
}
// NopReadCloser provides an io.ReadCloser which does nothing.
type NopReadCloser struct {
}
// Read satisfies io.Reader.
func (n NopReadCloser) Read(b []byte) (int, error) {
return 0, nil
}
// Close satisfies io.Closer.
func (n NopReadCloser) Close() error {
return nil
}
// NewFsCategory returns a Category based on which files are present.
// If 'mkcategory' is present and executable, an FsCommandCategory is returned.
// Otherwise, FsCategory is returned.
func NewFsCategory(fs afero.Fs, cat string) Category {
bfs := NewRecursiveBasePathFs(fs, cat)
if info, err := bfs.Stat("mkcategory"); (err == nil) && (info.Mode()&0100 != 0) {
if command, err := bfs.RealPath(info.Name()); err != nil {
log.Println("Unable to resolve full path to", info.Name())
} else {
return FsCommandCategory{
fs: bfs,
command: command,
timeout: 2 * time.Second,
}
func NewFsCategory(fsys fs.FS, cat string) Category {
bfs := namesubfs.Sub(fsys, cat)
if info, err := fs.Stat(bfs, "mkcategory"); (err == nil) && (info.Mode()&0100 != 0) {
return FsCommandCategory{
fs: bfs,
command: bfs.FullPath(info.Name()),
timeout: 2 * time.Second,
}
}
return FsCategory{fs: bfs}
@ -100,11 +81,6 @@ func (c FsCategory) Puzzle(points int) (Puzzle, error) {
return NewFsPuzzlePoints(c.fs, points).Puzzle()
}
// Open returns an io.ReadCloser for the given filename.
func (c FsCategory) Open(points int, filename string) (ReadSeekCloser, error) {
return NewFsPuzzlePoints(c.fs, points).Open(filename)
}
// Answer checks whether an answer is correct.
func (c FsCategory) Answer(points int, answer string) bool {
// BUG(neale): FsCategory.Answer should probably always return false, to prevent you from running uncompiled puzzles with participants.
@ -177,13 +153,7 @@ func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) {
return p, nil
}
// Open returns an io.ReadCloser for the given filename.
func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) {
stdout, err := c.run("file", strconv.Itoa(points), filename)
return nopCloser{bytes.NewReader(stdout)}, err
}
// Answer checks whether an answer is correct.
// Answer checks whether an answer is correct.Open
func (c FsCommandCategory) Answer(points int, answer string) bool {
stdout, err := c.run("answer", strconv.Itoa(points), answer)
if err != nil {

View File

@ -3,11 +3,10 @@ package transpile
import (
"bytes"
"io"
"os"
"os/exec"
"strings"
"testing"
"github.com/spf13/afero"
)
func TestFsCategory(t *testing.T) {
@ -33,7 +32,9 @@ func TestFsCategory(t *testing.T) {
t.Error("Incorrect answer accepted as correct")
}
if r, err := c.Open(1, "moo.txt"); err != nil {
if p, err := c.Puzzle(1); err != nil {
t.Error(err)
} else if r, err := p.Open("moo.txt"); err != nil {
t.Log(c.Puzzle(1))
t.Error(err)
} else {
@ -54,8 +55,8 @@ func TestFsCategory(t *testing.T) {
}
func TestOsFsCategory(t *testing.T) {
fs := NewRecursiveBasePathFs(afero.NewOsFs(), "testdata")
static := NewFsCategory(fs, "static")
fsys := os.DirFS("testdata")
static := NewFsCategory(fsys, "static")
if p, err := static.Puzzle(1); err != nil {
t.Error(err)
@ -71,7 +72,7 @@ func TestOsFsCategory(t *testing.T) {
t.Error("Wrong authors", p.Authors)
}
generated := NewFsCategory(fs, "generated")
generated := NewFsCategory(fsys, "generated")
if inv, err := generated.Inventory(); err != nil {
t.Error(err)

View File

@ -4,11 +4,12 @@ import (
"bufio"
"bytes"
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/mail"
"os"
@ -18,7 +19,7 @@ import (
"strings"
"time"
"github.com/spf13/afero"
"github.com/dirtbags/moth/pkg/namesubfs"
"gopkg.in/yaml.v2"
)
@ -37,50 +38,27 @@ type PuzzleDebug struct {
Summary string
}
// Puzzle contains everything about a puzzle that a client will see.
type Puzzle struct {
// Debug contains debugging information, omitted in mothballs
Debug PuzzleDebug
// Authors names all authors of this puzzle
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
// PuzzleMetadata contains everything about a puzzle that a client would see.
type PuzzleMetadata struct {
Debug PuzzleDebug
Authors []string
Attachments []string
Scripts []string
Body string
AnswerPattern string
// AnswerHashes contains hashes of all answers for this puzzle
AnswerHashes []string
// Answers lists all acceptable answers, omitted in mothballs
Answers []string
// Extra is send unchanged to the client.
// Eventually, Objective, KSAs, and Success will move into Extra.
Extra map[string]any
// 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
AnswerHashes []string
Objective string
KSAs []string
Success struct {
Acceptable string
// Mastery describes the work required to be considered mastering this puzzle's concepts
Mastery string
Mastery string
}
// Answers will be empty in a mothball
Answers []string
}
type Puzzle interface {
}
func (puzzle *Puzzle) computeAnswerHashes() {
@ -89,9 +67,9 @@ func (puzzle *Puzzle) computeAnswerHashes() {
}
puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
for i, answer := range puzzle.Answers {
sum := sha1.Sum([]byte(answer))
sum := sha256.Sum256([]byte(answer))
hexsum := fmt.Sprintf("%x", sum)
puzzle.AnswerHashes[i] = hexsum[:4]
puzzle.AnswerHashes[i] = hexsum
}
}
@ -101,15 +79,14 @@ type StaticPuzzle struct {
Attachments []StaticAttachment
Scripts []StaticAttachment
AnswerPattern string
Answers []string
Debug PuzzleDebug
Extra map[string]any
Objective string
Success struct {
Acceptable string
Mastery string
}
KSAs []string
KSAs []string
Debug PuzzleDebug
Answers []string
}
// StaticAttachment carries information about an attached file.
@ -138,35 +115,27 @@ func (sa *StaticAttachment) UnmarshalYAML(unmarshal func(interface{}) error) err
return nil
}
// ReadSeekCloser provides io.Reader, io.Seeker, and io.Closer.
type ReadSeekCloser interface {
io.Reader
io.Seeker
io.Closer
}
// PuzzleProvider establishes the functionality required to provide one puzzle.
type PuzzleProvider interface {
// Puzzle returns a Puzzle struct for the current puzzle.
Puzzle() (Puzzle, error)
// Open returns a newly-opened file.
Open(filename string) (ReadSeekCloser, error)
Open(filename string) (fs.File, error)
// Answer returns whether the provided answer is correct.
Answer(answer string) bool
}
// NewFsPuzzle returns a new FsPuzzle.
func NewFsPuzzle(fs afero.Fs) PuzzleProvider {
func NewFsPuzzle(fsys fs.FS) (PuzzleProvider, error) {
var command string
bfs := NewRecursiveBasePathFs(fs, "")
if info, err := bfs.Stat("mkpuzzle"); !os.IsNotExist(err) {
if bfs, err := namesubfs.Sub(fsys, ""); err != nil {
return nil, err
} else if info, err := fs.Stat(bfs, "mkpuzzle"); !os.IsNotExist(err) {
if (info.Mode() & 0100) != 0 {
if command, err = bfs.RealPath(info.Name()); err != nil {
log.Println("WARN: Unable to resolve full path to", info.Name())
}
command = bfs.FullName(info.Name())
} else {
log.Println("WARN: mkpuzzle exists, but isn't executable.")
}
@ -174,26 +143,27 @@ func NewFsPuzzle(fs afero.Fs) PuzzleProvider {
if command != "" {
return FsCommandPuzzle{
fs: fs,
fs: fsys,
command: command,
timeout: 2 * time.Second,
}
}, nil
}
return FsPuzzle{
fs: fs,
}
fs: fsys,
}, nil
}
// NewFsPuzzlePoints returns a new FsPuzzle for points.
func NewFsPuzzlePoints(fs afero.Fs, points int) PuzzleProvider {
return NewFsPuzzle(NewRecursiveBasePathFs(fs, strconv.Itoa(points)))
func NewFsPuzzlePoints(fs fs.FS, points int) PuzzleProvider {
subfs, _ := namesubfs.Sub(fs, strconv.Itoa(points))
return NewFsPuzzle(subfs)
}
// FsPuzzle is a single puzzle's directory.
type FsPuzzle struct {
fs afero.Fs
fs fs.FS
mkpuzzle bool
}
@ -210,7 +180,6 @@ func (fp FsPuzzle) Puzzle() (Puzzle, error) {
puzzle.Debug = static.Debug
puzzle.Answers = static.Answers
puzzle.Authors = static.Authors
puzzle.Extra = static.Extra
puzzle.Objective = static.Objective
puzzle.KSAs = static.KSAs
puzzle.Success = static.Success
@ -248,7 +217,7 @@ func (fp FsPuzzle) Open(name string) (ReadSeekCloser, error) {
}
}
if fsPath == "" {
return empty, fmt.Errorf("not listed in attachments or scripts: %s", name)
return empty, fmt.Errorf("Not listed in attachments or scripts: %s", name)
}
return fp.fs.Open(fsPath)
@ -334,7 +303,7 @@ func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) {
p := StaticPuzzle{}
m, err := mail.ReadMessage(r)
if err != nil {
return p, fmt.Errorf("parsing RFC822 headers: %v", err)
return p, fmt.Errorf("Parsing RFC822 headers: %v", err)
}
for key, val := range m.Header {
@ -365,7 +334,7 @@ func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) {
case "success.mastery":
p.Success.Mastery = val[0]
default:
return p, fmt.Errorf("unknown header field: %s", key)
return p, fmt.Errorf("Unknown header field: %s", key)
}
}
@ -388,7 +357,7 @@ func (fp FsPuzzle) Answer(answer string) bool {
// FsCommandPuzzle provides an FsPuzzle backed by running a command.
type FsCommandPuzzle struct {
fs afero.Fs
fs fs.FS
command string
timeout time.Duration
}

View File

@ -23,12 +23,6 @@ func TestPuzzle(t *testing.T) {
if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") {
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") {
t.Error("Authors are wrong", p.Authors)
}

View File

@ -1,175 +0,0 @@
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,182 +1,132 @@
/* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
:root {
--bg: #010e19;
--fg: #edd488;
--bg-main: #000d;
--heading: #cb2408cc;
--bg-heading1: #cb240844;
--fg-link: #b9cbd8;
--bg-input: #ccc4;
--bg-input-hover: #8884;
--bg-notification: #ac8f3944;
--bg-error: #f00;
--fg-error: white;
--bg-category: #ccc4;
--bg-input-invalid: #800;
--fg-input-invalid: white;
--bg-mothball: #ccc;
--bg-debug: #cccc;
--fg-debug: black;
--bg-toast: #333;
--fg-toast: #eee;
--box-toast: #0b0;
}
@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.
*/
:root {
--bg: #b9cbd8;
--fg: black;
--bg-main: #fffd;
--fg-link: #092b45;
}
}
/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */
body {
font-family: sans-serif;
background: var(--bg) url("bg.png") center fixed;
background-size: cover;
background-blend-mode: soft-light;
background-color: var(--bg);
color: var(--fg);
}
canvas.wallpaper {
position: fixed;
display: block;
z-index: -1000;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
opacity: 0.2;
image-rendering: pixelated;
}
@media (prefers-reduced-motion) {
canvas.wallpaper {
display: none;
}
}
main {
max-width: 40em;
margin: 1em auto;
padding: 1px 3px;
border-radius: 5px;
background: var(--bg-main);
background: #282a33;
color: #f6efdc;
}
h1, h2, h3, h4, h5, h6 {
color: var(--heading);
body.wide {
max-width: 100%;
}
a:any-link {
color: #8b969a;
}
h1 {
background: var(--bg-heading1);
padding: 3px;
background: #5e576b;
color: #9e98a8;
}
.Fail, .Error, #messages {
background: #3a3119;
color: #ffcc98;
}
.Fail:before {
content: "Fail: ";
}
.Error:before {
content: "Error: ";
}
p {
margin: 1em 0em;
}
a:any-link {
color: var(--fg-link);
}
form, pre {
margin: 1em;
overflow-x: auto;
}
input, select {
padding: 0.6em;
margin: 0.2em;
max-width: 30em;
}
input {
background-color: var(--bg-input);
color: inherit;
}
input:hover {
background-color: var(--bg-input-hover);
}
input:active {
background-color: inherit;
}
.notification, .error {
padding: 0 1em;
border-radius: 8px;
}
.notification {
background: var(--bg-notification);
}
.error {
background: var(--bg-error);
color: var(--fg-error);
}
.hidden {
display: none;
}
/** Puzzles list */
.category {
margin: 5px 0;
background: var(--bg-category);
}
.category h2 {
margin: 0 0.2em;
}
.category .solved {
text-decoration: line-through;
nav {
border: solid black 2px;
}
nav ul, .category ul {
margin: 0;
padding: 0.2em 1em;
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
padding: 1em;
}
nav li, .category li {
display: inline;
margin: 1em;
}
.category li.entitled {
flex-basis: 100%;
iframe#body {
border: inherit;
width: 100%;
}
.mothball {
float: right;
text-decoration: none;
border-radius: 5px;
background: var(--bg-mothball);
padding: 4px 8px;
margin: 5px;
}
/** Puzzle content */
#puzzle {
border-bottom: solid;
padding: 0 0.5em;
}
#puzzle img {
img {
max-width: 100%;
}
input:invalid {
background-color: var(--bg-input-invalid);
color: var(--fg-input-invalid);
border-color: red;
}
.answer_ok {
cursor: help;
#messages {
min-height: 3em;
border: solid black 2px;
}
#rankings {
width: 100%;
position: relative;
}
/** Development mode information */
.debug {
overflow: auto;
padding: 1em;
border-radius: 10px;
margin: 2em auto;
background: var(--bg-debug);
color: var(--fg-debug);
#rankings span {
font-size: 75%;
display: inline-block;
overflow: hidden;
height: 1.7em;
}
.debug dt {
font-weight: bold;
#rankings span.teamname {
font-size: inherit;
color: white;
text-shadow: 0 0 3px black;
opacity: 0.8;
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;}
#devel {
background-color: #eee;
color: black;
overflow: scroll;
}
#devel .string {
color: #9c27b0;
}
#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 {
content: "↕";
padding: 0.5em;
@ -194,31 +144,6 @@ li[draggable] {
border: 1px white dashed;
}
/** 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: var(--bg-toast);
color: var(--fg-toast);
box-shadow: 0px 0px 8px 0px var(--box-toast);
}
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
#cacheButton.disabled {
display: none;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

View File

@ -1,88 +0,0 @@
/**
* 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)
/** A channel to monitor for state updates (or to notify of state updates) */
const StateUpdateChannel = new BroadcastChannel("StateUpdate")
/**
* 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,
StateUpdateChannel,
BaseURL,
Toast,
WhenDOMLoaded,
Truthy,
Config,
}

View File

@ -1,21 +0,0 @@
{
"PuzzleList": {
"TrackSolved": true,
"Titles": false,
"": 0
},
"Puzzle": {
"": 0
},
"Scoreboard": {
"DisplayServerURLWhenEnabled": true,
"ShowCategoryLeaders": true,
"ReplayHistory": true,
"ReplayFPS": 6,
"ReplayDurationMS": 2000,
"NoScoresHtml": "<div class='notification'><h2>~ no scores ~</h2></div>",
"": 0
},
"Messages": "<!-- Messages can go here (HTML) -->",
"": "this is here so you don't have to remember to take the comma off the last item"
}

Binary file not shown.

View File

@ -1,44 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<html>
<head>
<title>MOTH</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<link rel="icon" href="luna-moth.svg">
<link rel="stylesheet" href="basic.css">
<script src="index.mjs" type="module" async></script>
<script src="background.mjs" type="module" async></script>
<script src="moth.js"></script>
<link rel="manifest" href="manifest.json">
</head>
<body>
<h1 class="title" title="Monarch Of The Hill">MOTH</h1>
<main>
<div class="messages notification">
<h1 id="title">MOTH</h1>
<section>
<div id="messages">
<div id="notices"></div>
</div>
<form class="login">
<form id="login">
<!--
<span id="pid">
Participant ID: <input name="pid"> (optional) <br>
</span>
-->
Team ID: <input name="id"> <br>
Team name: <input name="name"> <br>
<input type="submit" value="Sign In">
</form>
<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>
<div id="puzzles"></div>
</section>
<nav>
<ul>
<li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>
<li><button class="logout">Sign Out</button></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
<li><a href="logout.html">Sign Out</a></li>
</ul>
</nav>
</body>

View File

@ -1,203 +0,0 @@
/**
* 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())
}
common.StateUpdateChannel.addEventListener("message", () => {
// Give mothd time to catch up
setTimeout(() => this.UpdateState(), 1/2 * common.Second)
})
setInterval(() => this.UpdateState(), common.Minute/3)
setInterval(() => this.UpdateConfig(), common.Minute* 5)
this.UpdateConfig()
.finally(() => this.UpdateState())
}
handleLoginSubmit(event) {
event.preventDefault()
let f = new FormData(event.target)
this.Login(f.get("id"), f.get("name"))
}
/**
* 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.PuzzleList?.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.PuzzleList?.TrackSolved) {
a.classList.toggle("solved", this.state.IsSolved(puzzle))
}
if (this.config.PuzzleList?.Titles) {
this.loadTitle(puzzle, i)
}
}
if (!this.state.ContainsUnsolved(cat)) {
l.appendChild(document.createElement("li")).textContent = "✿"
}
element.appendChild(pdiv)
}
}
/**
* Asynchronously loads a puzzle, in order to populate the title.
*
* Calling this for every open puzzle will generate a lot of load on the server.
* If we decide we want this for a multi-participant server,
* we should implement some sort of cache.
*
* @param {Puzzle} puzzle
* @param {Element} element
*/
async loadTitle(puzzle, element) {
await puzzle.Populate()
let title = puzzle.Extra.title
if (!title) {
return
}
element.classList.add("entitled")
for (let a of element.querySelectorAll("a")) {
a.textContent += `: ${title}`
}
}
}
function init() {
window.app = new App()
}
common.WhenDOMLoaded(init)

23
theme/logout.html Normal file
View File

@ -0,0 +1,23 @@
<!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>

9
theme/manifest.json Normal file
View File

@ -0,0 +1,9 @@
{
"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 Normal file

File diff suppressed because one or more lines are too long

203
theme/moth.js Normal file
View File

@ -0,0 +1,203 @@
// 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()
}

View File

@ -1,684 +0,0 @@
/**
* 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.Scripts ||= []
this.Debug ||= {}
this.Debug.Errors ||= []
this.Debug.Hints ||= []
this.Debug.Log ||= []
this.Extra ||= {}
// Be ready to handle a future revision to the Puzzle structure
this.Objective ||= this.Extra.Objective
this.KSAs ||= this.Extra.KSAs || []
this.Success ||= this.Extra.Success || {}
}
/**
* 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,
}
/** True if the server is in enabled state, or if we don't know */
this.Enabled = obj.Enabled ?? true
/** 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() {
return [...this.ScoresHistory()].pop()
}
}
/**
* 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,
State,
}

View File

@ -1,117 +0,0 @@
@font-face {
font-family: "Go";
src: url("fonts/Go-Regular.ttf");
}
@font-face {
font-family: "Go-Mono";
src: url("fonts/Go-Mono.ttf");
}
/** Workspace
*
* Tools for this puzzle: shows up in content.
* Right now this is just a Python interpreter.
*/
.workspace {
background-color: rgba(255, 240, 220, 0.3);
white-space: normal;
padding: 0;
}
.output {
background-color: #555;
color: #fff;
margin: 0.5em 0;
padding: 0.5em;
flex-grow: 1;
flex-shrink: 1;
min-height: 3em;
max-height: 24em;
overflow: scroll;
}
.output, .editor {
font-family: Go, "source code pro", consolas, monospace;
}
.fixed .output, .fixed .editor {
font-family: "source code pro", consolas, monospace;
}
.controls {
display: flex;
align-items: center;
gap: 0.5em;
}
.controls .status {
font-size: 9pt;
flex-grow: 2;
}
.controls .language {
font-size: 9pt;
font-style: italic;
}
.stdout,
.stderr,
.stdinfo,
.traceback {
white-space: pre-wrap;
}
.stderr {
color: #f88;
}
.traceback {
background-color: #222;
}
.stdinfo {
font-style: italic;
}
.editor {
border: 1px solid black;
overflow-y: scroll;
max-height: 24em;
display: flex;
flex-grow: 1;
flex-shrink: 1;
font-size: 12pt;
line-height: 1.2rem;
}
.editor .linenos {
background-color: #eee;
white-space: pre;
min-width: 2em;
padding: 0 4px;
text-align: right;
height: fit-content;
}
.editor .text {
background-color: #fff;
flex-grow: 1;
flex-shrink: 1;
white-space: nowrap;
overflow-x: scroll;
overflow-y: hidden;
padding: 0 4px;
height: fit-content;
min-height: 8em;
}
/* Some things that crop up in puzzles */
[draggable] {
padding-left: 1em;
background-image: url(../images/drag-handle.svg);
background-position: 0 center;
background-size: 1em 1em;
background-repeat: no-repeat;
background-color: rgba(255, 255, 255, 0.4);
margin: 2px 0px;
cursor: move;
}
[draggable].over,
[draggable].moving {
background-color: rgba(127, 127, 127, 0.5);
}

View File

@ -1,54 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<html>
<head>
<title>Puzzle</title>
<link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width">
<meta charset="utf-8">
<link rel="icon" href="luna-moth.svg">
<link rel="stylesheet" href="basic.css">
<link rel="stylesheet" href="puzzle.css">
<script src="background.mjs" type="module" async></script>
<script src="puzzle.mjs" type="module" async></script>
<script src="puzzle.js"></script>
<script>
</script>
</head>
<body>
<h1 id="title">[loading]</h1>
<main>
<section id="puzzle">
<p class="notification">
Starting script...
</p>
</section>
<section class="meta"></section>
<ul id="files"></ul>
<p>Puzzle by <span id="authors">[loading]</span></p>
</section>
<form class="submit-answer">
<label for="answer">Answer:</label>
<input type="text" name="answer" id="answer"> <span class="answer_ok"></span>
<br>
<input type="submit" value="Submit">
</form>
</main>
<div class="debug" class="notification"></div>
<div class="toasts"></div>
<template id="workspace">
<div class="editor">
<div class="linenos"></div>
<div class="text"></div>
</div>
<div class="controls">
<button class="run">Run</button>
<button class="font" title="Switch in and out of monospace font">Font</button>
<span class="status">Execution time: 0.03s</span>
<span class="language"></span>
<button class="revert" title="Reset code to original">Revert</button>
</div>
<div class="output">
<div class="stdout"></div>
<div class="stderr"></div>
<div class="traceback"></div>
<div class="stdinfo"></div>
</div>
</template>
<h1>Puzzle</h1>
<section>
<div id="puzzle"><span class="spinner"></span></div>
<ul id="files"></ul>
<p>Puzzle by <span id="authors"></span></p>
</section>
<div id="messages"></div>
<form>
<input type="hidden" name="cat">
<input type="hidden" name="points">
<input type="hidden" name="xAnswer">
Team ID: <input type="text" name="id"> <br>
Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br>
<input type="submit" value="Submit">
</form>
<div id="devel"></div>
<nav>
<ul>
<li><a href="index.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body>
</html>

225
theme/puzzle.js Normal file
View File

@ -0,0 +1,225 @@
// 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()
}

View File

@ -1,271 +0,0 @@
/**
* Functionality for puzzle.html (Puzzle display / answer form)
*/
import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
const workspacePromise = import("./workspace/workspace.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)
common.StateUpdateChannel.postMessage({})
document.dispatchEvent(new CustomEvent("answerCorrect"))
}
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...")
let attachmentUrls = []
for (let fn of (puzzle.Attachments || [])) {
let li = document.createElement("li")
let a = document.createElement("a")
let url = new URL(fn, contentBase)
attachmentUrls.push(url)
a.href = url
a.innerText = fn
li.appendChild(a)
document.getElementById("files").appendChild(li)
}
workspacePromise.then(workspace => {
let codeBlocks = document.querySelectorAll("code[class^=language-]")
for (let i = 0; i < codeBlocks.length; i++) {
let codeBlock = codeBlocks[i]
let id = category + "#" + points + "#" + i
new workspace.Workspace(codeBlock, id, attachmentUrls)
}
})
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
}
const confettiPromise = import("https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/+esm")
async function CorrectAnswer() {
setInterval(window.close, 3 * common.Second)
let confetti = await confettiPromise
confetti.default({
disableForReducedMotion: true,
})
}
async function init() {
window.app = {}
window.setanswer = (str => SetAnswer(str))
window.checkAnswer = (str => window.app.puzzle.IsPossiblyCorrect(str))
for (let form of document.querySelectorAll("form.submit-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())
// Workspaces may trigger a "this is the answer" event
document.addEventListener("setAnswer", e => SetAnswer(e.detail.value))
// Celebrate on correct answer
document.addEventListener("answerCorrect", e => CorrectAnswer())
// 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)

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +0,0 @@
<!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>

View File

@ -1,140 +0,0 @@
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)

View File

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Scoreboard</title>
<link rel="stylesheet" href="basic.css">
<link rel="stylesheet" href="scoreboard.css">
<meta name="viewport" content="width=device-width">
<link rel="icon" href="luna-moth.svg">
<script type="module" src="scoreboard.mjs"></script>
</head>
<body>
<div class="no-scores hidden"></div>
<div class="rotate">
<div class="rankings category"></div>
<div class="rankings classic"></div>
</div>
<div class="location"></div>
</body>
</html>

View File

@ -1,120 +0,0 @@
/* GHC displays: 1024x1820 */
@media screen and (max-aspect-ratio: 4/5) and (min-height: 1600px) {
html {
font-size: 20pt;
}
}
.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;
}
.no-scores {
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
min-height: calc(100vh - 2em);
}
.no-scores.hidden {
display: none;
}
.no-scores img {
object-fit: cover;
max-height: 60vh;
}
/* Only the first child of a rotate class is visible */
.rotate > div:nth-child(n + 2) {
display: none;
}
/** Scoreboard */
.rankings.classic {
width: 100%;
position: relative;
background-color: #000c;
}
.rankings.classic div {
height: 1.2rem;
display: flex;
align-items: center;
}
.rankings.classic div:nth-child(6n){
background-color: #ccc3;
}
.rankings.classic div:nth-child(6n+3) {
background-color: #0f03;
}
.rankings.classic span {
display: inline-block;
overflow: hidden;
}
.rankings.classic span.category {
font-size: 80%;
}
.rankings.classic span.teamname {
height: auto;
font-size: inherit;
color: white;
background-color: #000e;
border-radius: 3px;
position: absolute;
right: 0.2em;
}
.rankings.classic span.teamname:hover,
.rankings.classic span.category:hover {
width: inherit;
max-width: 100%;
}
.topscore::before {
content: "✩";
font-size: 75%;
vertical-align: top;
}
.rankings.category {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
}
.rankings.category div {
border: solid black 2px;
min-width: 15em;
}
.rankings.category table {
width: 100%;
}
.rankings.category td.number {
text-align: right;
}
@media only screen and (max-width: 450px) {
.rankings.classic span.teamname {
max-width: 6em;
text-overflow: ellipsis;
}
span.teampoints {
max-width: 80%;
}
}
.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,14 +3,22 @@
<head>
<title>Scoreboard</title>
<link rel="stylesheet" href="basic.css">
<link rel="stylesheet" href="scoreboard.css">
<meta name="viewport" content="width=device-width">
<link rel="icon" href="luna-moth.svg">
<script type="module" src="scoreboard.mjs"></script>
<script src="moment.min.js" async></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" async></script>
<script src="scoreboard.js" async></script>
</head>
<body>
<div class="no-scores hidden"></div>
<div class="rankings classic"></div>
<div class="location"></div>
<body class="wide">
<h4 id="location"></h4>
<section class="rotate">
<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>
</html>

251
theme/scoreboard.js Normal file
View File

@ -0,0 +1,251 @@
// 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
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
row.appendChild(bar)
ncat += 1
}
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()
}

View File

@ -1,183 +0,0 @@
import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
const server = new moth.Server(".")
/** Don't let any team's score exceed this percentage width */
const MaxScoreWidth = 90
/**
* Returns a promise that resolves after timeout.
*
* This uses setTimeout instead of some other fancy thing like
* requestAnimationFrame, because who actually cares about scoreboard update
* framerate?
*
* @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 = {}
try {
config = await common.Config()
}
catch (err) {
console.warn("Parsing config.json:", err)
}
// Pull configuration settings
if (!config.Scoreboard) {
console.warn("config.json has empty Scoreboard section")
}
let ScoreboardConfig = config.Scoreboard ?? {}
let state = await server.GetState()
// Show URL of server
for (let e of document.querySelectorAll(".location")) {
e.textContent = common.BaseURL
e.classList.toggle("hidden", !(ScoreboardConfig.DisplayServerURLWhenEnabled && state.Enabled))
}
// Rotate views
for (let e of document.querySelectorAll(".rotate")) {
e.appendChild(e.firstChild)
}
// Render rankings
for (let e of document.querySelectorAll(".rankings")) {
if (e.classList.contains("classic")) {
classicRankings(e, state, ScoreboardConfig)
} else if (e.classList.contains("category")) {
categoryRankings(e, state, ScoreboardConfig)
}
}
}
async function classicRankings(rankingsElement, state, ScoreboardConfig) {
let ReplayHistory = ScoreboardConfig.ReplayHistory ?? false
let ReplayDurationMS = ScoreboardConfig.ReplayDurationMS ?? 300
let ReplayFPS = ScoreboardConfig.ReplayFPS ?? 24
let logSize = state.PointsLog.length
// Figure out the timing so that we can replay the scoreboard in about
// ReplayDurationMS.
let frameModulo = 1
let delay = 0
while (delay < (common.Second / ReplayFPS)) {
frameModulo += 1
delay = ReplayDurationMS / (logSize / frameModulo)
}
let frame = 0
for (let scores of state.ScoresHistory()) {
frame += 1
if (frame < state.PointsLog.length) { // Always render the last frame
if (!ReplayHistory || (frame % frameModulo)) { // Skip if we're not animating, or if we need to drop frames
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] ?? "rodney"
let row = rankingsElement.appendChild(document.createElement("div"))
let teamname = row.appendChild(document.createElement("span"))
teamname.textContent = teamName
teamname.classList.add("teamname")
let teampoints = row.appendChild(document.createElement("span"))
teampoints.classList.add("teampoints")
for (let category of scores.Categories) {
let score = scores.CyFiCategoryScore(category, teamID)
if (!score) {
continue
}
// XXX: Figure out how to do this properly with flexbox
let block = row.appendChild(document.createElement("span"))
let points = scores.GetPoints(category, teamID)
let width = MaxScoreWidth * score / topScore
let categoryNumber = [...scores.Categories].indexOf(category)
block.textContent = category
block.title = `${points} points`
block.style.width = `${width}%`
block.classList.add("category", `cat${categoryNumber}`)
block.classList.toggle("topscore", (score == 1) && ScoreboardConfig.ShowCategoryLeaders)
categoryNumber += 1
}
}
await sleep(delay)
}
for (let e of document.querySelectorAll(".no-scores")) {
e.innerHTML = ScoreboardConfig.NoScoresHtml
e.classList.toggle("hidden", frame > 0)
}
}
/**
*
* @param {*} rankingsElement
* @param {moth.State} state
* @param {*} ScoreboardConfig
*/
async function categoryRankings(rankingsElement, state, ScoreboardConfig) {
while (rankingsElement.firstChild) rankingsElement.firstChild.remove()
let scores = state.CurrentScores()
for (let category of scores.Categories) {
let categoryBox = rankingsElement.appendChild(document.createElement("div"))
categoryBox.classList.add("category")
categoryBox.appendChild(document.createElement("h2")).textContent = category
let categoryScores = []
for (let teamID in state.TeamNames) {
categoryScores.push({
teamName: state.TeamNames[teamID],
score: scores.GetPoints(category, teamID),
})
}
categoryScores.sort((a, b) => b.score - a.score)
let table = categoryBox.appendChild(document.createElement("table"))
let rows = 0
for (let categoryScore of categoryScores) {
let row = table.appendChild(document.createElement("tr"))
row.appendChild(document.createElement("td")).textContent = categoryScore.teamName
let td = row.appendChild(document.createElement("td"))
td.textContent = categoryScore.score
td.classList.add("number")
rows += 1
if (rows == 5) {
break
}
}
}
}
function init() {
setInterval(update, common.Minute)
update()
}
common.WhenDOMLoaded(init)

View File

@ -1,4 +0,0 @@
html {
background: #333;
color: white;
}

View File

@ -1,10 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Puzzle Viewer</title>
<link rel="stylesheet" href="index.css">
</head>
<body>
<object type="text/html" data="puzzle.html"></object>
</body>
</html>

View File

@ -1,42 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Example MOTHv5 Puzzle</title>
<!-- style tells the client whether to inject its own stylesheet.
By default, a client will try to style a puzzle using its own stylesheet.
If you want to override this behavior, provide "style" with
content="override".
Omitting this meta element is the same as content="inherit".
-->
<meta name="moth.style" content="inherit">
<!-- moth.answerhash is used for the "possibly correct" check.
This is the first 8 characters of the hex-encoded sha1 checksum of the answer.
If you have multiple acceptable answers, provide multiple answerhash elements.
-->
<meta name="moth.answerhash" content="2c26b46b">
<!-- author specifies the author of this puzzle.
If you have multiple authors, specify multiple meta elements,
one author per element.
-->
<meta name="author" content="Neale Pickett">
<meta name="author" content="Ford Powers">
<script href="example.mjs" type="module"></script>
</head>
<body>
<h1>Example Puzzle</h1>
<p>
This is an example puzzle, yo.
You can put whatever you want in the HTML.
If you do crazy tricks, it might break the client, though.
</p>
<img src="salad.jpg">
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@ -1,29 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<html>
<head>
<title>Redeem Token</title>
<link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width">
<script src="token.mjs" type="module" async></script>
<script src="puzzle.js"></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>
<body>
<h1>Redeem Token</h1>
<main>
<p>
Have you found a token?
</p>
<p></p>
Tokens look like
<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>
<div id="messages"></div>
<form id="tokenForm">
<input type="hidden" name="cat">
<input type="hidden" name="points">
<input type="hidden" name="answer">
Team ID: <input type="text" name="id"> <br>
Token: <input type="text" name="token"> <br>
<input type="submit" value="Submit">
</form>
<div class="toasts"></div>
<nav>
<ul>
<li><a href="puzzle-list.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body>
</html>

View File

@ -1,48 +0,0 @@
/**
* 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)

View File

@ -1,78 +0,0 @@
import * as pyodide from "https://cdn.jsdelivr.net/npm/pyodide@0.25.1/pyodide.mjs" // v0.16.1 known good
const HOME = "/home/web_user"
async function createInstance() {
let instance = await pyodide.loadPyodide()
instance.runPython("import sys")
self.postMessage({type: "loaded"})
return instance
}
const initialized = createInstance()
class Buffer {
constructor() {
this.buf = []
}
write(s) {
this.buf.push(s)
}
value() {
return this.buf.join("")
}
}
async function handleMessage(event) {
let data = event.data
let instance = await initialized
let fs = instance._module.FS
let ret = {
result: null,
answer: null,
stdout: null,
stderr: null,
traceback: null,
}
switch (data.type) {
case "nop":
// You might want to do nothing in order to display to the user that a run can now be handled
break
case "run":
let sys = instance.globals.get("sys")
sys.stdout = new Buffer()
sys.stderr = new Buffer()
instance.globals.set("setanswer", (s) => {ret.answer = s})
try {
ret.result = await instance.runPythonAsync(data.code)
} catch (err) {
ret.traceback = err
}
ret.stdout = sys.stdout.value()
ret.stderr = sys.stderr.value()
break
case "wget":
let url = data.url
let dir = data.directory || fs.cwd()
let filename = url.split("/").pop()
let path = dir + "/" + filename
if (fs.analyzePath(path).exists) {
fs.unlink(path)
}
fs.createLazyFile(dir, filename, url, true, false)
break
default:
ret.result = "Unknown message type: " + data.type
break
}
if (data.channel) {
data.channel.postMessage(ret)
}
}
self.addEventListener("message", e => handleMessage(e))

View File

@ -1,237 +0,0 @@
import {Toast} from "../common.mjs"
//import "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"
import "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"
import "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"
import * as CodeJar from "https://cdn.jsdelivr.net/npm/codejar@4.2.0"
Prism.plugins.autoloader.languages_path = "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/"
const prismCssUrl = "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css"
export class Workspace {
/**
*
* @param codeBlock {HTMLElement} The element containing the source code
* @param id {string} A unique identifier of this workspace
* @param attachmentUrls {URL[]} List of attachment URLs
*/
constructor(codeBlock, id, attachmentUrls) {
// Show a progress bar
let loadingElement = document.createElement("progress")
codeBlock.insertAdjacentElement("afterend", loadingElement)
this.language = "unknown"
for (let c of codeBlock.classList) {
let parts = c.split("-")
if ((parts.length == 2) && parts[0].startsWith("lang")) {
this.language = parts[1]
}
}
this.element = document.createElement("div")
this.element.classList.add("workspace")
let template = document.querySelector("template#workspace")
this.element.appendChild(template.content.cloneNode(true))
this.originalCode = codeBlock.textContent
this.attachmentUrls = attachmentUrls
this.storageKey = "code:" + id
// Get our document and window
this.document = this.element.ownerDocument
this.window = this.document.defaultView
// Load user modifications, if there are any
this.code = localStorage[this.storageKey] || this.originalCode
this.status = this.element.querySelector(".status")
this.linenos = this.element.querySelector(".editor .linenos")
this.editor = this.element.querySelector(".editor .text")
this.stdout = this.element.querySelector(".stdout")
this.stderr = this.element.querySelector(".stderr")
this.traceback = this.element.querySelector(".traceback")
this.stdinfo = this.element.querySelector(".stdinfo")
this.runButton = this.element.querySelector("button.run")
this.revertButton = this.element.querySelector("button.revert")
this.fontButton = this.element.querySelector("button.font")
this.element.querySelector(".language").textContent = this.language
this.runButton.disabled = true
// Load in the editor
this.editor.classList.add("language-" + this.language)
this.jar = CodeJar.CodeJar(this.editor, (editor) => this.highlight(editor), {window: this.window})
this.jar.updateCode(this.code)
switch (this.language) {
case "python":
this.jar.updateOptions({
tab: " ",
indentOn: /:$/,
})
break
}
// Load the interpreter
this.initLanguage(this.language)
.then(() => {
codeBlock.parentElement.replaceWith(this.element)
})
.catch(err => console.warn(`Unable to load interpreter: `, this.language))
.finally(() => {
loadingElement.remove()
})
this.runButton.addEventListener("click", () => this.run())
this.revertButton.addEventListener("click", () => this.revert())
this.fontButton.addEventListener("click", () => this.font())
}
initLanguage(language) {
let start = performance.now()
this.status.textContent = "Initializing..."
this.status.appendChild(document.createElement("progress"))
let workerUrl = new URL(language + ".mjs", import.meta.url)
this.worker = new Worker(workerUrl, {type: "module"})
// XXX: There has got to be a cleaner way to do this
return new Promise((resolve, reject) => {
this.worker.addEventListener("error", err => reject(err))
this.workerMessage({type: "nop"})
.then(() => {
let runtime = performance.now() - start
let duration = new Date(runtime).toISOString().slice(11, -1)
this.status.textContent = "Loaded in " + duration
this.runButton.disabled = false
for (let a of this.attachmentUrls) {
let filename = a.pathname.split("/").pop()
this.workerMessage({type: "wget", url: a.href || a})
.then(ret => {
this.stdinfo.appendChild(this.document.createElement("div")).textContent = "Downloaded " + filename
})
}
resolve()
})
})
}
workerMessage(message) {
let chan = new MessageChannel()
message.channel = chan.port2
this.worker.postMessage(message, [chan.port2])
let p = new Promise(
(resolve, reject) => {
chan.port1.addEventListener("message", e => resolve(e.data), {once: true})
}
)
chan.port1.start()
return p
}
workerReady() {
return this.workerMessage({type: "nop"})
}
workerWget(url) {
return this.workerMessage({
type: "wget",
url: url.href || url,
})
}
/**
* highlight provides a code highlighter for CodeJar
*
* It calls Prism.highlightElement, then updates line numbers
*/
highlight(editor) {
if (Prism) {
// Sometimes it loads slowly
Prism.highlightElement(editor)
} else {
console.warn("No highlighter!", Prism, this.window.document.scripts)
}
// Create a line numbers column
if (true) {
const code = editor.textContent || ""
const lines = code.split("\n")
let linesCount = lines.length
if (lines[linesCount-1]) {
linesCount += 1
}
let ltxt = ""
for (let i = 1; i < linesCount; i++) {
ltxt += i + "\n"
}
this.linenos.textContent = ltxt
}
}
setAnswer(answer) {
let evt = new CustomEvent("setAnswer", {detail: {value: answer}, bubbles: true, cancelable: true})
this.element.dispatchEvent(evt)
this.stdinfo.appendChild(this.document.createTextNode("Set answer to "))
this.stdinfo.appendChild(this.document.createElement("code")).textContent = answer
}
async run() {
let start = performance.now()
this.runButton.disabled = true
this.status.textContent = "Running..."
// Save first. Always save first.
let program = this.jar.toString()
if (program != this.originalCode) {
localStorage[this.storageKey] = program
}
let result = await this.workerMessage({
type: "run",
code: program,
})
this.stdout.textContent = result.stdout
this.stderr.textContent = result.stderr
this.traceback.textContent = result.traceback
while (this.stdinfo.firstChild) this.stdinfo.firstChild.remove()
if (result.answer) {
this.setAnswer(result.answer)
}
let runtime = performance.now() - start
let duration = new Date(runtime).toISOString().slice(11, -1)
this.status.textContent = "Ran in " + duration
this.runButton.disabled = false
}
revert() {
let currentCode = this.jar.toString()
let savedCode = localStorage[this.storageKey]
if ((currentCode == this.originalCode) && savedCode) {
this.jar.updateCode(savedCode)
Toast("Re-loaded saved code")
} else {
this.jar.updateCode(this.originalCode)
Toast("Reverted to original code")
}
}
font() {
this.element.classList.toggle("fixed")
}
}
function init() {
let link = document.head.appendChild(document.createElement("link"))
link.rel = "stylesheet"
link.href = prismCssUrl
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}