mirror of https://github.com/dirtbags/moth.git
Compare commits
2 Commits
959a802c84
...
fa5ea87f22
Author | SHA1 | Date |
---|---|---|
Neale Pickett | fa5ea87f22 | |
Neale Pickett | 55254234bf |
|
@ -1,10 +1,5 @@
|
||||||
*~
|
*~
|
||||||
*#
|
*#
|
||||||
/.idea
|
.idea
|
||||||
/vendor/
|
/vendor/
|
||||||
/__debug_bin
|
__debug_bin
|
||||||
winmoth.*.zip
|
|
||||||
/*.tar.gz
|
|
||||||
/transpile
|
|
||||||
/mothd
|
|
||||||
/*.exe
|
|
||||||
|
|
|
@ -1,59 +1,19 @@
|
||||||
stages:
|
stages:
|
||||||
- test
|
- test
|
||||||
- build
|
|
||||||
- push
|
- push
|
||||||
|
|
||||||
Run unit tests:
|
test:
|
||||||
stage: test
|
stage: test
|
||||||
image: &goimage golang:1.21
|
image: golang:1.17
|
||||||
only:
|
only:
|
||||||
refs:
|
refs:
|
||||||
- main
|
- main
|
||||||
- tags
|
|
||||||
- merge_requests
|
- merge_requests
|
||||||
script:
|
script:
|
||||||
- go test -coverprofile=coverage.txt -covermode=atomic -race ./...
|
- go test ./...
|
||||||
- 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/*
|
|
||||||
|
|
||||||
push:
|
push:
|
||||||
stage: push
|
stage: push
|
||||||
needs: ["Run unit tests"]
|
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_TAG
|
- if: $CI_COMMIT_TAG
|
||||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||||
|
|
38
CHANGELOG.md
38
CHANGELOG.md
|
@ -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/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
## [v4.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
|
## [v4.4.6] - 2021-10-26
|
||||||
### Added
|
### Added
|
||||||
- State is now cached in memory, in an attempt to reduce filesystem metadata operations,
|
- State is now cached in memory, in an attempt to reduce filesystem metadata operations,
|
||||||
|
|
36
LICENSE.md
36
LICENSE.md
|
@ -129,36 +129,10 @@ Both came with the following license:
|
||||||
> OTHER DEALINGS IN THE FONT SOFTWARE.
|
> OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
Go Fonts
|
Javascript MD5 Library
|
||||||
=======
|
======================
|
||||||
|
|
||||||
The Go fonts were obtained from
|
Obtained from <https://github.com/blueimp/JavaScript-MD5>, which says:
|
||||||
https://go.googlesource.com/image
|
|
||||||
|
|
||||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
> The JavaScript MD5 script is released under the
|
||||||
|
> [MIT license](http://www.opensource.org/licenses/MIT).
|
||||||
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.
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
Dirtbags Monarch Of The Hill Server
|
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.
|
Monarch Of The Hill (MOTH) is a puzzle server.
|
||||||
We (the authors) have used it for instructional and contest events called
|
We (the authors) have used it for instructional and contest events called
|
||||||
|
|
|
@ -43,11 +43,6 @@ case $ACTION in
|
||||||
run docker push $image
|
run docker push $image
|
||||||
done
|
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
|
echo "Unknown action: $1" 1>&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
ARG GO_VERSION=1.21-alpine
|
FROM golang:1 AS builder
|
||||||
FROM docker.io/library/golang:${GO_VERSION} AS builder
|
|
||||||
COPY go.* /src/
|
COPY go.* /src/
|
||||||
COPY pkg /src/pkg/
|
COPY pkg /src/pkg/
|
||||||
COPY cmd /src/cmd/
|
COPY cmd /src/cmd/
|
||||||
|
|
|
@ -2,34 +2,19 @@
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
cd $(dirname $0)
|
cd $(dirname $0)/../..
|
||||||
base=../..
|
|
||||||
|
|
||||||
VERSION=$(cat $base/CHANGELOG.md | awk -F '[][]' '/^## \[/ {print $2; exit}')
|
PODMAN=$(command -v podman || echo docker)
|
||||||
GO_VERSION=$(cat $base/go.mod | sed -n 's/^go //p')
|
VERSION=$(cat CHANGELOG.md | awk -F '[][]' '/^## \[/ {print $2; exit}')
|
||||||
|
|
||||||
(
|
for target in moth; do
|
||||||
zipfile=winmoth.$VERSION.zip
|
tag=dirtbags/$target:$VERSION
|
||||||
echo "=== Building $zipfile"
|
echo "==== Building $tag"
|
||||||
mkdir -p winmoth winmoth/state winmoth/puzzles winmoth/mothballs
|
$PODMAN build \
|
||||||
echo devel > winmoth/state/teamids.txt
|
--build-arg http_proxy --build-arg https_proxy --build-arg no_proxy \
|
||||||
cp moth-devel.bat winmoth
|
--tag $tag \
|
||||||
cp -a $base/theme winmoth
|
--target $target \
|
||||||
(
|
-f build/package/Containerfile .
|
||||||
cd winmoth
|
done
|
||||||
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
|
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
mkdir state
|
|
||||||
mkdir puzzles
|
|
||||||
echo devel > state/teamids.txt
|
|
||||||
.\mothd.exe -puzzles puzzles
|
|
|
@ -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
|
|
||||||
)
|
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/v4/pkg/jsend"
|
"github.com/dirtbags/moth/pkg/jsend"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTPServer is a MOTH HTTP server
|
// HTTPServer is a MOTH HTTP server
|
||||||
|
@ -44,8 +44,9 @@ func (h *HTTPServer) HandleMothFunc(
|
||||||
mothHandler func(MothRequestHandler, http.ResponseWriter, *http.Request),
|
mothHandler func(MothRequestHandler, http.ResponseWriter, *http.Request),
|
||||||
) {
|
) {
|
||||||
handler := func(w http.ResponseWriter, req *http.Request) {
|
handler := func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
participantID := req.FormValue("pid")
|
||||||
teamID := req.FormValue("id")
|
teamID := req.FormValue("id")
|
||||||
mh := h.server.NewHandler(teamID)
|
mh := h.server.NewHandler(participantID, teamID)
|
||||||
mothHandler(mh, w, req)
|
mothHandler(mh, w, req)
|
||||||
}
|
}
|
||||||
h.HandleFunc(h.base+pattern, handler)
|
h.HandleFunc(h.base+pattern, handler)
|
||||||
|
|
|
@ -4,15 +4,19 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const TestParticipantID = "shipox"
|
||||||
|
|
||||||
func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest.ResponseRecorder {
|
func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest.ResponseRecorder {
|
||||||
vals := url.Values{}
|
vals := url.Values{}
|
||||||
|
vals.Set("pid", TestParticipantID)
|
||||||
vals.Set("id", TestTeamID)
|
vals.Set("id", TestTeamID)
|
||||||
for k, v := range args {
|
for k, v := range args {
|
||||||
vals.Set(k, v)
|
vals.Set(k, v)
|
||||||
|
@ -29,7 +33,11 @@ func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHttpd(t *testing.T) {
|
func TestHttpd(t *testing.T) {
|
||||||
server := NewTestServer()
|
server, err := NewTestServer()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer server.cleanup()
|
||||||
hs := NewHTTPServer("/", server.MothServer)
|
hs := NewHTTPServer("/", server.MothServer)
|
||||||
|
|
||||||
if r := hs.TestRequest("/", nil); r.Result().StatusCode != 200 {
|
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 {
|
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
} else if r.Body.String() != `{"Config":{"Devel":false},"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())
|
t.Error("Unexpected state", r.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,11 +75,11 @@ func TestHttpd(t *testing.T) {
|
||||||
t.Error("Register failed", r.Body.String())
|
t.Error("Register failed", r.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
server.refresh()
|
time.Sleep(TestMaintenanceInterval)
|
||||||
|
|
||||||
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
} else if r.Body.String() != `{"Config":{"Devel":false},"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())
|
t.Error("Unexpected state", r.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,7 +117,7 @@ func TestHttpd(t *testing.T) {
|
||||||
t.Error("Unexpected body", r.Body.String())
|
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 {
|
if r := hs.TestRequest("/content/pategory/2/puzzle.json", nil); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
|
@ -121,20 +129,24 @@ func TestHttpd(t *testing.T) {
|
||||||
} else if err := json.Unmarshal(r.Body.Bytes(), &state); err != nil {
|
} else if err := json.Unmarshal(r.Body.Bytes(), &state); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
} else if len(state.PointsLog) != 1 {
|
} else if len(state.PointsLog) != 1 {
|
||||||
t.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 {
|
} else if len(state.Puzzles["pategory"]) != 2 {
|
||||||
t.Error("Didn't unlock next puzzle")
|
t.Error("Didn't unlock next puzzle")
|
||||||
}
|
}
|
||||||
|
|
||||||
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"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())
|
t.Error("Unexpected body", r.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevelMemHttpd(t *testing.T) {
|
func TestDevelMemHttpd(t *testing.T) {
|
||||||
srv := NewTestServer()
|
srv, err := NewTestServer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.cleanup()
|
||||||
|
|
||||||
{
|
{
|
||||||
hs := NewHTTPServer("/", srv.MothServer)
|
hs := NewHTTPServer("/", srv.MothServer)
|
||||||
|
@ -157,9 +169,9 @@ func TestDevelMemHttpd(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevelFsHttps(t *testing.T) {
|
func TestDevelFsHttps(t *testing.T) {
|
||||||
fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata")
|
fsys := os.DirFS("testdata")
|
||||||
transpilerProvider := NewTranspilerProvider(fs)
|
transpilerProvider := NewTranspilerProvider(fsys)
|
||||||
srv := NewMothServer(Configuration{Devel: true}, NewTestTheme(), NewTestState(), transpilerProvider)
|
srv := NewMothServer(Configuration{Devel: true}, NewTheme("testdata/theme"), NewTestState(), transpilerProvider)
|
||||||
hs := NewHTTPServer("/", srv)
|
hs := NewHTTPServer("/", srv)
|
||||||
|
|
||||||
if r := hs.TestRequest("/mothballer/cat0.mb", nil); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/mothballer/cat0.mb", nil); r.Result().StatusCode != 200 {
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -6,10 +6,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
@ -55,38 +52,20 @@ func main() {
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
var theme *Theme
|
theme := NewTheme(*themePath)
|
||||||
osfs := afero.NewOsFs()
|
|
||||||
if p, err := filepath.Abs(*themePath); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
} else {
|
|
||||||
theme = NewTheme(afero.NewBasePathFs(osfs, p))
|
|
||||||
}
|
|
||||||
|
|
||||||
config := Configuration{}
|
config := Configuration{}
|
||||||
|
|
||||||
var provider PuzzleProvider
|
var provider PuzzleProvider
|
||||||
if p, err := filepath.Abs(*mothballPath); err != nil {
|
provider = NewMothballs(os.DirFS(*mothballPath))
|
||||||
log.Fatal(err)
|
|
||||||
} else {
|
|
||||||
provider = NewMothballs(afero.NewBasePathFs(osfs, p))
|
|
||||||
}
|
|
||||||
if *puzzlePath != "" {
|
if *puzzlePath != "" {
|
||||||
if p, err := filepath.Abs(*puzzlePath); err != nil {
|
provider = NewTranspilerProvider(os.DirFS(*puzzlePath))
|
||||||
log.Fatal(err)
|
|
||||||
} else {
|
|
||||||
provider = NewTranspilerProvider(afero.NewBasePathFs(osfs, p))
|
|
||||||
}
|
|
||||||
config.Devel = true
|
config.Devel = true
|
||||||
log.Println("-=- You are in development mode, champ! -=-")
|
log.Println("-=- You are in development mode, champ! -=-")
|
||||||
}
|
}
|
||||||
|
|
||||||
var state StateProvider
|
var state StateProvider
|
||||||
if p, err := filepath.Abs(*statePath); err != nil {
|
state = NewState(*statePath)
|
||||||
log.Fatal(err)
|
|
||||||
} else {
|
|
||||||
state = NewState(afero.NewBasePathFs(osfs, p))
|
|
||||||
}
|
|
||||||
if config.Devel {
|
if config.Devel {
|
||||||
state = NewDevelState(state)
|
state = NewDevelState(state)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,36 +3,35 @@ package main
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/afero/zipfs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type zipCategory struct {
|
type zipCategory struct {
|
||||||
afero.Fs
|
zip.Reader
|
||||||
io.Closer
|
io.Closer
|
||||||
mtime time.Time
|
mtime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mothballs provides a collection of active mothball files (puzzle categories)
|
// Mothballs provides a collection of active mothball files (puzzle categories)
|
||||||
type Mothballs struct {
|
type Mothballs struct {
|
||||||
afero.Fs
|
fs.FS
|
||||||
categories map[string]zipCategory
|
categories map[string]zipCategory
|
||||||
categoryLock *sync.RWMutex
|
categoryLock *sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMothballs returns a new Mothballs structure backed by the provided directory
|
// 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{
|
return &Mothballs{
|
||||||
Fs: fs,
|
FS: fsys,
|
||||||
categories: make(map[string]zipCategory),
|
categories: make(map[string]zipCategory),
|
||||||
categoryLock: new(sync.RWMutex),
|
categoryLock: new(sync.RWMutex),
|
||||||
}
|
}
|
||||||
|
@ -45,8 +44,8 @@ func (m *Mothballs) getCat(cat string) (zipCategory, bool) {
|
||||||
return ret, ok
|
return ret, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open returns a ReadSeekCloser corresponding to the filename in a puzzle's category and points
|
// 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) (ReadSeekCloser, time.Time, error) {
|
func (m *Mothballs) Open(cat string, points int, filename string) (fs.File, time.Time, error) {
|
||||||
zc, ok := m.getCat(cat)
|
zc, ok := m.getCat(cat)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, time.Time{}, fmt.Errorf("no such category: %s", cat)
|
return nil, time.Time{}, fmt.Errorf("no such category: %s", cat)
|
||||||
|
@ -112,6 +111,41 @@ func (m *Mothballs) CheckAnswer(cat string, points int, answer string) (bool, er
|
||||||
return false, nil
|
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.
|
// refresh refreshes internal state.
|
||||||
// It looks for changes to the directory listing, and caches any new mothballs.
|
// It looks for changes to the directory listing, and caches any new mothballs.
|
||||||
func (m *Mothballs) refresh() {
|
func (m *Mothballs) refresh() {
|
||||||
|
@ -119,7 +153,7 @@ func (m *Mothballs) refresh() {
|
||||||
defer m.categoryLock.Unlock()
|
defer m.categoryLock.Unlock()
|
||||||
|
|
||||||
// Any new categories?
|
// Any new categories?
|
||||||
files, err := afero.ReadDir(m.Fs, "/")
|
files, err := fs.ReadDir(m.FS, "/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Error listing mothballs:", err)
|
log.Println("Error listing mothballs:", err)
|
||||||
return
|
return
|
||||||
|
@ -136,7 +170,7 @@ func (m *Mothballs) refresh() {
|
||||||
reopen := false
|
reopen := false
|
||||||
if existingMothball, ok := m.categories[categoryName]; !ok {
|
if existingMothball, ok := m.categories[categoryName]; !ok {
|
||||||
reopen = true
|
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)
|
log.Println(err)
|
||||||
} else if si.ModTime().After(existingMothball.mtime) {
|
} else if si.ModTime().After(existingMothball.mtime) {
|
||||||
existingMothball.Close()
|
existingMothball.Close()
|
||||||
|
@ -145,33 +179,14 @@ func (m *Mothballs) refresh() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if reopen {
|
if reopen {
|
||||||
f, err := m.Fs.Open(filename)
|
if f, err := m.FS.Open(filename); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
continue
|
} else if zipCat, err := m.newZipCategory(f); err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
fi, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
f.Close()
|
|
||||||
log.Println(err)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,12 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
"github.com/spf13/afero"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type testFileContents struct {
|
type testFileContents struct {
|
||||||
|
@ -23,9 +24,27 @@ var testFiles = []testFileContents{
|
||||||
{"3/moo.txt", `moo`},
|
{"3/moo.txt", `moo`},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mothballs) createMothballWithFiles(cat string, contents []testFileContents) {
|
type TestMothballs struct {
|
||||||
f, _ := m.Create(fmt.Sprintf("%s.mb", cat))
|
*Mothballs
|
||||||
defer f.Close()
|
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)
|
w := zip.NewWriter(f)
|
||||||
defer w.Close()
|
defer w.Close()
|
||||||
|
@ -38,9 +57,16 @@ func (m *Mothballs) createMothballWithFiles(cat string, contents []testFileConte
|
||||||
of, _ := w.Create(file.Name)
|
of, _ := w.Create(file.Name)
|
||||||
of.Write([]byte(file.Body))
|
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(
|
m.createMothballWithFiles(
|
||||||
cat,
|
cat,
|
||||||
[]testFileContents{
|
[]testFileContents{
|
||||||
|
@ -49,14 +75,7 @@ func (m *Mothballs) createMothball(cat string) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTestMothballs() *Mothballs {
|
func TestMothballStuff(t *testing.T) {
|
||||||
m := NewMothballs(new(afero.MemMapFs))
|
|
||||||
m.createMothball("pategory")
|
|
||||||
m.refresh()
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMothballs(t *testing.T) {
|
|
||||||
m := NewTestMothballs()
|
m := NewTestMothballs()
|
||||||
if _, ok := m.categories["pategory"]; !ok {
|
if _, ok := m.categories["pategory"]; !ok {
|
||||||
t.Error("Didn't create a new category")
|
t.Error("Didn't create a new category")
|
||||||
|
@ -129,7 +148,7 @@ func TestMothballs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
m.createMothball("test2")
|
m.createMothball("test2")
|
||||||
m.Fs.Remove("pategory.mb")
|
delete(m.fsys, "pategory.mb")
|
||||||
m.refresh()
|
m.refresh()
|
||||||
inv = m.Inventory()
|
inv = m.Inventory()
|
||||||
if len(inv) != 1 {
|
if len(inv) != 1 {
|
||||||
|
|
|
@ -15,7 +15,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/v4/pkg/transpile"
|
"github.com/dirtbags/moth/pkg/transpile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProviderCommand specifies a command to run for the puzzle API
|
// 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".
|
// 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)
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/v4/pkg/award"
|
"github.com/dirtbags/moth/pkg/award"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Category represents a puzzle category.
|
// Category represents a puzzle category.
|
||||||
|
@ -15,13 +15,6 @@ type Category struct {
|
||||||
Puzzles []int
|
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.
|
// Configuration stores information about server configuration.
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
Devel bool
|
Devel bool
|
||||||
|
@ -30,7 +23,7 @@ type Configuration struct {
|
||||||
// StateExport is given to clients requesting the current state.
|
// StateExport is given to clients requesting the current state.
|
||||||
type StateExport struct {
|
type StateExport struct {
|
||||||
Config Configuration
|
Config Configuration
|
||||||
Enabled bool
|
Messages string
|
||||||
TeamNames map[string]string
|
TeamNames map[string]string
|
||||||
PointsLog award.List
|
PointsLog award.List
|
||||||
Puzzles map[string][]int
|
Puzzles map[string][]int
|
||||||
|
@ -38,7 +31,7 @@ type StateExport struct {
|
||||||
|
|
||||||
// PuzzleProvider defines what's required to provide puzzles.
|
// PuzzleProvider defines what's required to provide puzzles.
|
||||||
type PuzzleProvider interface {
|
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
|
Inventory() []Category
|
||||||
CheckAnswer(cat string, points int, answer string) (bool, error)
|
CheckAnswer(cat string, points int, answer string) (bool, error)
|
||||||
Mothball(cat string, w io.Writer) error
|
Mothball(cat string, w io.Writer) error
|
||||||
|
@ -47,18 +40,18 @@ type PuzzleProvider interface {
|
||||||
|
|
||||||
// ThemeProvider defines what's required to provide a theme.
|
// ThemeProvider defines what's required to provide a theme.
|
||||||
type ThemeProvider interface {
|
type ThemeProvider interface {
|
||||||
Open(path string) (ReadSeekCloser, time.Time, error)
|
Open(path string) (io.ReadSeekCloser, time.Time, error)
|
||||||
Maintainer
|
Maintainer
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateProvider defines what's required to provide MOTH state.
|
// StateProvider defines what's required to provide MOTH state.
|
||||||
type StateProvider interface {
|
type StateProvider interface {
|
||||||
Enabled() bool
|
Messages() string
|
||||||
PointsLog() award.List
|
PointsLog() award.List
|
||||||
TeamName(teamID string) (string, error)
|
TeamName(teamID string) (string, error)
|
||||||
SetTeamName(teamID, teamName string) error
|
SetTeamName(teamID, teamName string) error
|
||||||
AwardPoints(teamID string, cat string, points int) error
|
AwardPoints(teamID string, cat string, points int) error
|
||||||
LogEvent(event, teamID, cat string, points int, extra ...string)
|
LogEvent(event, participantID, teamID, cat string, points int, extra ...string)
|
||||||
Maintainer
|
Maintainer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,9 +61,6 @@ type Maintainer interface {
|
||||||
// It will only be called once, when execution begins.
|
// It will only be called once, when execution begins.
|
||||||
// It's okay to just exit if there's no maintenance to be done.
|
// It's okay to just exit if there's no maintenance to be done.
|
||||||
Maintain(updateInterval time.Duration)
|
Maintain(updateInterval time.Duration)
|
||||||
|
|
||||||
// refresh is a shortcut used internally for testing
|
|
||||||
refresh()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MothServer gathers together the providers that make up a MOTH server.
|
// MothServer gathers together the providers that make up a MOTH server.
|
||||||
|
@ -92,22 +82,24 @@ func NewMothServer(config Configuration, theme ThemeProvider, state StateProvide
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler returns a new http.RequestHandler for the provided teamID.
|
// NewHandler returns a new http.RequestHandler for the provided teamID.
|
||||||
func (s *MothServer) NewHandler(teamID string) MothRequestHandler {
|
func (s *MothServer) NewHandler(participantID, teamID string) MothRequestHandler {
|
||||||
return MothRequestHandler{
|
return MothRequestHandler{
|
||||||
MothServer: s,
|
MothServer: s,
|
||||||
teamID: teamID,
|
participantID: participantID,
|
||||||
|
teamID: teamID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MothRequestHandler provides http.RequestHandler for a MothServer.
|
// MothRequestHandler provides http.RequestHandler for a MothServer.
|
||||||
type MothRequestHandler struct {
|
type MothRequestHandler struct {
|
||||||
*MothServer
|
*MothServer
|
||||||
teamID string
|
participantID string
|
||||||
|
teamID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PuzzlesOpen opens a file associated with a puzzle.
|
// PuzzlesOpen opens a file associated with a puzzle.
|
||||||
// BUG(neale): Multiple providers with the same category name are not detected or handled well.
|
// 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)
|
export := mh.exportStateIfRegistered(true)
|
||||||
found := false
|
found := false
|
||||||
for _, p := range export.Puzzles[cat] {
|
for _, p := range export.Puzzles[cat] {
|
||||||
|
@ -129,7 +121,7 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (
|
||||||
|
|
||||||
// Log puzzle.json loads
|
// Log puzzle.json loads
|
||||||
if path == "puzzle.json" {
|
if path == "puzzle.json" {
|
||||||
mh.State.LogEvent("load", mh.teamID, cat, points)
|
mh.State.LogEvent("load", mh.participantID, mh.teamID, cat, points)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -146,33 +138,34 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !correct {
|
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")
|
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 {
|
if _, err := mh.State.TeamName(mh.teamID); err != nil {
|
||||||
return fmt.Errorf("invalid team ID")
|
return fmt.Errorf("invalid team ID")
|
||||||
}
|
}
|
||||||
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
|
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
|
||||||
return err
|
return fmt.Errorf("error awarding points: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ThemeOpen opens a file from a theme.
|
// 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)
|
return mh.Theme.Open(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register associates a team name with a team ID.
|
// Register associates a team name with a team ID.
|
||||||
func (mh *MothRequestHandler) Register(teamName string) error {
|
func (mh *MothRequestHandler) Register(teamName string) error {
|
||||||
|
// BUG(neale): Register returns an error if a team is already registered; it may make more sense to return success
|
||||||
if teamName == "" {
|
if teamName == "" {
|
||||||
return fmt.Errorf("empty team name")
|
return fmt.Errorf("empty team name")
|
||||||
}
|
}
|
||||||
mh.State.LogEvent("register", mh.teamID, "", 0)
|
mh.State.LogEvent("register", mh.participantID, mh.teamID, "", 0)
|
||||||
return mh.State.SetTeamName(mh.teamID, teamName)
|
return mh.State.SetTeamName(mh.teamID, teamName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,17 +177,14 @@ func (mh *MothRequestHandler) ExportState() *StateExport {
|
||||||
return mh.exportStateIfRegistered(false)
|
return mh.exportStateIfRegistered(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export state, replacing the team ID with "self" if the team is registered.
|
func (mh *MothRequestHandler) exportStateIfRegistered(override bool) *StateExport {
|
||||||
//
|
|
||||||
// If forceRegistered is true, go ahead and export it anyway
|
|
||||||
func (mh *MothRequestHandler) exportStateIfRegistered(forceRegistered bool) *StateExport {
|
|
||||||
export := StateExport{}
|
export := StateExport{}
|
||||||
export.Config = mh.Config
|
export.Config = mh.Config
|
||||||
|
|
||||||
teamName, err := mh.State.TeamName(mh.teamID)
|
teamName, err := mh.State.TeamName(mh.teamID)
|
||||||
registered := 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)
|
export.TeamNames = make(map[string]string)
|
||||||
|
|
||||||
// Anonymize team IDs in points log, and write out team names
|
// Anonymize team IDs in points log, and write out team names
|
||||||
|
|
|
@ -2,46 +2,55 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const TestMaintenanceInterval = time.Millisecond * 1
|
||||||
const TestTeamID = "teamID"
|
const TestTeamID = "teamID"
|
||||||
|
|
||||||
type TestServer struct {
|
type TestMothServer struct {
|
||||||
*MothServer
|
*MothServer
|
||||||
|
stateDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTestServer creates a new MothServer with NewTestMothballs and some initial state.
|
func NewTestServer() (*TestMothServer, error) {
|
||||||
//
|
|
||||||
// See function definition for details.
|
|
||||||
func NewTestServer() TestServer {
|
|
||||||
puzzles := NewTestMothballs()
|
puzzles := NewTestMothballs()
|
||||||
puzzles.refresh()
|
go puzzles.Maintain(TestMaintenanceInterval)
|
||||||
|
|
||||||
state := NewTestState()
|
stateDir, err := ioutil.TempDir("", "state")
|
||||||
afero.WriteFile(state, "teamids.txt", []byte("teamID\n"), 0644)
|
if err != nil {
|
||||||
state.refresh()
|
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()
|
theme := NewTheme("testdata/theme")
|
||||||
afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644)
|
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() {
|
func (m *TestMothServer) cleanup() {
|
||||||
ts.State.(*State).refresh()
|
if m.stateDir != "" {
|
||||||
for _, pp := range ts.PuzzleProviders {
|
os.RemoveAll(m.stateDir)
|
||||||
pp.(*Mothballs).refresh()
|
|
||||||
}
|
}
|
||||||
ts.Theme.(*Theme).refresh()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevelServer(t *testing.T) {
|
func TestDevelServer(t *testing.T) {
|
||||||
server := NewTestServer()
|
server, err := NewTestServer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer server.cleanup()
|
||||||
server.Config.Devel = true
|
server.Config.Devel = true
|
||||||
anonHandler := server.NewHandler("badTeamId")
|
anonHandler := server.NewHandler("badParticipantId", "badTeamId")
|
||||||
|
|
||||||
{
|
{
|
||||||
es := anonHandler.ExportState()
|
es := anonHandler.ExportState()
|
||||||
|
@ -56,11 +65,16 @@ func TestDevelServer(t *testing.T) {
|
||||||
|
|
||||||
func TestProdServer(t *testing.T) {
|
func TestProdServer(t *testing.T) {
|
||||||
teamName := "OurTeam"
|
teamName := "OurTeam"
|
||||||
|
participantID := "participantID"
|
||||||
teamID := TestTeamID
|
teamID := TestTeamID
|
||||||
|
|
||||||
server := NewTestServer()
|
server, err := NewTestServer()
|
||||||
handler := server.NewHandler(teamID)
|
if err != nil {
|
||||||
anonHandler := server.NewHandler("badTeamId")
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer server.cleanup()
|
||||||
|
handler := server.NewHandler(participantID, teamID)
|
||||||
|
anonHandler := server.NewHandler("badParticipantId", "badTeamId")
|
||||||
|
|
||||||
{
|
{
|
||||||
es := handler.ExportState()
|
es := handler.ExportState()
|
||||||
|
@ -90,7 +104,8 @@ func TestProdServer(t *testing.T) {
|
||||||
t.Error("index.html wrong contents", contents)
|
t.Error("index.html wrong contents", contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
server.refresh()
|
// Wait for refresh to pick everything up
|
||||||
|
time.Sleep(TestMaintenanceInterval)
|
||||||
|
|
||||||
{
|
{
|
||||||
es := handler.ExportState()
|
es := handler.ExportState()
|
||||||
|
@ -100,6 +115,9 @@ func TestProdServer(t *testing.T) {
|
||||||
if len(es.Puzzles) != 1 {
|
if len(es.Puzzles) != 1 {
|
||||||
t.Error("Puzzle categories wrong length", len(es.Puzzles))
|
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 {
|
if len(es.PointsLog) != 0 {
|
||||||
t.Error("Points log not empty")
|
t.Error("Points log not empty")
|
||||||
}
|
}
|
||||||
|
@ -140,7 +158,7 @@ func TestProdServer(t *testing.T) {
|
||||||
t.Error("Right answer marked wrong", err)
|
t.Error("Right answer marked wrong", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
server.refresh()
|
time.Sleep(TestMaintenanceInterval)
|
||||||
|
|
||||||
{
|
{
|
||||||
es := handler.ExportState()
|
es := handler.ExportState()
|
||||||
|
@ -169,7 +187,7 @@ func TestProdServer(t *testing.T) {
|
||||||
t.Error("Right answer marked wrong:", err)
|
t.Error("Right answer marked wrong:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
server.refresh()
|
time.Sleep(TestMaintenanceInterval)
|
||||||
|
|
||||||
{
|
{
|
||||||
es := anonHandler.ExportState()
|
es := anonHandler.ExportState()
|
||||||
|
|
|
@ -14,8 +14,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/v4/pkg/award"
|
"github.com/dirtbags/moth/pkg/award"
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DistinguishableChars are visually unambiguous glyphs.
|
// 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.
|
// We use the filesystem for synchronization between threads.
|
||||||
// The only thing State methods need to know is the path to the state directory.
|
// The only thing State methods need to know is the path to the state directory.
|
||||||
type State struct {
|
type State struct {
|
||||||
afero.Fs
|
basedir string
|
||||||
|
|
||||||
// Enabled tracks whether the current State system is processing updates
|
// Enabled tracks whether the current State system is processing updates
|
||||||
enabled bool
|
Enabled bool
|
||||||
|
|
||||||
enabledWhy string
|
|
||||||
refreshNow chan bool
|
refreshNow chan bool
|
||||||
eventStream chan []string
|
eventStream chan []string
|
||||||
eventWriter *csv.Writer
|
eventWriter *csv.Writer
|
||||||
eventWriterFile afero.File
|
eventWriterFile *os.File
|
||||||
|
|
||||||
// Caches, so we're not hammering NFS with metadata operations
|
// Caches, so we're not hammering NFS with metadata operations
|
||||||
teamNamesLastChange time.Time
|
teamNames map[string]string
|
||||||
teamNames map[string]string
|
pointsLog award.List
|
||||||
pointsLog award.List
|
messages string
|
||||||
lock sync.RWMutex
|
lock sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewState returns a new State struct backed by the given Fs
|
// NewState returns a new State struct backed by the given Fs
|
||||||
func NewState(fs afero.Fs) *State {
|
func NewState(basedir string) *State {
|
||||||
s := &State{
|
s := &State{
|
||||||
Fs: fs,
|
basedir: basedir,
|
||||||
enabled: true,
|
Enabled: true,
|
||||||
refreshNow: make(chan bool, 5),
|
refreshNow: make(chan bool, 5),
|
||||||
eventStream: make(chan []string, 80),
|
eventStream: make(chan []string, 80),
|
||||||
|
|
||||||
|
@ -68,13 +66,19 @@ func NewState(fs afero.Fs) *State {
|
||||||
return s
|
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".
|
// updateEnabled checks a few things to see if this state directory is "enabled".
|
||||||
func (s *State) updateEnabled() {
|
func (s *State) updateEnabled() {
|
||||||
nextEnabled := true
|
nextEnabled := true
|
||||||
why := "state/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()
|
defer untilFile.Close()
|
||||||
|
why = "`state/hours.txt` present"
|
||||||
|
|
||||||
scanner := bufio.NewScanner(untilFile)
|
scanner := bufio.NewScanner(untilFile)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
|
@ -94,36 +98,35 @@ func (s *State) updateEnabled() {
|
||||||
case '#':
|
case '#':
|
||||||
continue
|
continue
|
||||||
default:
|
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)
|
line = strings.TrimSpace(line)
|
||||||
until := time.Time{}
|
until, err := time.Parse(time.RFC3339, line)
|
||||||
if len(line) == 0 {
|
if err != nil {
|
||||||
// Let it stay as zero time, so it's always before now
|
until, err = time.Parse(RFC3339Space, line)
|
||||||
} else if until, err = time.Parse(time.RFC3339, line); err == nil {
|
}
|
||||||
// Great, it was RFC 3339
|
if err != nil {
|
||||||
} else if until, err = time.Parse(RFC3339Space, line); err == nil {
|
log.Println("Suspended: Unparseable until date:", line)
|
||||||
// Great, it was RFC 3339 with a space instead of a 'T'
|
|
||||||
} else {
|
|
||||||
log.Println("state/hours.txt has bad timestamp:", line)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if until.Before(time.Now()) {
|
if until.Before(time.Now()) {
|
||||||
nextEnabled = thisEnabled
|
nextEnabled = thisEnabled
|
||||||
why = fmt.Sprint("state/hours.txt most recent timestamp:", line)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextEnabled != s.enabled) || (why != s.enabledWhy) {
|
if _, err := os.Stat(s.path("enabled")); os.IsNotExist(err) {
|
||||||
s.enabled = nextEnabled
|
nextEnabled = false
|
||||||
s.enabledWhy = why
|
why = "`state/enabled` missing"
|
||||||
log.Printf("Setting enabled=%v: %s", s.enabled, s.enabledWhy)
|
}
|
||||||
if s.enabled {
|
|
||||||
s.LogEvent("enabled", "", "", 0, s.enabledWhy)
|
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 {
|
} 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.
|
// SetTeamName writes out team name.
|
||||||
// This can only be done once per team.
|
// This can only be done once per team.
|
||||||
func (s *State) SetTeamName(teamID, teamName string) error {
|
func (s *State) SetTeamName(teamID, teamName string) error {
|
||||||
s.lock.RLock()
|
idsFile, err := os.Open(s.path("teamids.txt"))
|
||||||
_, ok := s.teamNames[teamID]
|
|
||||||
s.lock.RUnlock()
|
|
||||||
if ok {
|
|
||||||
return ErrAlreadyRegistered
|
|
||||||
}
|
|
||||||
|
|
||||||
idsFile, err := s.Open("teamids.txt")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("team IDs file does not exist")
|
return fmt.Errorf("team IDs file does not exist")
|
||||||
}
|
}
|
||||||
|
@ -167,7 +163,7 @@ func (s *State) SetTeamName(teamID, teamName string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
teamFilename := filepath.Join("teams", teamID)
|
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) {
|
if os.IsExist(err) {
|
||||||
return ErrAlreadyRegistered
|
return ErrAlreadyRegistered
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
|
@ -192,9 +188,11 @@ func (s *State) PointsLog() award.List {
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enabled returns true if the server is in "enabled" state
|
// Messages retrieves the current messages.
|
||||||
func (s *State) Enabled() bool {
|
func (s *State) Messages() string {
|
||||||
return s.enabled
|
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.
|
// 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)
|
tmpfn := filepath.Join("points.tmp", fn)
|
||||||
newfn := filepath.Join("points.new", 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Rename(tmpfn, newfn); err != nil {
|
if err := os.Rename(s.path(tmpfn), newfn); err != nil {
|
||||||
return err
|
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,
|
// collectPoints gathers up files in points.new/ and appends their contents to points.log,
|
||||||
// removing each points.new/ file as it goes.
|
// removing each points.new/ file as it goes.
|
||||||
func (s *State) collectPoints() {
|
func (s *State) collectPoints() {
|
||||||
files, err := afero.ReadDir(s, "points.new")
|
files, err := os.ReadDir(s.path("points.new"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
filename := filepath.Join("points.new", f.Name())
|
filename := filepath.Join("points.new", f.Name())
|
||||||
awardstr, err := afero.ReadFile(s, filename)
|
awardstr, err := os.ReadFile(s.path(filename))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print("Opening new points: ", err)
|
log.Print("Opening new points: ", err)
|
||||||
continue
|
continue
|
||||||
|
@ -276,7 +274,7 @@ func (s *State) collectPoints() {
|
||||||
} else {
|
} else {
|
||||||
log.Print("Award: ", awd.String())
|
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 {
|
if err != nil {
|
||||||
log.Print("Can't append to points log: ", err)
|
log.Print("Can't append to points log: ", err)
|
||||||
return
|
return
|
||||||
|
@ -290,7 +288,7 @@ func (s *State) collectPoints() {
|
||||||
s.lock.Unlock()
|
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)
|
log.Print("Unable to remove new points file: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -298,7 +296,7 @@ func (s *State) collectPoints() {
|
||||||
|
|
||||||
func (s *State) maybeInitialize() {
|
func (s *State) maybeInitialize() {
|
||||||
// Are we supposed to re-initialize?
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -306,28 +304,28 @@ func (s *State) maybeInitialize() {
|
||||||
log.Print("initialized file missing, re-initializing")
|
log.Print("initialized file missing, re-initializing")
|
||||||
|
|
||||||
// Remove any extant control and state files
|
// Remove any extant control and state files
|
||||||
s.Remove("enabled")
|
os.Remove(s.path("enabled"))
|
||||||
s.Remove("hours.txt")
|
os.Remove(s.path("hours.txt"))
|
||||||
s.Remove("points.log")
|
os.Remove(s.path("points.log"))
|
||||||
s.Remove("events.csv")
|
os.Remove(s.path("messages.html"))
|
||||||
s.Remove("mothd.log")
|
os.Remove(s.path("mothd.log"))
|
||||||
s.RemoveAll("points.tmp")
|
os.RemoveAll(s.path("points.tmp"))
|
||||||
s.RemoveAll("points.new")
|
os.RemoveAll(s.path("points.new"))
|
||||||
s.RemoveAll("teams")
|
os.RemoveAll(s.path("teams"))
|
||||||
|
|
||||||
// Open log file
|
// Open log file
|
||||||
if err := s.reopenEventLog(); err != nil {
|
if err := s.reopenEventLog(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
s.LogEvent("init", "", "", 0)
|
s.LogEvent("init", "", "", "", 0)
|
||||||
|
|
||||||
// Make sure various subdirectories exist
|
// Make sure various subdirectories exist
|
||||||
s.Mkdir("points.tmp", 0755)
|
os.Mkdir(s.path("points.tmp"), 0755)
|
||||||
s.Mkdir("points.new", 0755)
|
os.Mkdir(s.path("points.new"), 0755)
|
||||||
s.Mkdir("teams", 0755)
|
os.Mkdir(s.path("teams"), 0755)
|
||||||
|
|
||||||
// Preseed available team ids if file doesn't exist
|
// 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)
|
id := make([]byte, 8)
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
for i := range id {
|
for i := range id {
|
||||||
|
@ -340,42 +338,50 @@ func (s *State) maybeInitialize() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create some files
|
// 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, "initialized: remove to re-initialize the contest.")
|
||||||
fmt.Fprintln(f)
|
fmt.Fprintln(f)
|
||||||
fmt.Fprintln(f, "This instance was initialized at", now)
|
fmt.Fprintln(f, "This instance was initialized at", now)
|
||||||
f.Close()
|
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, "# hours.txt: when the contest is enabled")
|
||||||
fmt.Fprintln(f, "#")
|
fmt.Fprintln(f, "#")
|
||||||
fmt.Fprintln(f, "# Enable: + [timestamp]")
|
fmt.Fprintln(f, "# Enable: + timestamp")
|
||||||
fmt.Fprintln(f, "# Disable: - [timestamp]")
|
fmt.Fprintln(f, "# Disable: - timestamp")
|
||||||
fmt.Fprintln(f, "#")
|
fmt.Fprintln(f, "#")
|
||||||
fmt.Fprintln(f, "# This file, and all files in this directory, are re-read periodically.")
|
fmt.Fprintln(f, "# You can have multiple start/stop times.")
|
||||||
fmt.Fprintln(f, "# Default is enabled.")
|
fmt.Fprintln(f, "# Whatever time is the most recent, wins.")
|
||||||
fmt.Fprintln(f, "# Rules with only '-' or '+' are also allowed.")
|
fmt.Fprintln(f, "# Times in the future are ignored.")
|
||||||
fmt.Fprintln(f, "# Rules apply from the top down.")
|
|
||||||
fmt.Fprintln(f, "# If you put something in out of order, it's going to be bonkers.")
|
|
||||||
fmt.Fprintln(f)
|
fmt.Fprintln(f)
|
||||||
fmt.Fprintln(f, "- 1970-01-01T00:00:00Z")
|
|
||||||
fmt.Fprintln(f, "+", now)
|
fmt.Fprintln(f, "+", now)
|
||||||
fmt.Fprintln(f, "- 2519-10-31T00:00:00Z")
|
fmt.Fprintln(f, "- 2519-10-31T00:00:00Z")
|
||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
f.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogEvent writes to the event log
|
// 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(
|
s.eventStream <- append(
|
||||||
[]string{
|
[]string{
|
||||||
strconv.FormatInt(time.Now().Unix(), 10),
|
strconv.FormatInt(time.Now().Unix(), 10),
|
||||||
event,
|
event,
|
||||||
|
participantID,
|
||||||
teamID,
|
teamID,
|
||||||
cat,
|
cat,
|
||||||
strconv.Itoa(points),
|
strconv.Itoa(points),
|
||||||
|
@ -394,7 +400,7 @@ func (s *State) reopenEventLog() error {
|
||||||
log.Print(err)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -407,7 +413,7 @@ func (s *State) updateCaches() {
|
||||||
s.lock.Lock()
|
s.lock.Lock()
|
||||||
defer s.lock.Unlock()
|
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)
|
log.Println(err)
|
||||||
} else {
|
} else {
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
@ -426,42 +432,37 @@ func (s *State) updateCaches() {
|
||||||
s.pointsLog = pointsLog
|
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
|
// The compiler recognizes this as an optimization case
|
||||||
if fi, err := s.Fs.Stat("teams"); err != nil {
|
for k := range s.teamNames {
|
||||||
log.Printf("Getting modification time of teams directory: %v", err)
|
delete(s.teamNames, k)
|
||||||
} else if ismmfs || s.teamNamesLastChange.Before(fi.ModTime()) {
|
}
|
||||||
s.teamNamesLastChange = fi.ModTime()
|
|
||||||
|
|
||||||
// The compiler recognizes this as an optimization case
|
if dirents, err := os.ReadDir(s.path("teams")); err != nil {
|
||||||
for k := range s.teamNames {
|
log.Printf("Reading team ids: %v", err)
|
||||||
delete(s.teamNames, k)
|
} else {
|
||||||
}
|
for _, dirent := range dirents {
|
||||||
|
teamID := dirent.Name()
|
||||||
teamsFs := afero.NewBasePathFs(s.Fs, "teams")
|
if teamNameBytes, err := os.ReadFile(s.path("teams", teamID)); err != nil {
|
||||||
if dirents, err := afero.ReadDir(teamsFs, "."); err != nil {
|
log.Printf("Reading team %s: %v", teamID, err)
|
||||||
log.Printf("Reading team ids: %v", err)
|
} else {
|
||||||
} else {
|
teamName := strings.TrimSpace(string(teamNameBytes))
|
||||||
for _, dirent := range dirents {
|
s.teamNames[teamID] = teamName
|
||||||
teamID := dirent.Name()
|
|
||||||
if teamNameBytes, err := afero.ReadFile(teamsFs, teamID); err != nil {
|
|
||||||
log.Printf("Reading team %s: %v", teamID, err)
|
|
||||||
} else {
|
|
||||||
teamName := strings.TrimSpace(string(teamNameBytes))
|
|
||||||
s.teamNames[teamID] = teamName
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if bMessages, err := os.ReadFile(s.path("messages.html")); err == nil {
|
||||||
|
s.messages = string(bMessages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) refresh() {
|
func (s *State) refresh() {
|
||||||
s.maybeInitialize()
|
s.maybeInitialize()
|
||||||
s.updateEnabled()
|
s.updateEnabled()
|
||||||
if s.enabled {
|
if s.Enabled {
|
||||||
s.collectPoints()
|
s.collectPoints()
|
||||||
}
|
}
|
||||||
s.updateCaches()
|
s.updateCaches()
|
||||||
|
@ -509,9 +510,6 @@ func (ds *DevelState) TeamName(teamID string) (string, error) {
|
||||||
if name, err := ds.StateProvider.TeamName(teamID); err == nil {
|
if name, err := ds.StateProvider.TeamName(teamID); err == nil {
|
||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
if teamID == "" {
|
|
||||||
return "", fmt.Errorf("empty team ID")
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("«devel:%s»", teamID), nil
|
return fmt.Sprintf("«devel:%s»", teamID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,16 +17,8 @@ func NewTestState() *State {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func slurp(c chan bool) {
|
|
||||||
for range c {
|
|
||||||
// Nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestState(t *testing.T) {
|
func TestState(t *testing.T) {
|
||||||
s := NewTestState()
|
s := NewTestState()
|
||||||
defer close(s.refreshNow)
|
|
||||||
go slurp(s.refreshNow)
|
|
||||||
|
|
||||||
mustExist := func(path string) {
|
mustExist := func(path string) {
|
||||||
_, err := s.Fs.Stat(path)
|
_, err := s.Fs.Stat(path)
|
||||||
|
@ -41,6 +33,7 @@ func TestState(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
mustExist("initialized")
|
mustExist("initialized")
|
||||||
|
mustExist("enabled")
|
||||||
mustExist("hours.txt")
|
mustExist("hours.txt")
|
||||||
|
|
||||||
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
|
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
|
||||||
|
@ -164,19 +157,16 @@ func TestStateOutOfOrderAward(t *testing.T) {
|
||||||
|
|
||||||
func TestStateEvents(t *testing.T) {
|
func TestStateEvents(t *testing.T) {
|
||||||
s := NewTestState()
|
s := NewTestState()
|
||||||
s.LogEvent("moo", "", "", 0)
|
s.LogEvent("moo", "", "", "", 0)
|
||||||
s.LogEvent("moo 2", "", "", 0)
|
s.LogEvent("moo 2", "", "", "", 0)
|
||||||
|
|
||||||
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "init:::0" {
|
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "init::::0" {
|
||||||
t.Error("Wrong message from event stream:", msg)
|
t.Error("Wrong message from event stream:", msg)
|
||||||
}
|
}
|
||||||
if msg := <-s.eventStream; !strings.HasPrefix(msg[5], "state/hours.txt") {
|
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo::::0" {
|
||||||
t.Error("Wrong message from event stream:", msg[5])
|
|
||||||
}
|
|
||||||
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo:::0" {
|
|
||||||
t.Error("Wrong message from event stream:", msg)
|
t.Error("Wrong message from event stream:", msg)
|
||||||
}
|
}
|
||||||
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo 2:::0" {
|
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo 2::::0" {
|
||||||
t.Error("Wrong message from event stream:", msg)
|
t.Error("Wrong message from event stream:", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -185,7 +175,7 @@ func TestStateDisabled(t *testing.T) {
|
||||||
s := NewTestState()
|
s := NewTestState()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
|
|
||||||
if !s.Enabled() {
|
if !s.Enabled {
|
||||||
t.Error("Brand new state is disabled")
|
t.Error("Brand new state is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,72 +184,62 @@ func TestStateDisabled(t *testing.T) {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
defer hoursFile.Close()
|
defer hoursFile.Close()
|
||||||
s.refresh()
|
|
||||||
if !s.Enabled() {
|
|
||||||
t.Error("Empty hours.txt not enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "- 1970-01-01T01:01:01Z")
|
fmt.Fprintln(hoursFile, "- 1970-01-01T01:01:01Z")
|
||||||
hoursFile.Sync()
|
hoursFile.Sync()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if s.Enabled() {
|
if s.Enabled {
|
||||||
t.Error("1970-01-01")
|
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()
|
hoursFile.Sync()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled() {
|
if !s.Enabled {
|
||||||
t.Error("1970-01-02")
|
t.Error("Enabling 1970-01-02")
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "-")
|
|
||||||
hoursFile.Sync()
|
|
||||||
s.refresh()
|
|
||||||
if s.Enabled() {
|
|
||||||
t.Error("bare -")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "+")
|
|
||||||
hoursFile.Sync()
|
|
||||||
s.refresh()
|
|
||||||
if !s.Enabled() {
|
|
||||||
t.Error("bare +")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "")
|
fmt.Fprintln(hoursFile, "")
|
||||||
fmt.Fprintln(hoursFile, "# Comment")
|
fmt.Fprintln(hoursFile, "# Comment")
|
||||||
hoursFile.Sync()
|
hoursFile.Sync()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled() {
|
if !s.Enabled {
|
||||||
t.Error("Comment")
|
t.Error("Comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "intentional parse error")
|
fmt.Fprintln(hoursFile, "intentional parse error")
|
||||||
hoursFile.Sync()
|
hoursFile.Sync()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled() {
|
if !s.Enabled {
|
||||||
t.Error("intentional parse error")
|
t.Error("intentional parse error")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "- 1980-01-01T01:01:01Z")
|
fmt.Fprintln(hoursFile, "- 1980-01-01T01:01:01Z")
|
||||||
hoursFile.Sync()
|
hoursFile.Sync()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if s.Enabled() {
|
if s.Enabled {
|
||||||
t.Error("1980-01-01")
|
t.Error("Disabling 1980-01-01")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Remove("hours.txt"); err != nil {
|
if err := s.Remove("hours.txt"); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled() {
|
if !s.Enabled {
|
||||||
t.Error("Removing `hours.txt` disabled event")
|
t.Error("Removing `hours.txt` disabled event")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.Remove("enabled"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
s.refresh()
|
||||||
|
if s.Enabled {
|
||||||
|
t.Error("Removing `enabled` didn't disable")
|
||||||
|
}
|
||||||
|
|
||||||
s.Remove("initialized")
|
s.Remove("initialized")
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled() {
|
if !s.Enabled {
|
||||||
t.Error("Re-initializing didn't start event")
|
t.Error("Re-initializing didn't start event")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -286,7 +266,7 @@ func TestStateMaintainer(t *testing.T) {
|
||||||
t.Error("Team ID too short:", teamID)
|
t.Error("Team ID too short:", teamID)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.LogEvent("Hello!", "", "", 0)
|
s.LogEvent("Hello!", "", "", "", 0)
|
||||||
|
|
||||||
if len(s.PointsLog()) != 0 {
|
if len(s.PointsLog()) != 0 {
|
||||||
t.Error("Points log is not empty")
|
t.Error("Points log is not empty")
|
||||||
|
@ -311,11 +291,11 @@ func TestStateMaintainer(t *testing.T) {
|
||||||
eventLog, err := afero.ReadFile(s.Fs, "events.csv")
|
eventLog, err := afero.ReadFile(s.Fs, "events.csv")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
} else if events := strings.Split(string(eventLog), "\n"); len(events) != 4 {
|
} else if events := strings.Split(string(eventLog), "\n"); len(events) != 3 {
|
||||||
t.Log("Events:", events)
|
t.Log("Events:", events)
|
||||||
t.Error("Wrong event log length:", len(events))
|
t.Error("Wrong event log length:", len(events))
|
||||||
} else if events[3] != "" {
|
} else if events[2] != "" {
|
||||||
t.Error("Event log didn't end with newline", events)
|
t.Error("Event log didn't end with newline")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
this is the index
|
|
@ -1,26 +1,27 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Theme defines a filesystem-backed ThemeProvider.
|
// Theme defines a filesystem-backed ThemeProvider.
|
||||||
type Theme struct {
|
type Theme struct {
|
||||||
afero.Fs
|
basedir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTheme returns a new Theme, backed by Fs.
|
// NewTheme returns a new Theme, backed by Fs.
|
||||||
func NewTheme(fs afero.Fs) *Theme {
|
func NewTheme(basedir string) *Theme {
|
||||||
return &Theme{
|
return &Theme{
|
||||||
Fs: fs,
|
basedir: basedir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open returns a new opened file.
|
// Open returns a new opened file.
|
||||||
func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) {
|
func (t *Theme) Open(name string) (io.ReadSeekCloser, time.Time, error) {
|
||||||
f, err := t.Fs.Open(name)
|
f, err := os.Open(path.Join(t.basedir, name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, time.Time{}, err
|
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) {
|
func (t *Theme) Maintain(i time.Duration) {
|
||||||
// No periodic tasks for a theme
|
// No periodic tasks for a theme
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Theme) refresh() {
|
|
||||||
// Nothing to do for a theme
|
|
||||||
}
|
|
||||||
|
|
|
@ -2,25 +2,12 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewTestTheme() *Theme {
|
|
||||||
return NewTheme(new(afero.MemMapFs))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheme(t *testing.T) {
|
func TestTheme(t *testing.T) {
|
||||||
s := NewTestTheme()
|
s := NewTheme("testdata/theme")
|
||||||
|
|
||||||
filename := "/index.html"
|
|
||||||
index := "this is the index"
|
|
||||||
afero.WriteFile(s.Fs, filename, []byte(index), 0644)
|
|
||||||
fileInfo, err := s.Fs.Stat(filename)
|
|
||||||
if err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if f, timestamp, err := s.Open("/index.html"); err != nil {
|
if f, timestamp, err := s.Open("/index.html"); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
|
@ -28,7 +15,9 @@ func TestTheme(t *testing.T) {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
} else if string(buf) != index {
|
} else if string(buf) != index {
|
||||||
t.Error("Read wrong value from 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")
|
t.Error("Timestamp compared wrong")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,21 +4,21 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/v4/pkg/transpile"
|
"github.com/dirtbags/moth/pkg/transpile"
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewTranspilerProvider returns a new TranspilerProvider.
|
// NewTranspilerProvider returns a new TranspilerProvider.
|
||||||
func NewTranspilerProvider(fs afero.Fs) TranspilerProvider {
|
func NewTranspilerProvider(fs fs.FS) TranspilerProvider {
|
||||||
return TranspilerProvider{fs}
|
return TranspilerProvider{fs}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TranspilerProvider provides puzzles generated from source files on disk
|
// TranspilerProvider provides puzzles generated from source files on disk
|
||||||
type TranspilerProvider struct {
|
type TranspilerProvider struct {
|
||||||
fs afero.Fs
|
fs fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inventory returns a Category list for this provider.
|
// 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) {
|
func (p TranspilerProvider) Maintain(updateInterval time.Duration) {
|
||||||
// Nothing to do here.
|
// Nothing to do here.
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p TranspilerProvider) refresh() {
|
|
||||||
// Nothing to do for a theme
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTranspiler(t *testing.T) {
|
func TestTranspiler(t *testing.T) {
|
||||||
fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata")
|
p := NewTranspilerProvider(os.DirFS("testdata"))
|
||||||
p := NewTranspilerProvider(fs)
|
|
||||||
|
|
||||||
inv := p.Inventory()
|
inv := p.Inventory()
|
||||||
if len(inv) != 1 {
|
if len(inv) != 1 {
|
||||||
|
|
|
@ -5,13 +5,13 @@ import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/v4/pkg/transpile"
|
"github.com/dirtbags/moth/pkg/namesubfs"
|
||||||
|
"github.com/dirtbags/moth/pkg/transpile"
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// T represents the state of things
|
// T represents the state of things
|
||||||
|
@ -20,8 +20,8 @@ type T struct {
|
||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
Stderr io.Writer
|
Stderr io.Writer
|
||||||
Args []string
|
Args []string
|
||||||
BaseFs afero.Fs
|
BaseFs fs.FS
|
||||||
fs afero.Fs
|
fs fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command is a function invoked by the user
|
// Command is a function invoked by the user
|
||||||
|
@ -88,7 +88,7 @@ func (t *T) ParseArgs() (Command, error) {
|
||||||
return nothing, err
|
return nothing, err
|
||||||
}
|
}
|
||||||
if *directory != "" {
|
if *directory != "" {
|
||||||
t.fs = afero.NewBasePathFs(t.BaseFs, *directory)
|
t.fs = namesubfs.Sub(t.BaseFs, *directory)
|
||||||
} else {
|
} else {
|
||||||
t.fs = t.BaseFs
|
t.fs = t.BaseFs
|
||||||
}
|
}
|
||||||
|
@ -121,7 +121,10 @@ func (t *T) PrintInventory() error {
|
||||||
|
|
||||||
// DumpPuzzle writes a puzzle's JSON to the writer.
|
// DumpPuzzle writes a puzzle's JSON to the writer.
|
||||||
func (t *T) DumpPuzzle() error {
|
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()
|
p, err := puzzle.Puzzle()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -142,7 +145,10 @@ func (t *T) DumpFile() error {
|
||||||
filename = t.Args[0]
|
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)
|
f, err := puzzle.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -166,7 +172,7 @@ func (t *T) DumpMothball() error {
|
||||||
w = t.Stdout
|
w = t.Stdout
|
||||||
} else {
|
} else {
|
||||||
filename = t.Args[0]
|
filename = t.Args[0]
|
||||||
outf, err := t.BaseFs.Create(filename)
|
outf, err := os.Create(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -177,7 +183,7 @@ func (t *T) DumpMothball() error {
|
||||||
|
|
||||||
if err := transpile.Mothball(c, w); err != nil {
|
if err := transpile.Mothball(c, w); err != nil {
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
t.BaseFs.Remove(filename)
|
os.Remove(filename)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -190,8 +196,11 @@ func (t *T) CheckAnswer() error {
|
||||||
if len(t.Args) > 0 {
|
if len(t.Args) > 0 {
|
||||||
answer = t.Args[0]
|
answer = t.Args[0]
|
||||||
}
|
}
|
||||||
c := transpile.NewFsPuzzle(t.fs)
|
c, err := transpile.NewFsPuzzle(t.fs)
|
||||||
_, err := fmt.Fprintf(t.Stdout, `{"Correct":%v}`, c.Answer(answer))
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(t.Stdout, `{"Correct":%v}`, c.Answer(answer))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -206,7 +215,7 @@ func main() {
|
||||||
Stdout: os.Stdout,
|
Stdout: os.Stdout,
|
||||||
Stderr: os.Stderr,
|
Stderr: os.Stderr,
|
||||||
Args: os.Args,
|
Args: os.Args,
|
||||||
BaseFs: afero.NewOsFs(),
|
BaseFs: os.DirFS(""),
|
||||||
}
|
}
|
||||||
cmd, err := t.ParseArgs()
|
cmd, err := t.ParseArgs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -5,13 +5,15 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/v4/pkg/transpile"
|
"github.com/dirtbags/moth/pkg/transpile"
|
||||||
"github.com/spf13/afero"
|
"github.com/psanford/memfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
var testMothYaml = []byte(`---
|
var testMothYaml = []byte(`---
|
||||||
|
@ -27,20 +29,20 @@ attachments:
|
||||||
YAML body
|
YAML body
|
||||||
`)
|
`)
|
||||||
|
|
||||||
func newTestFs() afero.Fs {
|
func newTestFs() fs.FS {
|
||||||
fs := afero.NewMemMapFs()
|
fsys := memfs.New()
|
||||||
afero.WriteFile(fs, "cat0/1/puzzle.md", testMothYaml, 0644)
|
fsys.WriteFile("cat0/1/puzzle.md", testMothYaml, 0644)
|
||||||
afero.WriteFile(fs, "cat0/1/moo.txt", []byte("Moo."), 0644)
|
fsys.WriteFile("cat0/1/moo.txt", []byte("Moo."), 0644)
|
||||||
afero.WriteFile(fs, "cat0/2/puzzle.moth", testMothYaml, 0644)
|
fsys.WriteFile("cat0/2/puzzle.moth", testMothYaml, 0644)
|
||||||
afero.WriteFile(fs, "cat0/3/puzzle.moth", testMothYaml, 0644)
|
fsys.WriteFile("cat0/3/puzzle.moth", testMothYaml, 0644)
|
||||||
afero.WriteFile(fs, "cat0/4/puzzle.md", testMothYaml, 0644)
|
fsys.WriteFile("cat0/4/puzzle.md", testMothYaml, 0644)
|
||||||
afero.WriteFile(fs, "cat0/5/puzzle.md", testMothYaml, 0644)
|
fsys.WriteFile("cat0/5/puzzle.md", testMothYaml, 0644)
|
||||||
afero.WriteFile(fs, "cat0/10/puzzle.md", testMothYaml, 0644)
|
fsys.WriteFile("cat0/10/puzzle.md", testMothYaml, 0644)
|
||||||
afero.WriteFile(fs, "unbroken/1/puzzle.md", testMothYaml, 0644)
|
fsys.WriteFile("unbroken/1/puzzle.md", testMothYaml, 0644)
|
||||||
afero.WriteFile(fs, "unbroken/1/moo.txt", []byte("Moo."), 0644)
|
fsys.WriteFile("unbroken/1/moo.txt", []byte("Moo."), 0644)
|
||||||
afero.WriteFile(fs, "unbroken/2/puzzle.md", testMothYaml, 0644)
|
fsys.WriteFile("unbroken/2/puzzle.md", testMothYaml, 0644)
|
||||||
afero.WriteFile(fs, "unbroken/2/moo.txt", []byte("Moo."), 0644)
|
fsys.WriteFile("unbroken/2/moo.txt", []byte("Moo."), 0644)
|
||||||
return fs
|
return fsys
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tp T) Run(args ...string) error {
|
func (tp T) Run(args ...string) error {
|
||||||
|
@ -124,8 +126,7 @@ func TestMothballs(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// afero.WriteFile(tp.BaseFs, "unbroken.mb", []byte("moo"), 0644)
|
fis, err := fs.ReadDir(tp.BaseFs, "/")
|
||||||
fis, err := afero.ReadDir(tp.BaseFs, "/")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
@ -140,13 +141,24 @@ func TestMothballs(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer mb.Close()
|
defer mb.Close()
|
||||||
|
|
||||||
info, err := mb.Stat()
|
var zmb *zip.Reader
|
||||||
if err != nil {
|
switch r := mb.(type) {
|
||||||
t.Error(err)
|
case io.ReaderAt:
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
return
|
return
|
||||||
|
@ -185,7 +197,7 @@ func TestFilesystem(t *testing.T) {
|
||||||
Stdin: stdin,
|
Stdin: stdin,
|
||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
Stderr: stderr,
|
Stderr: stderr,
|
||||||
BaseFs: afero.NewOsFs(),
|
BaseFs: os.DirFS(""),
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
|
@ -220,7 +232,7 @@ func TestCwd(t *testing.T) {
|
||||||
Stdin: stdin,
|
Stdin: stdin,
|
||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
Stderr: stderr,
|
Stderr: stderr,
|
||||||
BaseFs: afero.NewOsFs(),
|
BaseFs: os.DirFS(""),
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
|
|
|
@ -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"
|
|
79
docs/FAQ.md
79
docs/FAQ.md
|
@ -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.
|
|
|
@ -45,8 +45,8 @@ Scores
|
||||||
Pausing/resuming scoring
|
Pausing/resuming scoring
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
echo '-###' >> /srv/moth/state/hours.txt # Suspend scoring
|
rm /srv/moth/state/enabled # Pause scoring
|
||||||
sed -i '/###/d' /srv/moth/state/hours.txt # Resume scoring
|
touch /srv/moth/state/enabled # Resume scoring
|
||||||
|
|
||||||
When scoring is paused,
|
When scoring is paused,
|
||||||
participants can still submit answers,
|
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,
|
As soon as you unpause,
|
||||||
all correctly-submitted answers will be scored.
|
all correctly-submitted answers will be scored.
|
||||||
|
|
||||||
|
|
||||||
Adjusting scores
|
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
|
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:
|
We don't warn participants before we do this:
|
||||||
any points scored while scoring is suspended are queued up and processed as soon as scoreing is resumed.
|
any points scored while scoring is suspended are queued up and processed as soon as scoreing is resumed.
|
||||||
|
|
|
@ -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) |
|
|
|
@ -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}
|
|
@ -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 |
|
@ -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:
|
Use it along with a text editor and shell to create new puzzles and categories.
|
||||||
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)
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
Developing Content: Source vs Mothballs
|
Set up some example puzzles
|
||||||
=============================
|
|
||||||
|
|
||||||
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
|
|
||||||
---------
|
---------
|
||||||
|
|
||||||
We like Visual Studio Code,
|
If you don't have puzzles of your own to start with,
|
||||||
but any text editor will work:
|
you can copy the example puzzles that come with the source:
|
||||||
you only need to edit the `puzzle.md` (Markdown) files.
|
|
||||||
|
|
||||||
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
|
Run the server in development mode
|
||||||
---
|
---------------
|
||||||
authors:
|
|
||||||
- YOUR NAME HERE
|
|
||||||
answers:
|
|
||||||
- Elephant
|
|
||||||
- elephant
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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,
|
### Podman
|
||||||
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.
|
|
||||||
|
|
||||||
The two answers we're accepting can be improved,
|
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
The [Writing Puzzles](writing-puzzles.md) document
|
||||||
has some tips on how we approach puzzle writing.
|
has some tips on how we approach puzzle writing.
|
||||||
There may be something in here that will help you out!
|
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,
|
Once your category is set up the way you like it,
|
||||||
download a mothball for 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.
|
with the production server.
|
||||||
|
|
|
@ -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`.
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Internal Structures
|
||||||
|
|
|
@ -49,10 +49,10 @@ It ought to import into any spreadsheet program painlessly.
|
||||||
|
|
||||||
Each line has six fields minimum:
|
Each line has six fields minimum:
|
||||||
|
|
||||||
| `timestamp` | `event` | `teamID` | `category` | `points` | `extra`... |
|
| `timestamp` | `event` | `participantID` | `teamID` | `category` | `points` | `extra`... |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| int | string | string | string | int | string... |
|
| int | string | 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 |
|
| 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.
|
Fields after `points` contain extra fields associated with the event.
|
||||||
|
|
||||||
|
|
|
@ -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.
|
|
|
@ -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.
|
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.
|
Put an RFC3337 date/time stamp in here to have the server pause itself at a given time.
|
||||||
If all the hours are in the future, the event defaults to running.
|
Remember that time zones exist!
|
||||||
"Stop" here just pertains to scoreboard updates and puzzle unlocking.
|
I recommend always using Zulu time.
|
||||||
People can still submit answers and their awards are queued up for the next start.
|
|
||||||
|
This file does not normally exist.
|
||||||
|
|
||||||
|
|
||||||
`teamids.txt`
|
`teamids.txt`
|
||||||
|
|
|
@ -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>
|
|
||||||
```
|
|
|
@ -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.
|
|
|
@ -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.
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
Boop!
|
|
|
@ -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
16
go.mod
|
@ -1,15 +1,11 @@
|
||||||
module github.com/dirtbags/moth/v4
|
module github.com/dirtbags/moth
|
||||||
|
|
||||||
go 1.21
|
go 1.13
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/spf13/afero v1.8.2
|
github.com/go-redis/redis/v8 v8.11.4
|
||||||
github.com/yuin/goldmark v1.4.13
|
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
|
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
444
go.sum
|
@ -1,445 +1,121 @@
|
||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
|
||||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
|
||||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
|
||||||
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
|
||||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
|
||||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
|
||||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
|
||||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
|
||||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
|
||||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
|
||||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
|
||||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
|
||||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
|
||||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
|
||||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
|
||||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
|
||||||
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
|
|
||||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
|
||||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
|
||||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
|
||||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
|
||||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
|
||||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
|
||||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
|
||||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
|
||||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
|
||||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
|
||||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
|
||||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
|
||||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
|
||||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
|
||||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
|
||||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
|
||||||
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
|
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
|
||||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
|
||||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg=
|
||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
|
||||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
|
||||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
|
||||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
|
||||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.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/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.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.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-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.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.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.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.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.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/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/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
|
||||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
|
||||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
|
||||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
|
||||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
|
||||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
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/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/psanford/memfs v0.0.0-20210214183328-a001468d78ef h1:NKxTG6GVGbfMXc2mIk+KphcH6hagbVXhcFkbTgYleTI=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/psanford/memfs v0.0.0-20210214183328-a001468d78ef/go.mod h1:tcaRap0jS3eifrEEllL6ZMd9dg8IlDpi2S1oARrQ+NI=
|
||||||
github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
|
github.com/spf13/afero v1.4.0 h1:jsLTaI1zwYO3vjrzHalkVcIHXTNmdQFepW4OI8H3+x8=
|
||||||
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
|
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
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.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
|
||||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
|
||||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-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-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.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-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-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-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-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
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/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
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-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-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-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-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-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-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-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
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.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.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
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-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-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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-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-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 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.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.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.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.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/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.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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
|
||||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
|
||||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
|
||||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
|
||||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
|
||||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
moo.
|
|
@ -0,0 +1 @@
|
||||||
|
moo too.
|
|
@ -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
|
|
||||||
}
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
|
@ -12,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dirtbags/moth/pkg/namesubfs"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -28,41 +30,20 @@ type Category interface {
|
||||||
// Puzzle provides a Puzzle structure for the given point value.
|
// Puzzle provides a Puzzle structure for the given point value.
|
||||||
Puzzle(points int) (Puzzle, error)
|
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 returns whether the given answer is correct.
|
||||||
Answer(points int, answer string) bool
|
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.
|
// NewFsCategory returns a Category based on which files are present.
|
||||||
// If 'mkcategory' is present and executable, an FsCommandCategory is returned.
|
// If 'mkcategory' is present and executable, an FsCommandCategory is returned.
|
||||||
// Otherwise, FsCategory is returned.
|
// Otherwise, FsCategory is returned.
|
||||||
func NewFsCategory(fs afero.Fs, cat string) Category {
|
func NewFsCategory(fsys fs.FS, cat string) Category {
|
||||||
bfs := NewRecursiveBasePathFs(fs, cat)
|
bfs := namesubfs.Sub(fsys, cat)
|
||||||
if info, err := bfs.Stat("mkcategory"); (err == nil) && (info.Mode()&0100 != 0) {
|
if info, err := fs.Stat(bfs, "mkcategory"); (err == nil) && (info.Mode()&0100 != 0) {
|
||||||
if command, err := bfs.RealPath(info.Name()); err != nil {
|
return FsCommandCategory{
|
||||||
log.Println("Unable to resolve full path to", info.Name())
|
fs: bfs,
|
||||||
} else {
|
command: bfs.FullPath(info.Name()),
|
||||||
return FsCommandCategory{
|
timeout: 2 * time.Second,
|
||||||
fs: bfs,
|
|
||||||
command: command,
|
|
||||||
timeout: 2 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return FsCategory{fs: bfs}
|
return FsCategory{fs: bfs}
|
||||||
|
@ -100,11 +81,6 @@ func (c FsCategory) Puzzle(points int) (Puzzle, error) {
|
||||||
return NewFsPuzzlePoints(c.fs, points).Puzzle()
|
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.
|
// Answer checks whether an answer is correct.
|
||||||
func (c FsCategory) Answer(points int, answer string) bool {
|
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.
|
// 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
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open returns an io.ReadCloser for the given filename.
|
// Answer checks whether an answer is correct.Open
|
||||||
func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) {
|
|
||||||
stdout, err := c.run("file", strconv.Itoa(points), filename)
|
|
||||||
return nopCloser{bytes.NewReader(stdout)}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Answer checks whether an answer is correct.
|
|
||||||
func (c FsCommandCategory) Answer(points int, answer string) bool {
|
func (c FsCommandCategory) Answer(points int, answer string) bool {
|
||||||
stdout, err := c.run("answer", strconv.Itoa(points), answer)
|
stdout, err := c.run("answer", strconv.Itoa(points), answer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -3,11 +3,10 @@ package transpile
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFsCategory(t *testing.T) {
|
func TestFsCategory(t *testing.T) {
|
||||||
|
@ -33,7 +32,9 @@ func TestFsCategory(t *testing.T) {
|
||||||
t.Error("Incorrect answer accepted as correct")
|
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.Log(c.Puzzle(1))
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
} else {
|
} else {
|
||||||
|
@ -54,8 +55,8 @@ func TestFsCategory(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOsFsCategory(t *testing.T) {
|
func TestOsFsCategory(t *testing.T) {
|
||||||
fs := NewRecursiveBasePathFs(afero.NewOsFs(), "testdata")
|
fsys := os.DirFS("testdata")
|
||||||
static := NewFsCategory(fs, "static")
|
static := NewFsCategory(fsys, "static")
|
||||||
|
|
||||||
if p, err := static.Puzzle(1); err != nil {
|
if p, err := static.Puzzle(1); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
|
@ -71,7 +72,7 @@ func TestOsFsCategory(t *testing.T) {
|
||||||
t.Error("Wrong authors", p.Authors)
|
t.Error("Wrong authors", p.Authors)
|
||||||
}
|
}
|
||||||
|
|
||||||
generated := NewFsCategory(fs, "generated")
|
generated := NewFsCategory(fsys, "generated")
|
||||||
|
|
||||||
if inv, err := generated.Inventory(); err != nil {
|
if inv, err := generated.Inventory(); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
|
|
|
@ -4,11 +4,12 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha1"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"os"
|
"os"
|
||||||
|
@ -18,7 +19,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/dirtbags/moth/pkg/namesubfs"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -37,50 +38,27 @@ type PuzzleDebug struct {
|
||||||
Summary string
|
Summary string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Puzzle contains everything about a puzzle that a client will see.
|
// PuzzleMetadata contains everything about a puzzle that a client would see.
|
||||||
type Puzzle struct {
|
type PuzzleMetadata struct {
|
||||||
// Debug contains debugging information, omitted in mothballs
|
Debug PuzzleDebug
|
||||||
Debug PuzzleDebug
|
Authors []string
|
||||||
|
Attachments []string
|
||||||
// Authors names all authors of this puzzle
|
Scripts []string
|
||||||
Authors []string
|
Body string
|
||||||
|
|
||||||
// Attachments is a list of filenames used by this puzzle
|
|
||||||
Attachments []string
|
|
||||||
|
|
||||||
// Scripts is a list of EMCAScript files needed by the client for this puzzle
|
|
||||||
Scripts []string
|
|
||||||
|
|
||||||
// Body is the HTML rendering of this puzzle
|
|
||||||
Body string
|
|
||||||
|
|
||||||
// AnswerPattern contains the pattern (regular expression?) used to match valid answers
|
|
||||||
AnswerPattern string
|
AnswerPattern string
|
||||||
|
AnswerHashes []string
|
||||||
// AnswerHashes contains hashes of all answers for this puzzle
|
Objective string
|
||||||
AnswerHashes []string
|
KSAs []string
|
||||||
|
Success struct {
|
||||||
// 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
|
|
||||||
Acceptable string
|
Acceptable string
|
||||||
|
Mastery string
|
||||||
// Mastery describes the work required to be considered mastering this puzzle's concepts
|
|
||||||
Mastery string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Answers will be empty in a mothball
|
||||||
|
Answers []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Puzzle interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (puzzle *Puzzle) computeAnswerHashes() {
|
func (puzzle *Puzzle) computeAnswerHashes() {
|
||||||
|
@ -89,9 +67,9 @@ func (puzzle *Puzzle) computeAnswerHashes() {
|
||||||
}
|
}
|
||||||
puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
|
puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
|
||||||
for i, answer := range puzzle.Answers {
|
for i, answer := range puzzle.Answers {
|
||||||
sum := sha1.Sum([]byte(answer))
|
sum := sha256.Sum256([]byte(answer))
|
||||||
hexsum := fmt.Sprintf("%x", sum)
|
hexsum := fmt.Sprintf("%x", sum)
|
||||||
puzzle.AnswerHashes[i] = hexsum[:4]
|
puzzle.AnswerHashes[i] = hexsum
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,15 +79,14 @@ type StaticPuzzle struct {
|
||||||
Attachments []StaticAttachment
|
Attachments []StaticAttachment
|
||||||
Scripts []StaticAttachment
|
Scripts []StaticAttachment
|
||||||
AnswerPattern string
|
AnswerPattern string
|
||||||
Answers []string
|
|
||||||
Debug PuzzleDebug
|
|
||||||
Extra map[string]any
|
|
||||||
Objective string
|
Objective string
|
||||||
Success struct {
|
Success struct {
|
||||||
Acceptable string
|
Acceptable string
|
||||||
Mastery string
|
Mastery string
|
||||||
}
|
}
|
||||||
KSAs []string
|
KSAs []string
|
||||||
|
Debug PuzzleDebug
|
||||||
|
Answers []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StaticAttachment carries information about an attached file.
|
// StaticAttachment carries information about an attached file.
|
||||||
|
@ -138,35 +115,27 @@ func (sa *StaticAttachment) UnmarshalYAML(unmarshal func(interface{}) error) err
|
||||||
return nil
|
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.
|
// PuzzleProvider establishes the functionality required to provide one puzzle.
|
||||||
type PuzzleProvider interface {
|
type PuzzleProvider interface {
|
||||||
// Puzzle returns a Puzzle struct for the current puzzle.
|
// Puzzle returns a Puzzle struct for the current puzzle.
|
||||||
Puzzle() (Puzzle, error)
|
Puzzle() (Puzzle, error)
|
||||||
|
|
||||||
// Open returns a newly-opened file.
|
// 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 returns whether the provided answer is correct.
|
||||||
Answer(answer string) bool
|
Answer(answer string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFsPuzzle returns a new FsPuzzle.
|
// NewFsPuzzle returns a new FsPuzzle.
|
||||||
func NewFsPuzzle(fs afero.Fs) PuzzleProvider {
|
func NewFsPuzzle(fsys fs.FS) (PuzzleProvider, error) {
|
||||||
var command string
|
var command string
|
||||||
|
|
||||||
bfs := NewRecursiveBasePathFs(fs, "")
|
if bfs, err := namesubfs.Sub(fsys, ""); err != nil {
|
||||||
if info, err := bfs.Stat("mkpuzzle"); !os.IsNotExist(err) {
|
return nil, err
|
||||||
|
} else if info, err := fs.Stat(bfs, "mkpuzzle"); !os.IsNotExist(err) {
|
||||||
if (info.Mode() & 0100) != 0 {
|
if (info.Mode() & 0100) != 0 {
|
||||||
if command, err = bfs.RealPath(info.Name()); err != nil {
|
command = bfs.FullName(info.Name())
|
||||||
log.Println("WARN: Unable to resolve full path to", info.Name())
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
log.Println("WARN: mkpuzzle exists, but isn't executable.")
|
log.Println("WARN: mkpuzzle exists, but isn't executable.")
|
||||||
}
|
}
|
||||||
|
@ -174,26 +143,27 @@ func NewFsPuzzle(fs afero.Fs) PuzzleProvider {
|
||||||
|
|
||||||
if command != "" {
|
if command != "" {
|
||||||
return FsCommandPuzzle{
|
return FsCommandPuzzle{
|
||||||
fs: fs,
|
fs: fsys,
|
||||||
command: command,
|
command: command,
|
||||||
timeout: 2 * time.Second,
|
timeout: 2 * time.Second,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return FsPuzzle{
|
return FsPuzzle{
|
||||||
fs: fs,
|
fs: fsys,
|
||||||
}
|
}, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFsPuzzlePoints returns a new FsPuzzle for points.
|
// NewFsPuzzlePoints returns a new FsPuzzle for points.
|
||||||
func NewFsPuzzlePoints(fs afero.Fs, points int) PuzzleProvider {
|
func NewFsPuzzlePoints(fs fs.FS, points int) PuzzleProvider {
|
||||||
return NewFsPuzzle(NewRecursiveBasePathFs(fs, strconv.Itoa(points)))
|
subfs, _ := namesubfs.Sub(fs, strconv.Itoa(points))
|
||||||
|
return NewFsPuzzle(subfs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FsPuzzle is a single puzzle's directory.
|
// FsPuzzle is a single puzzle's directory.
|
||||||
type FsPuzzle struct {
|
type FsPuzzle struct {
|
||||||
fs afero.Fs
|
fs fs.FS
|
||||||
mkpuzzle bool
|
mkpuzzle bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -210,7 +180,6 @@ func (fp FsPuzzle) Puzzle() (Puzzle, error) {
|
||||||
puzzle.Debug = static.Debug
|
puzzle.Debug = static.Debug
|
||||||
puzzle.Answers = static.Answers
|
puzzle.Answers = static.Answers
|
||||||
puzzle.Authors = static.Authors
|
puzzle.Authors = static.Authors
|
||||||
puzzle.Extra = static.Extra
|
|
||||||
puzzle.Objective = static.Objective
|
puzzle.Objective = static.Objective
|
||||||
puzzle.KSAs = static.KSAs
|
puzzle.KSAs = static.KSAs
|
||||||
puzzle.Success = static.Success
|
puzzle.Success = static.Success
|
||||||
|
@ -248,7 +217,7 @@ func (fp FsPuzzle) Open(name string) (ReadSeekCloser, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if fsPath == "" {
|
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)
|
return fp.fs.Open(fsPath)
|
||||||
|
@ -334,7 +303,7 @@ func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) {
|
||||||
p := StaticPuzzle{}
|
p := StaticPuzzle{}
|
||||||
m, err := mail.ReadMessage(r)
|
m, err := mail.ReadMessage(r)
|
||||||
if err != nil {
|
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 {
|
for key, val := range m.Header {
|
||||||
|
@ -365,7 +334,7 @@ func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) {
|
||||||
case "success.mastery":
|
case "success.mastery":
|
||||||
p.Success.Mastery = val[0]
|
p.Success.Mastery = val[0]
|
||||||
default:
|
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.
|
// FsCommandPuzzle provides an FsPuzzle backed by running a command.
|
||||||
type FsCommandPuzzle struct {
|
type FsCommandPuzzle struct {
|
||||||
fs afero.Fs
|
fs fs.FS
|
||||||
command string
|
command string
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,12 +23,6 @@ func TestPuzzle(t *testing.T) {
|
||||||
if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") {
|
if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") {
|
||||||
t.Error("Answers are wrong", p.Answers)
|
t.Error("Answers are wrong", p.Answers)
|
||||||
}
|
}
|
||||||
if len(p.Answers) != len(p.AnswerHashes) {
|
|
||||||
t.Error("Answer hashes length does not match answers length")
|
|
||||||
}
|
|
||||||
if len(p.AnswerHashes[0]) != 4 {
|
|
||||||
t.Error("Answer hash is wrong length")
|
|
||||||
}
|
|
||||||
if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") {
|
if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") {
|
||||||
t.Error("Authors are wrong", p.Authors)
|
t.Error("Authors are wrong", p.Authors)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
269
theme/basic.css
269
theme/basic.css
|
@ -1,182 +1,132 @@
|
||||||
/* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
|
/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */
|
||||||
|
|
||||||
: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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
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;
|
max-width: 40em;
|
||||||
margin: 1em auto;
|
background: #282a33;
|
||||||
padding: 1px 3px;
|
color: #f6efdc;
|
||||||
border-radius: 5px;
|
|
||||||
background: var(--bg-main);
|
|
||||||
}
|
}
|
||||||
h1, h2, h3, h4, h5, h6 {
|
body.wide {
|
||||||
color: var(--heading);
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
a:any-link {
|
||||||
|
color: #8b969a;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
background: var(--bg-heading1);
|
background: #5e576b;
|
||||||
padding: 3px;
|
color: #9e98a8;
|
||||||
|
}
|
||||||
|
.Fail, .Error, #messages {
|
||||||
|
background: #3a3119;
|
||||||
|
color: #ffcc98;
|
||||||
|
}
|
||||||
|
.Fail:before {
|
||||||
|
content: "Fail: ";
|
||||||
|
}
|
||||||
|
.Error:before {
|
||||||
|
content: "Error: ";
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
margin: 1em 0em;
|
margin: 1em 0em;
|
||||||
}
|
}
|
||||||
a:any-link {
|
|
||||||
color: var(--fg-link);
|
|
||||||
}
|
|
||||||
form, pre {
|
form, pre {
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
overflow-x: auto;
|
|
||||||
}
|
}
|
||||||
input, select {
|
input, select {
|
||||||
padding: 0.6em;
|
padding: 0.6em;
|
||||||
margin: 0.2em;
|
margin: 0.2em;
|
||||||
max-width: 30em;
|
max-width: 30em;
|
||||||
}
|
}
|
||||||
input {
|
nav {
|
||||||
background-color: var(--bg-input);
|
border: solid black 2px;
|
||||||
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 ul, .category ul {
|
nav ul, .category ul {
|
||||||
margin: 0;
|
padding: 1em;
|
||||||
padding: 0.2em 1em;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 16px;
|
|
||||||
}
|
}
|
||||||
nav li, .category li {
|
nav li, .category li {
|
||||||
display: inline;
|
display: inline;
|
||||||
|
margin: 1em;
|
||||||
}
|
}
|
||||||
.category li.entitled {
|
iframe#body {
|
||||||
flex-basis: 100%;
|
border: inherit;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.mothball {
|
img {
|
||||||
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 {
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
input:invalid {
|
input:invalid {
|
||||||
background-color: var(--bg-input-invalid);
|
border-color: red;
|
||||||
color: var(--fg-input-invalid);
|
|
||||||
}
|
}
|
||||||
.answer_ok {
|
#messages {
|
||||||
cursor: help;
|
min-height: 3em;
|
||||||
|
border: solid black 2px;
|
||||||
|
}
|
||||||
|
#rankings {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Development mode information */
|
#rankings span {
|
||||||
.debug {
|
font-size: 75%;
|
||||||
overflow: auto;
|
display: inline-block;
|
||||||
padding: 1em;
|
overflow: hidden;
|
||||||
border-radius: 10px;
|
height: 1.7em;
|
||||||
margin: 2em auto;
|
|
||||||
background: var(--bg-debug);
|
|
||||||
color: var(--fg-debug);
|
|
||||||
}
|
}
|
||||||
.debug dt {
|
#rankings span.teamname {
|
||||||
font-weight: bold;
|
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 {
|
li[draggable]::before {
|
||||||
content: "↕";
|
content: "↕";
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
@ -194,31 +144,6 @@ li[draggable] {
|
||||||
border: 1px white dashed;
|
border: 1px white dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#cacheButton.disabled {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
|
||||||
/** Toasts are little pop-up informational messages. */
|
|
||||||
.toasts {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 100;
|
|
||||||
bottom: 10px;
|
|
||||||
left: 10px;
|
|
||||||
text-align: center;
|
|
||||||
width: calc(100% - 20px);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.toast {
|
|
||||||
border-radius: 0.5em;
|
|
||||||
padding: 0.2em 2em;
|
|
||||||
animation: fadeIn ease 1s;
|
|
||||||
margin: 2px auto;
|
|
||||||
background: var(--bg-toast);
|
|
||||||
color: var(--fg-toast);
|
|
||||||
box-shadow: 0px 0px 8px 0px var(--box-toast);
|
|
||||||
}
|
|
||||||
@keyframes fadeIn {
|
|
||||||
0% { opacity: 0; }
|
|
||||||
100% { opacity: 1; }
|
|
||||||
}
|
}
|
||||||
|
|
BIN
theme/bg.png
BIN
theme/bg.png
Binary file not shown.
Before Width: | Height: | Size: 180 KiB |
|
@ -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,
|
|
||||||
}
|
|
|
@ -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.
|
@ -1,44 +1,38 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>MOTH</title>
|
<title>MOTH</title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<link rel="icon" href="luna-moth.svg">
|
|
||||||
<link rel="stylesheet" href="basic.css">
|
<link rel="stylesheet" href="basic.css">
|
||||||
<script src="index.mjs" type="module" async></script>
|
<script src="moth.js"></script>
|
||||||
<script src="background.mjs" type="module" async></script>
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 class="title" title="Monarch Of The Hill">MOTH</h1>
|
<h1 id="title">MOTH</h1>
|
||||||
<main>
|
<section>
|
||||||
<div class="messages notification">
|
<div id="messages">
|
||||||
|
<div id="notices"></div>
|
||||||
</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 ID: <input name="id"> <br>
|
||||||
Team name: <input name="name"> <br>
|
Team name: <input name="name"> <br>
|
||||||
<input type="submit" value="Sign In">
|
<input type="submit" value="Sign In">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="puzzles"></div>
|
<div id="puzzles"></div>
|
||||||
</main>
|
|
||||||
|
|
||||||
<div class="notification" data-track-solved="no">
|
|
||||||
<p>
|
|
||||||
Solved puzzle tracking: <b>disabled</b>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Your team's Incident Coordinator can help coordinate team activity.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toasts"></div>
|
|
||||||
|
|
||||||
|
</section>
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>
|
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||||
<li><button class="logout">Sign Out</button></li>
|
<li><a href="logout.html">Sign Out</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</body>
|
</body>
|
||||||
|
|
203
theme/index.mjs
203
theme/index.mjs
|
@ -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)
|
|
|
@ -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>
|
|
@ -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"
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
|
@ -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()
|
||||||
|
}
|
||||||
|
|
684
theme/moth.mjs
684
theme/moth.mjs
|
@ -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,
|
|
||||||
}
|
|
117
theme/puzzle.css
117
theme/puzzle.css
|
@ -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);
|
|
||||||
}
|
|
|
@ -1,54 +1,37 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Puzzle</title>
|
<title>Puzzle</title>
|
||||||
|
<link rel="stylesheet" href="basic.css">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<link rel="icon" href="luna-moth.svg">
|
<script src="puzzle.js"></script>
|
||||||
<link rel="stylesheet" href="basic.css">
|
<script>
|
||||||
<link rel="stylesheet" href="puzzle.css">
|
|
||||||
<script src="background.mjs" type="module" async></script>
|
</script>
|
||||||
<script src="puzzle.mjs" type="module" async></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 id="title">[loading]</h1>
|
<h1>Puzzle</h1>
|
||||||
<main>
|
<section>
|
||||||
<section id="puzzle">
|
<div id="puzzle"><span class="spinner"></span></div>
|
||||||
<p class="notification">
|
<ul id="files"></ul>
|
||||||
Starting script...
|
<p>Puzzle by <span id="authors"></span></p>
|
||||||
</p>
|
</section>
|
||||||
</section>
|
<div id="messages"></div>
|
||||||
<section class="meta"></section>
|
<form>
|
||||||
<ul id="files"></ul>
|
<input type="hidden" name="cat">
|
||||||
<p>Puzzle by <span id="authors">[loading]</span></p>
|
<input type="hidden" name="points">
|
||||||
</section>
|
<input type="hidden" name="xAnswer">
|
||||||
<form class="submit-answer">
|
Team ID: <input type="text" name="id"> <br>
|
||||||
<label for="answer">Answer:</label>
|
Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br>
|
||||||
<input type="text" name="answer" id="answer"> <span class="answer_ok"></span>
|
<input type="submit" value="Submit">
|
||||||
<br>
|
</form>
|
||||||
<input type="submit" value="Submit">
|
<div id="devel"></div>
|
||||||
</form>
|
<nav>
|
||||||
</main>
|
<ul>
|
||||||
<div class="debug" class="notification"></div>
|
<li><a href="index.html">Puzzles</a></li>
|
||||||
<div class="toasts"></div>
|
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||||
<template id="workspace">
|
</ul>
|
||||||
<div class="editor">
|
</nav>
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
271
theme/puzzle.mjs
271
theme/puzzle.mjs
|
@ -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
|
@ -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>
|
|
|
@ -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)
|
|
|
@ -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>
|
|
|
@ -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;}
|
|
|
@ -3,14 +3,22 @@
|
||||||
<head>
|
<head>
|
||||||
<title>Scoreboard</title>
|
<title>Scoreboard</title>
|
||||||
<link rel="stylesheet" href="basic.css">
|
<link rel="stylesheet" href="basic.css">
|
||||||
<link rel="stylesheet" href="scoreboard.css">
|
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<link rel="icon" href="luna-moth.svg">
|
<script src="moment.min.js" async></script>
|
||||||
<script type="module" src="scoreboard.mjs"></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>
|
</head>
|
||||||
<body>
|
<body class="wide">
|
||||||
<div class="no-scores hidden"></div>
|
<h4 id="location"></h4>
|
||||||
<div class="rankings classic"></div>
|
<section class="rotate">
|
||||||
<div class="location"></div>
|
<div id="chart"></div>
|
||||||
|
<div id="rankings"></div>
|
||||||
|
</section>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">Puzzles</a></li>
|
||||||
|
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -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)
|
|
|
@ -1,4 +0,0 @@
|
||||||
html {
|
|
||||||
background: #333;
|
|
||||||
color: white;
|
|
||||||
}
|
|
|
@ -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>
|
|
|
@ -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 |
|
@ -1,29 +1,45 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Redeem Token</title>
|
<title>Redeem Token</title>
|
||||||
<link rel="stylesheet" href="basic.css">
|
<link rel="stylesheet" href="basic.css">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<script src="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Redeem Token</h1>
|
<h1>Redeem Token</h1>
|
||||||
<main>
|
<div id="messages"></div>
|
||||||
<p>
|
<form id="tokenForm">
|
||||||
Have you found a token?
|
<input type="hidden" name="cat">
|
||||||
</p>
|
<input type="hidden" name="points">
|
||||||
<p></p>
|
<input type="hidden" name="answer">
|
||||||
Tokens look like
|
Team ID: <input type="text" name="id"> <br>
|
||||||
<code>category:5:xylep-radar-nanox</code>
|
Token: <input type="text" name="token"> <br>
|
||||||
<p>
|
|
||||||
Tokens may be redeemed here for points in their category.
|
|
||||||
Tokens can appear anywhere: online, on slips of paper, projected onto screens…
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
<form class="token"</form>
|
|
||||||
<label for="token">Token:</label> <input type="text" name="token" id="token"> <br>
|
|
||||||
<input type="submit" value="Submit">
|
<input type="submit" value="Submit">
|
||||||
</form>
|
</form>
|
||||||
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -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)
|
|
|
@ -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))
|
|
|
@ -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()
|
|
||||||
}
|
|
Loading…
Reference in New Issue