Compare commits

..

114 Commits

Author SHA1 Message Date
Neale Pickett 959a802c84 gitignore fixes 2024-04-11 16:44:35 -06:00
Neale Pickett ceb0cb0edb Toying around with API ideas 2024-04-11 16:44:01 -06:00
Neale Pickett 7c5b5b5ccf Make all paths absolute
Fixes #200
2024-04-09 11:54:39 -06:00
Neale Pickett dfecd100b8 Syntax Highlighting is not (currently) optional 2024-04-09 11:52:47 -06:00
Neale Pickett 830eb2851a Color syntax highlighting
Fixes #202
2024-04-09 11:12:32 -06:00
Neale Pickett 0696e7c61c handle prefers-reduced-motion 2024-04-08 17:22:35 -06:00
Neale Pickett cc74318e15 Workspaces: graceful failover
If the workspace fails to load for any reason, or if the language handler fails to load, we now fall back to displaying no editor, just like a normal code block.
2024-04-08 17:08:24 -06:00
Neale Pickett afae394618 Instant update index, close tab on correct answer
Fixes #201
2024-04-08 14:38:08 -06:00
Neale Pickett c9bd05c4ef Fix dark mode 2024-04-02 13:15:45 -06:00
Neale Pickett a7c2ee0022 Better structured theme config 2024-04-01 17:06:29 -06:00
Neale Pickett 2f7fba2dff Default font fallback 2024-04-01 16:57:02 -06:00
Neale Pickett 285c101bc6 I don't like Go Mono 2024-04-01 16:56:33 -06:00
Neale Pickett 3e629c6859 Re-adds workspaces (python IDE) 2024-04-01 16:46:45 -06:00
Neale Pickett c4788acaa2 NOPE 2024-01-19 12:29:23 -07:00
Neale Pickett be75ae0d5a v4.5.1 with go module version? 2024-01-19 12:24:37 -07:00
Neale Pickett 9c8c757dc0 Release 4.5.0 2024-01-19 12:09:53 -07:00
Neale Pickett 702118a437 Add optional support for entitled puzzles 2024-01-08 18:14:28 -07:00
Neale Pickett ce0862372c Get extra field working, fix a few error messages 2024-01-03 15:56:20 -07:00
Neale Pickett 58f60e4598 changelog wording 2024-01-03 14:30:13 -07:00
Neale Pickett 6a6860b5da Allow arbitrary metadata on puzzles 2024-01-03 14:28:50 -07:00
Neale Pickett 124b879f03 Ignore windows executables 2023-12-13 18:37:47 -07:00
Neale Pickett 3924eb0249 Twiddle a bit 2023-12-11 11:40:21 -07:00
Neale Pickett bbb5a5484a Development doc more approachable? 2023-12-11 11:36:18 -07:00
Neale Pickett 60fdb0ddd8 Include moth-devel.bat in win artifacts 2023-12-06 17:22:45 -07:00
Neale Pickett cc0e5bba94 actually build it 2023-12-06 17:17:47 -07:00
Neale Pickett 535276446c Possibly smarter winbuild 2023-12-06 17:13:21 -07:00
Neale Pickett 7418a3c224 More CI work 2023-12-06 16:51:39 -07:00
Neale Pickett 05ed4f315c winbuild work? 2023-12-06 16:49:02 -07:00
Neale Pickett fa049db1a2 Windows executables from CI/CD? 2023-12-06 16:44:40 -07:00
Neale Pickett 4710b6927a Clarify download everything doc 2023-11-21 13:10:44 -07:00
Neale Pickett f75286d0cf download everything doc + script 2023-11-21 13:05:55 -07:00
Neale Pickett 63881f05fa New scoreboard view 2023-11-16 23:44:32 -07:00
Neale Pickett c4bf25f8fa s/id/class/ 2023-11-16 22:37:05 -07:00
Neale Pickett 610eb27430 Scoreboard changes:
* Consistent category colors
* Only show server URL when enabled
* HTML to display when there are no scores
2023-11-16 22:18:16 -07:00
Neale Pickett e4a8883f27 Scoreboard: preserve category order 2023-11-16 20:07:49 -07:00
Neale Pickett 79cef80486 scoreboard: category stays consistent color 2023-11-16 19:57:01 -07:00
Neale Pickett 62043919f5 Reduce scoreboard replay FPS 2023-11-16 19:56:30 -07:00
Neale Pickett 6045000564 fix: helper.js was updating form.answer 2023-11-15 09:58:41 -07:00
Neale Pickett bae0fb25c6 Rm "answer" class from form: helpers.js confused. 2023-11-14 14:37:04 -07:00
Neale Pickett 40b9acf33f ci.sh can build a tarball now 2023-10-18 15:12:38 -06:00
Neale Pickett eba861aed6 Document prior releases 2023-10-18 15:01:51 -06:00
Neale Pickett c20cc1484f Pass unit tests 2023-10-03 11:44:51 -07:00
Neale Pickett 44dfbd43b5 window.checkAnswer function 2023-10-03 11:24:26 -07:00
Neale Pickett 59a6aef007 lowercase error string 2023-09-29 15:38:44 -06:00
Neale Pickett 79799bf1c2 State: add "Enabeled", remove "Messages"
Fixes #164
2023-09-29 15:37:18 -06:00
Neale Pickett 077dc261e4 Merge branch 'libmoth' into 'main'
New theme

Closes #190

See merge request devs/moth!181
2023-09-29 00:17:52 +00:00
Neale Pickett 0abb44c48c Actually implement login, LOL 2023-09-28 18:16:18 -06:00
Neale Pickett 6ff379e0f4 try to prevent future bad decisions 2023-09-28 12:59:51 -06:00
Neale Pickett eb786ba184 More scoreboard configurables 2023-09-28 12:42:25 -06:00
Neale Pickett 3d8c47d316 Integrate Ken's "monarch of the category" 2023-09-27 18:17:11 -06:00
Neale Pickett 5dfcb6324f Merge branch 'github/fork/knewbetter/scoreboard-js-dependency-loading' into 'libmoth'
Added responsive design elements and separated the scores from the te…

See merge request devs/moth!170
2023-09-28 00:01:06 +00:00
Neale Pickett 9071631353 more cleanup 2023-09-27 17:58:29 -06:00
Neale Pickett 43aec24d63 more cleanup 2023-09-27 17:57:30 -06:00
Neale Pickett b863955fdc Fully integrated 2023-09-27 17:56:40 -06:00
Neale Pickett b293a9f0e9 Add merged scoreboard.css 2023-09-27 17:15:51 -06:00
Neale Pickett 34e51848be Merge branch 'libmoth' into github/fork/knewbetter/scoreboard-js-dependency-loading 2023-09-27 17:15:37 -06:00
Neale Pickett 1ca2ec284f make the report card link to the report card 2023-09-27 16:16:04 -06:00
Neale Pickett 12979a55a3 We're not doing github builds any more 2023-09-27 16:14:23 -06:00
Neale Pickett 3282ad22b0 Scores, not Score 2023-09-27 16:10:31 -06:00
Neale Pickett 5350cf73a0 leadership sprint bugfixes
* Messages now in config.json
* puzzle.html: display errors
2023-09-19 16:48:24 -06:00
Neale Pickett 768600e48e Logout in devel mode generates a new TeamID 2023-09-15 16:13:09 -06:00
Neale Pickett bb4859e7a9 URL in scoreboard (configurable) 2023-09-15 16:09:08 -06:00
Neale Pickett d18de0fe8b working scoreboard 2023-09-15 15:17:07 -06:00
Neale Pickett f49eb3ed46 Change answer hash algorithm to SHA1₄ 2023-09-15 12:34:31 -06:00
Neale Pickett c72d13af32 Some twiddling to prepare for a scoreboard update 2023-09-14 19:08:44 -06:00
Neale Pickett c0761933a9 KSA report finished, config.json 2023-09-14 17:42:02 -06:00
Neale Pickett 4ce0dcf11a Stop accepting empty team ID in devel mode 2023-09-14 14:47:20 -06:00
Neale Pickett d87be0bfcb Color twiddling 2023-09-13 19:24:05 -06:00
Neale Pickett 13c17873d8 CSS twiddling 2023-09-13 19:10:25 -06:00
Neale Pickett 9ea39363b8 Mostly using new library, except scoreboard 2023-09-13 18:52:52 -06:00
Neale Pickett 0831c4e3d5 Just some twiddling 2023-09-12 19:30:53 -06:00
Neale Pickett 175b7aaa1b CoS hover cursor fix 2023-09-12 17:32:34 -06:00
Neale Pickett a82851fee3 Lots more (circle of success!) 2023-09-12 17:30:36 -06:00
Neale Pickett b135069851 Clean up animation code, begin work on login 2023-09-11 17:29:14 -06:00
Neale Pickett 18c5f044cc stub submit event 2023-09-08 18:11:36 -06:00
Neale Pickett 551afe04a5 Puzzle start using new lib +bg animation 2023-09-08 18:05:51 -06:00
Neale Pickett a896788cc5 Also list KSAs by Category 2023-09-08 11:31:41 -06:00
Neale Pickett 8ff91e79ec Refer to server docs for Puzzle fields 2023-09-07 17:29:21 -06:00
Neale Pickett 47671b9a12 jsdoc fixes (maybe?) 2023-09-07 16:32:06 -06:00
Neale Pickett 99d7245c49 Full moth.mjs, and an example to use it 2023-09-07 16:16:46 -06:00
Neale Pickett fcfa11b012 Initial work on #190 2023-09-01 17:59:09 -06:00
Neale Pickett a3d0f55160 Try to fix CI/CD build for tags 2023-04-13 15:32:15 -06:00
Neale Pickett d2971ee740 Attempt to fix packages 2023-04-11 17:56:59 -06:00
Neale Pickett 67e8dda39d Remove use of participant ID
fixes #176
2023-03-23 14:28:11 -06:00
Neale Pickett c43ed9620b
Merge pull request #175 from dirtbags/dependabot/go_modules/golang.org/x/text-0.3.8
Bump golang.org/x/text from 0.3.7 to 0.3.8

Thanks, dependabot!
2023-02-23 08:40:54 -07:00
dependabot[bot] ded29f92c1
Bump golang.org/x/text from 0.3.7 to 0.3.8
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.3.7 to 0.3.8.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.3.7...v0.3.8)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-23 05:10:45 +00:00
Neale Pickett 887e4b3eaf Remove disabled, better hours.txt logs 2022-11-29 15:48:35 -07:00
John Donaldson c3a7ee0d4f Pull XML reporting into its own job, so it still shows up 2022-10-28 12:33:30 -07:00
John Donaldson 7925547daf Coverage xml wasn't getting added, now 2022-10-28 12:32:44 -07:00
John Donaldson 5ba58664b6 Merge branch 'add_coverage_report_html' into 'main'
Add more coverage artifacts

See merge request devs/moth!180
2022-10-25 21:27:42 +00:00
John Donaldson 190657f2fa Add more coverage artifacts 2022-10-25 13:48:58 -07:00
John Donaldson 73933447a9 Merge branch 'add_coverage_report_number' into 'main'
Add overall report coverage number

See merge request devs/moth!179
2022-10-25 20:37:56 +00:00
John Donaldson 3bd1cdcc56 Add overall report coverage number 2022-10-25 20:37:56 +00:00
John Donaldson 5720961e85 Merge branch 'add_unit_test_coverage_report' into 'main'
Add better reporting on unit tests

See merge request devs/moth!178
2022-10-21 21:57:43 +00:00
John Donaldson 466de2d9c6 Let the test handler take care of installing stuff 2022-10-21 14:54:00 -07:00
John Donaldson 92d904150a Add better reporting on unit tests 2022-10-21 14:52:26 -07:00
Neale Pickett 8e0f4561a5 changelog 2022-05-12 18:15:46 -06:00
Neale Pickett eb08700dd1 Merge branch 'main' of https://git.cyberfire.ninja/devs/moth 2022-05-12 18:03:28 -06:00
Neale Pickett a85df22479 Upgrades, NFS optimization 2022-05-12 18:03:26 -06:00
Neale Pickett a387a06ae5 Merge branch '179-intermittent-test-failures-on-gitlab-ci' into 'main'
Resolve "Intermittent test failures on gitlab CI"

Closes #179

See merge request devs/moth!177
2022-05-11 02:19:57 +00:00
Neale Pickett cbe231ef12 Remove more debugging 2022-05-10 20:05:16 -06:00
Neale Pickett 243fdfd006 Remove some debugging 2022-05-10 19:57:07 -06:00
Neale Pickett bde4b2c86d A bit cleaner test interface, maybe 2022-05-10 19:48:51 -06:00
Neale Pickett 85f5b96a40 Stop running goroutines in unit tests 2022-05-10 19:36:36 -06:00
Neale Pickett dfc31eb9f3 Merge branch 'main' into 179-intermittent-test-failures-on-gitlab-ci 2022-05-10 19:21:25 -06:00
Neale Pickett be74961e94 still trying to fix race condition 2022-05-10 19:11:47 -06:00
Neale Pickett d014384b05 A possible fix for #179 2022-05-10 17:59:08 -06:00
Neale Pickett 6d7fb9ebf5 update changelog 2022-05-10 17:53:31 -06:00
Neale Pickett 5b6555cd9a Check team existence before registering.
Fixes #156
2022-05-10 17:47:26 -06:00
Neale Pickett e5a3b26c93 Update changelog 2022-05-10 15:26:01 -06:00
Neale Pickett eea674b1a4 Remove `events.csv` on init.
Fixes #177
2022-05-10 13:30:44 -06:00
Neale Pickett 459d774726 Always run tests in CI, not just on main branch 2021-10-20 11:30:53 -06:00
Neale Pickett e349a18861 Trying to isolate a race condition in tests 2021-10-20 11:29:55 -06:00
Ken Knudsen f7945fcf3b Added responsive design elements and separated the scores from the team names to reduce overlap. Use side-by-side view on large screens. 2021-08-13 00:22:15 +00:00
100 changed files with 53195 additions and 2679 deletions

9
.gitignore vendored
View File

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

View File

@ -1,19 +1,59 @@
stages: stages:
- test - test
- build
- push - push
test: Run unit tests:
stage: test stage: test
image: golang:1.17 image: &goimage golang:1.21
only: only:
refs: refs:
- main - main
- tags
- merge_requests - merge_requests
script: script:
- go test ./... - go test -coverprofile=coverage.txt -covermode=atomic -race ./...
- go tool cover -html=coverage.txt -o coverage.html
- go tool cover -func coverage.txt
coverage: /\(statements\)(?:\s+)?(\d+(?:\.\d+)?%)/
artifacts:
paths:
- coverage.html
- coverage.txt
Generage coverage XML:
stage: test
image: *goimage
needs: ["Run unit tests"]
script:
- go get github.com/boumenot/gocover-cobertura
- go run github.com/boumenot/gocover-cobertura < coverage.txt > coverage.xml
only:
refs:
- main
- tags
- merge_requests
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
winbuild:
stage: build
image: *goimage
needs: ["Run unit tests"]
script:
- GOOS=windows GOARCH=amd64 go build ./cmd/mothd
- cp build/package/moth-devel.bat .
artifacts:
paths:
- mothd.exe
- moth-devel.bat
- theme/*
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'

View File

@ -4,6 +4,44 @@ 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,

View File

@ -129,10 +129,36 @@ Both came with the following license:
> OTHER DEALINGS IN THE FONT SOFTWARE. > OTHER DEALINGS IN THE FONT SOFTWARE.
Javascript MD5 Library Go Fonts
====================== =======
Obtained from <https://github.com/blueimp/JavaScript-MD5>, which says: The Go fonts were obtained from
https://go.googlesource.com/image
> The JavaScript MD5 script is released under the Copyright (c) 2009 The Go Authors. All rights reserved.
> [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.

View File

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

View File

@ -43,6 +43,11 @@ 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

View File

@ -1,4 +1,5 @@
FROM golang:1 AS builder ARG GO_VERSION=1.21-alpine
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/

View File

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

View File

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

24
build/package/winbuild.sh Executable file
View File

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

View File

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

View File

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

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

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

View File

@ -6,7 +6,10 @@ import (
"log" "log"
"mime" "mime"
"os" "os"
"path/filepath"
"time" "time"
"github.com/spf13/afero"
) )
func main() { func main() {
@ -52,20 +55,38 @@ func main() {
) )
flag.Parse() flag.Parse()
theme := NewTheme(*themePath) var theme *Theme
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
provider = NewMothballs(os.DirFS(*mothballPath)) if p, err := filepath.Abs(*mothballPath); err != nil {
log.Fatal(err)
} else {
provider = NewMothballs(afero.NewBasePathFs(osfs, p))
}
if *puzzlePath != "" { if *puzzlePath != "" {
provider = NewTranspilerProvider(os.DirFS(*puzzlePath)) if p, err := filepath.Abs(*puzzlePath); err != nil {
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
state = NewState(*statePath) if p, err := filepath.Abs(*statePath); err != nil {
log.Fatal(err)
} else {
state = NewState(afero.NewBasePathFs(osfs, p))
}
if config.Devel { if config.Devel {
state = NewDevelState(state) state = NewDevelState(state)
} }

View File

@ -3,35 +3,36 @@ 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 {
zip.Reader afero.Fs
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 {
fs.FS afero.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(fsys fs.FS) *Mothballs { func NewMothballs(fs afero.Fs) *Mothballs {
return &Mothballs{ return &Mothballs{
FS: fsys, Fs: fs,
categories: make(map[string]zipCategory), categories: make(map[string]zipCategory),
categoryLock: new(sync.RWMutex), categoryLock: new(sync.RWMutex),
} }
@ -44,8 +45,8 @@ func (m *Mothballs) getCat(cat string) (zipCategory, bool) {
return ret, ok return ret, ok
} }
// Open returns an fs.File corresponding to the filename in a puzzle's category and points // Open returns a ReadSeekCloser corresponding to the filename in a puzzle's category and points
func (m *Mothballs) Open(cat string, points int, filename string) (fs.File, time.Time, error) { func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) {
zc, ok := m.getCat(cat) zc, ok := m.getCat(cat)
if !ok { if !ok {
return nil, time.Time{}, fmt.Errorf("no such category: %s", cat) return nil, time.Time{}, fmt.Errorf("no such category: %s", cat)
@ -111,41 +112,6 @@ 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() {
@ -153,7 +119,7 @@ func (m *Mothballs) refresh() {
defer m.categoryLock.Unlock() defer m.categoryLock.Unlock()
// Any new categories? // Any new categories?
files, err := fs.ReadDir(m.FS, "/") files, err := afero.ReadDir(m.Fs, "/")
if err != nil { if err != nil {
log.Println("Error listing mothballs:", err) log.Println("Error listing mothballs:", err)
return return
@ -170,7 +136,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 := fs.Stat(m.FS, filename); err != nil { } else if si, err := m.Fs.Stat(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()
@ -179,14 +145,33 @@ func (m *Mothballs) refresh() {
} }
if reopen { if reopen {
if f, err := m.FS.Open(filename); err != nil { f, err := m.Fs.Open(filename)
if err != nil {
log.Println(err) log.Println(err)
} else if zipCat, err := m.newZipCategory(f); err != nil { continue
log.Println(err)
} else {
m.categories[categoryName] = zipCat
log.Println("Adding category:", categoryName)
} }
fi, err := f.Stat()
if err != nil {
f.Close()
log.Println(err)
continue
}
zrc, err := zip.NewReader(f, fi.Size())
if err != nil {
f.Close()
log.Println(err)
continue
}
m.categories[categoryName] = zipCategory{
Fs: zipfs.New(zrc),
Closer: f,
mtime: fi.ModTime(),
}
log.Println("Adding category:", categoryName)
} }
} }

View File

@ -2,12 +2,11 @@ package main
import ( import (
"archive/zip" "archive/zip"
"bytes"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"testing" "testing"
"testing/fstest"
"time" "github.com/spf13/afero"
) )
type testFileContents struct { type testFileContents struct {
@ -24,27 +23,9 @@ var testFiles = []testFileContents{
{"3/moo.txt", `moo`}, {"3/moo.txt", `moo`},
} }
type TestMothballs struct { func (m *Mothballs) createMothballWithFiles(cat string, contents []testFileContents) {
*Mothballs f, _ := m.Create(fmt.Sprintf("%s.mb", cat))
fsys fstest.MapFS defer f.Close()
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()
@ -57,16 +38,9 @@ func (m *TestMothballs) createMothballWithFiles(cat string, contents []testFileC
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 *TestMothballs) createMothball(cat string) { func (m *Mothballs) createMothball(cat string) {
m.createMothballWithFiles( m.createMothballWithFiles(
cat, cat,
[]testFileContents{ []testFileContents{
@ -75,7 +49,14 @@ func (m *TestMothballs) createMothball(cat string) {
) )
} }
func TestMothballStuff(t *testing.T) { func NewTestMothballs() *Mothballs {
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")
@ -148,7 +129,7 @@ func TestMothballStuff(t *testing.T) {
} }
m.createMothball("test2") m.createMothball("test2")
delete(m.fsys, "pategory.mb") m.Fs.Remove("pategory.mb")
m.refresh() m.refresh()
inv = m.Inventory() inv = m.Inventory()
if len(inv) != 1 { if len(inv) != 1 {

View File

@ -15,7 +15,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dirtbags/moth/pkg/transpile" "github.com/dirtbags/moth/v4/pkg/transpile"
) )
// ProviderCommand specifies a command to run for the puzzle API // ProviderCommand specifies a command to run for the puzzle API
@ -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) (io.ReadSeekCloser, time.Time, error) { func (pc ProviderCommand) Open(cat string, points int, path string) (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()

View File

@ -6,7 +6,7 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/dirtbags/moth/pkg/award" "github.com/dirtbags/moth/v4/pkg/award"
) )
// Category represents a puzzle category. // Category represents a puzzle category.
@ -15,6 +15,13 @@ 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
@ -23,7 +30,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
Messages string Enabled bool
TeamNames map[string]string TeamNames map[string]string
PointsLog award.List PointsLog award.List
Puzzles map[string][]int Puzzles map[string][]int
@ -31,7 +38,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) (io.ReadSeekCloser, time.Time, error) Open(cat string, points int, path string) (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
@ -40,18 +47,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) (io.ReadSeekCloser, time.Time, error) Open(path string) (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 {
Messages() string Enabled() bool
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, participantID, teamID, cat string, points int, extra ...string) LogEvent(event, teamID, cat string, points int, extra ...string)
Maintainer Maintainer
} }
@ -61,6 +68,9 @@ type Maintainer interface {
// It will only be called once, when execution begins. // It will only be called once, when execution begins.
// It's okay to just exit if there's no maintenance to be done. // It's okay to just exit if there's no maintenance to be done.
Maintain(updateInterval time.Duration) Maintain(updateInterval time.Duration)
// refresh is a shortcut used internally for testing
refresh()
} }
// MothServer gathers together the providers that make up a MOTH server. // MothServer gathers together the providers that make up a MOTH server.
@ -82,24 +92,22 @@ func NewMothServer(config Configuration, theme ThemeProvider, state StateProvide
} }
// NewHandler returns a new http.RequestHandler for the provided teamID. // NewHandler returns a new http.RequestHandler for the provided teamID.
func (s *MothServer) NewHandler(participantID, teamID string) MothRequestHandler { func (s *MothServer) NewHandler(teamID string) MothRequestHandler {
return MothRequestHandler{ return MothRequestHandler{
MothServer: s, MothServer: s,
participantID: participantID, teamID: teamID,
teamID: teamID,
} }
} }
// MothRequestHandler provides http.RequestHandler for a MothServer. // MothRequestHandler provides http.RequestHandler for a MothServer.
type MothRequestHandler struct { type MothRequestHandler struct {
*MothServer *MothServer
participantID string teamID string
teamID string
} }
// PuzzlesOpen opens a file associated with a puzzle. // PuzzlesOpen opens a file associated with a puzzle.
// 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 io.ReadSeekCloser, ts time.Time, err error) { func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r 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] {
@ -121,7 +129,7 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (
// Log puzzle.json loads // Log puzzle.json loads
if path == "puzzle.json" { if path == "puzzle.json" {
mh.State.LogEvent("load", mh.participantID, mh.teamID, cat, points) mh.State.LogEvent("load", mh.teamID, cat, points)
} }
return return
@ -138,34 +146,33 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string)
} }
} }
if !correct { if !correct {
mh.State.LogEvent("wrong", mh.participantID, mh.teamID, cat, points) mh.State.LogEvent("wrong", mh.teamID, cat, points)
return fmt.Errorf("incorrect answer") return fmt.Errorf("incorrect answer")
} }
mh.State.LogEvent("correct", mh.participantID, mh.teamID, cat, points) mh.State.LogEvent("correct", mh.teamID, cat, points)
if _, err := mh.State.TeamName(mh.teamID); err != nil { if _, err := mh.State.TeamName(mh.teamID); err != nil {
return fmt.Errorf("invalid team ID") return fmt.Errorf("invalid team ID")
} }
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil { if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
return fmt.Errorf("error awarding points: %s", err) return err
} }
return nil return nil
} }
// ThemeOpen opens a file from a theme. // ThemeOpen opens a file from a theme.
func (mh *MothRequestHandler) ThemeOpen(path string) (io.ReadSeekCloser, time.Time, error) { func (mh *MothRequestHandler) ThemeOpen(path string) (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.participantID, mh.teamID, "", 0) mh.State.LogEvent("register", mh.teamID, "", 0)
return mh.State.SetTeamName(mh.teamID, teamName) return mh.State.SetTeamName(mh.teamID, teamName)
} }
@ -177,14 +184,17 @@ func (mh *MothRequestHandler) ExportState() *StateExport {
return mh.exportStateIfRegistered(false) return mh.exportStateIfRegistered(false)
} }
func (mh *MothRequestHandler) exportStateIfRegistered(override bool) *StateExport { // Export state, replacing the team ID with "self" if the team is registered.
//
// If forceRegistered is true, go ahead and export it anyway
func (mh *MothRequestHandler) exportStateIfRegistered(forceRegistered bool) *StateExport {
export := StateExport{} export := StateExport{}
export.Config = mh.Config export.Config = mh.Config
teamName, err := mh.State.TeamName(mh.teamID) teamName, err := mh.State.TeamName(mh.teamID)
registered := override || mh.Config.Devel || (err == nil) registered := forceRegistered || mh.Config.Devel || (err == nil)
export.Messages = mh.State.Messages() export.Enabled = mh.State.Enabled()
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

View File

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

View File

@ -14,7 +14,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/dirtbags/moth/pkg/award" "github.com/dirtbags/moth/v4/pkg/award"
"github.com/spf13/afero"
) )
// DistinguishableChars are visually unambiguous glyphs. // DistinguishableChars are visually unambiguous glyphs.
@ -33,28 +34,29 @@ 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 {
basedir string afero.Fs
// 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 *os.File eventWriterFile afero.File
// Caches, so we're not hammering NFS with metadata operations // Caches, so we're not hammering NFS with metadata operations
teamNames map[string]string teamNamesLastChange time.Time
pointsLog award.List teamNames map[string]string
messages string pointsLog award.List
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(basedir string) *State { func NewState(fs afero.Fs) *State {
s := &State{ s := &State{
basedir: basedir, Fs: fs,
Enabled: true, enabled: true,
refreshNow: make(chan bool, 5), refreshNow: make(chan bool, 5),
eventStream: make(chan []string, 80), eventStream: make(chan []string, 80),
@ -66,19 +68,13 @@ func NewState(basedir string) *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/enabled` present, `state/hours.txt` missing" why := "state/hours.txt has no timestamps before now"
if untilFile, err := os.Open(s.path("hours.txt")); err == nil { if untilFile, err := s.Open("hours.txt"); err == nil {
defer untilFile.Close() defer untilFile.Close()
why = "`state/hours.txt` present"
scanner := bufio.NewScanner(untilFile) scanner := bufio.NewScanner(untilFile)
for scanner.Scan() { for scanner.Scan() {
@ -98,35 +94,36 @@ func (s *State) updateEnabled() {
case '#': case '#':
continue continue
default: default:
log.Println("Misformatted line in hours.txt file") log.Println("state/hours.txt has bad line:", line)
} }
line, _, _ = strings.Cut(line, "#") // Remove inline comments
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
until, err := time.Parse(time.RFC3339, line) until := time.Time{}
if err != nil { if len(line) == 0 {
until, err = time.Parse(RFC3339Space, line) // Let it stay as zero time, so it's always before now
} } else if until, err = time.Parse(time.RFC3339, line); err == nil {
if err != nil { // Great, it was RFC 3339
log.Println("Suspended: Unparseable until date:", line) } else if until, err = time.Parse(RFC3339Space, line); err == nil {
// Great, it was RFC 3339 with a space instead of a 'T'
} else {
log.Println("state/hours.txt has bad timestamp:", line)
continue continue
} }
if until.Before(time.Now()) { if until.Before(time.Now()) {
nextEnabled = thisEnabled nextEnabled = thisEnabled
why = fmt.Sprint("state/hours.txt most recent timestamp:", line)
} }
} }
} }
if _, err := os.Stat(s.path("enabled")); os.IsNotExist(err) { if (nextEnabled != s.enabled) || (why != s.enabledWhy) {
nextEnabled = false s.enabled = nextEnabled
why = "`state/enabled` missing" s.enabledWhy = why
} log.Printf("Setting enabled=%v: %s", s.enabled, s.enabledWhy)
if s.enabled {
if nextEnabled != s.Enabled { s.LogEvent("enabled", "", "", 0, s.enabledWhy)
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, why) s.LogEvent("disabled", "", "", 0, s.enabledWhy)
} }
} }
} }
@ -145,7 +142,14 @@ 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 {
idsFile, err := os.Open(s.path("teamids.txt")) s.lock.RLock()
_, 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")
} }
@ -163,7 +167,7 @@ func (s *State) SetTeamName(teamID, teamName string) error {
} }
teamFilename := filepath.Join("teams", teamID) teamFilename := filepath.Join("teams", teamID)
teamFile, err := os.OpenFile(s.path(teamFilename), os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644) teamFile, err := s.Fs.OpenFile(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 {
@ -188,11 +192,9 @@ func (s *State) PointsLog() award.List {
return ret return ret
} }
// Messages retrieves the current messages. // Enabled returns true if the server is in "enabled" state
func (s *State) Messages() string { func (s *State) Enabled() bool {
s.lock.RLock() // It's not clear to me that this actually needs to happen return s.enabled
defer s.lock.RUnlock()
return s.messages
} }
// AwardPoints gives points to teamID in category. // AwardPoints gives points to teamID in category.
@ -224,11 +226,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 := os.WriteFile(s.path(tmpfn), []byte(a.String()), 0644); err != nil { if err := afero.WriteFile(s, tmpfn, []byte(a.String()), 0644); err != nil {
return err return err
} }
if err := os.Rename(s.path(tmpfn), newfn); err != nil { if err := s.Rename(tmpfn, newfn); err != nil {
return err return err
} }
@ -241,14 +243,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 := os.ReadDir(s.path("points.new")) files, err := afero.ReadDir(s, "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 := os.ReadFile(s.path(filename)) awardstr, err := afero.ReadFile(s, filename)
if err != nil { if err != nil {
log.Print("Opening new points: ", err) log.Print("Opening new points: ", err)
continue continue
@ -274,7 +276,7 @@ func (s *State) collectPoints() {
} else { } else {
log.Print("Award: ", awd.String()) log.Print("Award: ", awd.String())
logf, err := os.OpenFile(s.path("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) logf, err := s.OpenFile("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
@ -288,7 +290,7 @@ func (s *State) collectPoints() {
s.lock.Unlock() s.lock.Unlock()
} }
if err := os.Remove(s.path(filename)); err != nil { if err := s.Remove(filename); err != nil {
log.Print("Unable to remove new points file: ", err) log.Print("Unable to remove new points file: ", err)
} }
} }
@ -296,7 +298,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 := os.Stat(s.path("initialized")); !os.IsNotExist(err) { if _, err := s.Stat("initialized"); !os.IsNotExist(err) {
return return
} }
@ -304,28 +306,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
os.Remove(s.path("enabled")) s.Remove("enabled")
os.Remove(s.path("hours.txt")) s.Remove("hours.txt")
os.Remove(s.path("points.log")) s.Remove("points.log")
os.Remove(s.path("messages.html")) s.Remove("events.csv")
os.Remove(s.path("mothd.log")) s.Remove("mothd.log")
os.RemoveAll(s.path("points.tmp")) s.RemoveAll("points.tmp")
os.RemoveAll(s.path("points.new")) s.RemoveAll("points.new")
os.RemoveAll(s.path("teams")) s.RemoveAll("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
os.Mkdir(s.path("points.tmp"), 0755) s.Mkdir("points.tmp", 0755)
os.Mkdir(s.path("points.new"), 0755) s.Mkdir("points.new", 0755)
os.Mkdir(s.path("teams"), 0755) s.Mkdir("teams", 0755)
// Preseed available team ids if file doesn't exist // Preseed available team ids if file doesn't exist
if f, err := os.OpenFile(s.path("teamids.txt"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil { if f, err := s.OpenFile("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 {
@ -338,50 +340,42 @@ func (s *State) maybeInitialize() {
} }
// Create some files // Create some files
if f, err := os.Create(s.path("initialized")); err == nil { if f, err := s.Create("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 := os.Create(s.path("enabled")); err == nil { if f, err := s.Create("hours.txt"); 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, "# You can have multiple start/stop times.") fmt.Fprintln(f, "# This file, and all files in this directory, are re-read periodically.")
fmt.Fprintln(f, "# Whatever time is the most recent, wins.") fmt.Fprintln(f, "# Default is enabled.")
fmt.Fprintln(f, "# Times in the future are ignored.") fmt.Fprintln(f, "# Rules with only '-' or '+' are also allowed.")
fmt.Fprintln(f, "# Rules apply from the top down.")
fmt.Fprintln(f, "# If you put something in out of order, it's going to be bonkers.")
fmt.Fprintln(f) fmt.Fprintln(f)
fmt.Fprintln(f, "- 1970-01-01T00:00:00Z")
fmt.Fprintln(f, "+", now) fmt.Fprintln(f, "+", now)
fmt.Fprintln(f, "- 2519-10-31T00:00:00Z") fmt.Fprintln(f, "- 2519-10-31T00:00:00Z")
f.Close() f.Close()
} }
if f, err := os.Create(s.path("messages.html")); err == nil { if f, err := s.Create("points.log"); 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, participantID, teamID, cat string, points int, extra ...string) { func (s *State) LogEvent(event, teamID, cat string, points int, extra ...string) {
s.eventStream <- append( s.eventStream <- append(
[]string{ []string{
strconv.FormatInt(time.Now().Unix(), 10), strconv.FormatInt(time.Now().Unix(), 10),
event, event,
participantID,
teamID, teamID,
cat, cat,
strconv.Itoa(points), strconv.Itoa(points),
@ -400,7 +394,7 @@ func (s *State) reopenEventLog() error {
log.Print(err) log.Print(err)
} }
} }
eventWriterFile, err := os.OpenFile(s.path("events.csv"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) eventWriterFile, err := s.OpenFile("events.csv", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
return err return err
} }
@ -413,7 +407,7 @@ func (s *State) updateCaches() {
s.lock.Lock() s.lock.Lock()
defer s.lock.Unlock() defer s.lock.Unlock()
if f, err := os.Open(s.path("points.log")); err != nil { if f, err := s.Open("points.log"); err != nil {
log.Println(err) log.Println(err)
} else { } else {
defer f.Close() defer f.Close()
@ -432,37 +426,42 @@ 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
{ {
// The compiler recognizes this as an optimization case _, ismmfs := s.Fs.(*afero.MemMapFs) // Tests run so quickly that the time check isn't precise enough
for k := range s.teamNames { if fi, err := s.Fs.Stat("teams"); err != nil {
delete(s.teamNames, k) log.Printf("Getting modification time of teams directory: %v", err)
} } else if ismmfs || s.teamNamesLastChange.Before(fi.ModTime()) {
s.teamNamesLastChange = fi.ModTime()
if dirents, err := os.ReadDir(s.path("teams")); err != nil { // The compiler recognizes this as an optimization case
log.Printf("Reading team ids: %v", err) for k := range s.teamNames {
} else { delete(s.teamNames, k)
for _, dirent := range dirents { }
teamID := dirent.Name()
if teamNameBytes, err := os.ReadFile(s.path("teams", teamID)); err != nil { teamsFs := afero.NewBasePathFs(s.Fs, "teams")
log.Printf("Reading team %s: %v", teamID, err) if dirents, err := afero.ReadDir(teamsFs, "."); err != nil {
} else { log.Printf("Reading team ids: %v", err)
teamName := strings.TrimSpace(string(teamNameBytes)) } else {
s.teamNames[teamID] = teamName for _, dirent := range dirents {
teamID := dirent.Name()
if teamNameBytes, err := afero.ReadFile(teamsFs, teamID); err != nil {
log.Printf("Reading team %s: %v", teamID, err)
} else {
teamName := strings.TrimSpace(string(teamNameBytes))
s.teamNames[teamID] = teamName
}
} }
} }
} }
}
if bMessages, err := 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()
@ -510,6 +509,9 @@ func (ds *DevelState) TeamName(teamID string) (string, error) {
if name, err := ds.StateProvider.TeamName(teamID); err == nil { if name, err := ds.StateProvider.TeamName(teamID); err == nil {
return name, nil return name, nil
} }
if teamID == "" {
return "", fmt.Errorf("empty team ID")
}
return fmt.Sprintf("«devel:%s»", teamID), nil return fmt.Sprintf("«devel:%s»", teamID), nil
} }

View File

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

View File

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

View File

@ -1,27 +1,26 @@
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 {
basedir string afero.Fs
} }
// NewTheme returns a new Theme, backed by Fs. // NewTheme returns a new Theme, backed by Fs.
func NewTheme(basedir string) *Theme { func NewTheme(fs afero.Fs) *Theme {
return &Theme{ return &Theme{
basedir: basedir, Fs: fs,
} }
} }
// Open returns a new opened file. // Open returns a new opened file.
func (t *Theme) Open(name string) (io.ReadSeekCloser, time.Time, error) { func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) {
f, err := os.Open(path.Join(t.basedir, name)) f, err := t.Fs.Open(name)
if err != nil { if err != nil {
return nil, time.Time{}, err return nil, time.Time{}, err
} }
@ -39,3 +38,7 @@ func (t *Theme) Open(name string) (io.ReadSeekCloser, time.Time, error) {
func (t *Theme) Maintain(i time.Duration) { func (t *Theme) Maintain(i time.Duration) {
// No periodic tasks for a theme // No periodic tasks for a theme
} }
func (t *Theme) refresh() {
// Nothing to do for a theme
}

View File

@ -2,12 +2,25 @@ 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 := NewTheme("testdata/theme") s := NewTestTheme()
filename := "/index.html"
index := "this is the index"
afero.WriteFile(s.Fs, filename, []byte(index), 0644)
fileInfo, err := s.Fs.Stat(filename)
if err != nil {
t.Error(err)
}
if f, timestamp, err := s.Open("/index.html"); err != nil { if f, timestamp, err := s.Open("/index.html"); err != nil {
t.Error(err) t.Error(err)
@ -15,9 +28,7 @@ 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 fi, err := os.Stat("testdata/theme/index.html"); err != nil { } else if !timestamp.Equal(fileInfo.ModTime()) {
t.Error(err)
} else if !timestamp.Equal(fi.ModTime()) {
t.Error("Timestamp compared wrong") t.Error("Timestamp compared wrong")
} }

View File

@ -4,21 +4,21 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"io" "io"
"io/fs"
"log" "log"
"time" "time"
"github.com/dirtbags/moth/pkg/transpile" "github.com/dirtbags/moth/v4/pkg/transpile"
"github.com/spf13/afero"
) )
// NewTranspilerProvider returns a new TranspilerProvider. // NewTranspilerProvider returns a new TranspilerProvider.
func NewTranspilerProvider(fs fs.FS) TranspilerProvider { func NewTranspilerProvider(fs afero.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 fs.FS fs afero.Fs
} }
// Inventory returns a Category list for this provider. // Inventory returns a Category list for this provider.
@ -79,3 +79,7 @@ func (p TranspilerProvider) Mothball(cat string, w io.Writer) error {
func (p TranspilerProvider) Maintain(updateInterval time.Duration) { func (p TranspilerProvider) Maintain(updateInterval time.Duration) {
// Nothing to do here. // Nothing to do here.
} }
func (p TranspilerProvider) refresh() {
// Nothing to do for a theme
}

View File

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

View File

@ -5,13 +5,13 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"os" "os"
"sort" "sort"
"github.com/dirtbags/moth/pkg/namesubfs" "github.com/dirtbags/moth/v4/pkg/transpile"
"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 fs.FS BaseFs afero.Fs
fs fs.FS fs afero.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 = namesubfs.Sub(t.BaseFs, *directory) t.fs = afero.NewBasePathFs(t.BaseFs, *directory)
} else { } else {
t.fs = t.BaseFs t.fs = t.BaseFs
} }
@ -121,10 +121,7 @@ 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, err := transpile.NewFsPuzzle(t.fs) puzzle := transpile.NewFsPuzzle(t.fs)
if err != nil {
return err
}
p, err := puzzle.Puzzle() p, err := puzzle.Puzzle()
if err != nil { if err != nil {
@ -145,10 +142,7 @@ func (t *T) DumpFile() error {
filename = t.Args[0] filename = t.Args[0]
} }
puzzle, err := transpile.NewFsPuzzle(t.fs) puzzle := 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 {
@ -172,7 +166,7 @@ func (t *T) DumpMothball() error {
w = t.Stdout w = t.Stdout
} else { } else {
filename = t.Args[0] filename = t.Args[0]
outf, err := os.Create(filename) outf, err := t.BaseFs.Create(filename)
if err != nil { if err != nil {
return err return err
} }
@ -183,7 +177,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 != "" {
os.Remove(filename) t.BaseFs.Remove(filename)
} }
return err return err
} }
@ -196,11 +190,8 @@ func (t *T) CheckAnswer() error {
if len(t.Args) > 0 { if len(t.Args) > 0 {
answer = t.Args[0] answer = t.Args[0]
} }
c, err := transpile.NewFsPuzzle(t.fs) c := transpile.NewFsPuzzle(t.fs)
if err != nil { _, err := fmt.Fprintf(t.Stdout, `{"Correct":%v}`, c.Answer(answer))
return err
}
_, err = fmt.Fprintf(t.Stdout, `{"Correct":%v}`, c.Answer(answer))
return err return err
} }
@ -215,7 +206,7 @@ func main() {
Stdout: os.Stdout, Stdout: os.Stdout,
Stderr: os.Stderr, Stderr: os.Stderr,
Args: os.Args, Args: os.Args,
BaseFs: os.DirFS(""), BaseFs: afero.NewOsFs(),
} }
cmd, err := t.ParseArgs() cmd, err := t.ParseArgs()
if err != nil { if err != nil {

View File

@ -5,15 +5,13 @@ 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/pkg/transpile" "github.com/dirtbags/moth/v4/pkg/transpile"
"github.com/psanford/memfs" "github.com/spf13/afero"
) )
var testMothYaml = []byte(`--- var testMothYaml = []byte(`---
@ -29,20 +27,20 @@ attachments:
YAML body YAML body
`) `)
func newTestFs() fs.FS { func newTestFs() afero.Fs {
fsys := memfs.New() fs := afero.NewMemMapFs()
fsys.WriteFile("cat0/1/puzzle.md", testMothYaml, 0644) afero.WriteFile(fs, "cat0/1/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("cat0/1/moo.txt", []byte("Moo."), 0644) afero.WriteFile(fs, "cat0/1/moo.txt", []byte("Moo."), 0644)
fsys.WriteFile("cat0/2/puzzle.moth", testMothYaml, 0644) afero.WriteFile(fs, "cat0/2/puzzle.moth", testMothYaml, 0644)
fsys.WriteFile("cat0/3/puzzle.moth", testMothYaml, 0644) afero.WriteFile(fs, "cat0/3/puzzle.moth", testMothYaml, 0644)
fsys.WriteFile("cat0/4/puzzle.md", testMothYaml, 0644) afero.WriteFile(fs, "cat0/4/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("cat0/5/puzzle.md", testMothYaml, 0644) afero.WriteFile(fs, "cat0/5/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("cat0/10/puzzle.md", testMothYaml, 0644) afero.WriteFile(fs, "cat0/10/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("unbroken/1/puzzle.md", testMothYaml, 0644) afero.WriteFile(fs, "unbroken/1/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("unbroken/1/moo.txt", []byte("Moo."), 0644) afero.WriteFile(fs, "unbroken/1/moo.txt", []byte("Moo."), 0644)
fsys.WriteFile("unbroken/2/puzzle.md", testMothYaml, 0644) afero.WriteFile(fs, "unbroken/2/puzzle.md", testMothYaml, 0644)
fsys.WriteFile("unbroken/2/moo.txt", []byte("Moo."), 0644) afero.WriteFile(fs, "unbroken/2/moo.txt", []byte("Moo."), 0644)
return fsys return fs
} }
func (tp T) Run(args ...string) error { func (tp T) Run(args ...string) error {
@ -126,7 +124,8 @@ func TestMothballs(t *testing.T) {
return return
} }
fis, err := fs.ReadDir(tp.BaseFs, "/") // afero.WriteFile(tp.BaseFs, "unbroken.mb", []byte("moo"), 0644)
fis, err := afero.ReadDir(tp.BaseFs, "/")
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
@ -141,24 +140,13 @@ func TestMothballs(t *testing.T) {
} }
defer mb.Close() defer mb.Close()
var zmb *zip.Reader info, err := mb.Stat()
switch r := mb.(type) { if err != nil {
case io.ReaderAt: t.Error(err)
info, err := mb.Stat() return
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
@ -197,7 +185,7 @@ func TestFilesystem(t *testing.T) {
Stdin: stdin, Stdin: stdin,
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,
BaseFs: os.DirFS(""), BaseFs: afero.NewOsFs(),
} }
stdout.Reset() stdout.Reset()
@ -232,7 +220,7 @@ func TestCwd(t *testing.T) {
Stdin: stdin, Stdin: stdin,
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,
BaseFs: os.DirFS(""), BaseFs: afero.NewOsFs(),
} }
stdout.Reset() stdout.Reset()

51
contrib/download-everything.sh Executable file
View File

@ -0,0 +1,51 @@
#! /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 Normal file
View File

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

View File

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

163
docs/api-client.md Normal file
View File

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

View File

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

55
docs/client-structure.md Normal file
View File

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

BIN
docs/content-layout.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -1,105 +1,211 @@
Developing Content Developing Content: Structure
============================ ================
The development server shows debugging for each puzzle, ![Content Layout](content-layout.png)
and will compile puzzles on the fly.
Use it along with a text editor and shell to create new puzzles and categories. MOTH content heirarchy consists of three layers:
categories, puzzles, and attachments.
Category
-------
Set up some example puzzles 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.
If you don't have puzzles of your own to start with, Scoring is usually calculated by summing the
you can copy the example puzzles that come with the source: *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.
cp -r /path/to/src/moth/example-puzzles /srv/moth/puzzles Puzzle
Run the server in development mode
---------------
These recipes run the server in the foreground,
so you can watch the access log and any error messages.
### Podman
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
### 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). A puzzle consists of a few static fields,
You will be logged in automatically. 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)
```
Browse the example puzzles Developing Content: Source vs Mothballs
------------ =============================
As a developer,
you will begin by running MOTH in development mode.
In development mode,
MOTH will provide answers for each puzzle alongside the puzzle.
In order to run in production mode,
each category must be "transpiled" into a "mothball".
This is done to reduce the amount of dynamic code running on a production server,
as a way of decreasing the attack surface of the server.
To obtain a mothball,
simply click the "download" button on the puzzles list of a development server.
Mothballs have the file extension `.mb`.
The example puzzles are written to demonstrate various features of MOTH, Setting Up Your Workstation
and serve as documentation of the puzzle format. =====================
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.
Make your own puzzle category Text Editor
------------------------- ---------
cp -r /srv/moth/puzzles/example /srv/moth/puzzles/my-category We like Visual Studio Code,
but any text editor will work:
you only need to edit the `puzzle.md` (Markdown) files.
Your First Category
================
Edit the one point puzzle 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:
nano /srv/moth/puzzles/my-category/1/puzzle.md ```markdown
---
authors:
- YOUR NAME HERE
answers:
- Elephant
- elephant
---
I don't use nano, personally, # Animal Time
but if you're advanced enough to have an opinion about nano,
you're advanced enough to know how to use a different editor.
Do you like animals? I do!
Can you guess my favorite animal?
```
Read our advice Now,
--------------- open http://localhost:8080/ in a web browser.
You should see a "test-category" category,
with a single 1-point puzzle.
Click that puzzle,
and you should see your animal puzzle presented.
The two answers we're accepting can be improved,
because some people might use the plural.
Edit `puzzle.md` to add two new answers:
`Elephants` and `elephants`.
Reload the web page,
and check the debug section to verify that all four answers are now accepted.
Next Steps
==========
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 to [get started](getting-started.md) and you're ready for [getting started](getting-started.md)
with the production server. with the production server.

View File

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

View File

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

View File

@ -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` | `participantID` | `teamID` | `category` | `points` | `extra`... | | `timestamp` | `event` | `teamID` | `category` | `points` | `extra`... |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| int | string | string | string | string | int | string... | | int | string | string | string | int | string... |
| Unix epoch | Event type | Participant's (hopefully) unique ID | Team's unique ID | Name of category, if any | Points awarded, if any | Additional fields, if any | | Unix epoch | Event type | Team's unique ID | Name of category, if any | Points awarded, if any | Additional fields, if any |
Fields after `points` contain extra fields associated with the event. Fields after `points` contain extra fields associated with the event.

50
docs/metadata.md Normal file
View File

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

View File

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

183
docs/puzzle-format.md Normal file
View File

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

73
docs/scoring.md Normal file
View File

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

View File

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

View File

@ -0,0 +1 @@
Boop!

View File

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

16
go.mod
View File

@ -1,11 +1,15 @@
module github.com/dirtbags/moth module github.com/dirtbags/moth/v4
go 1.13 go 1.21
require ( require (
github.com/go-redis/redis/v8 v8.11.4 github.com/spf13/afero v1.8.2
github.com/kr/text v0.2.0 // indirect github.com/yuin/goldmark v1.4.13
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
View File

@ -1,121 +1,445 @@
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= cloud.google.com/go v0.26.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.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.9.0/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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
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-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.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.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.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/go-cmp v0.5.5/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.6/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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
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/psanford/memfs v0.0.0-20210214183328-a001468d78ef h1:NKxTG6GVGbfMXc2mIk+KphcH6hagbVXhcFkbTgYleTI= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/psanford/memfs v0.0.0-20210214183328-a001468d78ef/go.mod h1:tcaRap0jS3eifrEEllL6ZMd9dg8IlDpi2S1oARrQ+NI= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/spf13/afero v1.4.0 h1:jsLTaI1zwYO3vjrzHalkVcIHXTNmdQFepW4OI8H3+x8= github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
github.com/spf13/afero v1.4.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
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/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-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/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-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-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/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-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/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/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-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-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-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-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/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.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/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.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
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=

View File

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

View File

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

View File

@ -1,51 +0,0 @@
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")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,39 +0,0 @@
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"))
}
}

View File

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

View File

@ -1,26 +0,0 @@
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")
}
}

View File

@ -1 +0,0 @@
moo.

View File

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

72
pkg/transpile/basepath.go Normal file
View File

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

View File

@ -5,7 +5,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"log" "log"
"os/exec" "os/exec"
"path" "path"
@ -13,7 +12,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/dirtbags/moth/pkg/namesubfs"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -30,20 +28,41 @@ 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(fsys fs.FS, cat string) Category { func NewFsCategory(fs afero.Fs, cat string) Category {
bfs := namesubfs.Sub(fsys, cat) bfs := NewRecursiveBasePathFs(fs, cat)
if info, err := fs.Stat(bfs, "mkcategory"); (err == nil) && (info.Mode()&0100 != 0) { if info, err := bfs.Stat("mkcategory"); (err == nil) && (info.Mode()&0100 != 0) {
return FsCommandCategory{ if command, err := bfs.RealPath(info.Name()); err != nil {
fs: bfs, log.Println("Unable to resolve full path to", info.Name())
command: bfs.FullPath(info.Name()), } else {
timeout: 2 * time.Second, return FsCommandCategory{
fs: bfs,
command: command,
timeout: 2 * time.Second,
}
} }
} }
return FsCategory{fs: bfs} return FsCategory{fs: bfs}
@ -81,6 +100,11 @@ 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.
@ -153,7 +177,13 @@ func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) {
return p, nil return p, nil
} }
// Answer checks whether an answer is correct.Open // Open returns an io.ReadCloser for the given filename.
func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) {
stdout, err := c.run("file", strconv.Itoa(points), filename)
return nopCloser{bytes.NewReader(stdout)}, err
}
// Answer checks whether an answer is correct.
func (c FsCommandCategory) Answer(points int, answer string) bool { 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 {

View File

@ -3,10 +3,11 @@ 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) {
@ -32,9 +33,7 @@ func TestFsCategory(t *testing.T) {
t.Error("Incorrect answer accepted as correct") t.Error("Incorrect answer accepted as correct")
} }
if p, err := c.Puzzle(1); err != nil { if r, err := c.Open(1, "moo.txt"); 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 {
@ -55,8 +54,8 @@ func TestFsCategory(t *testing.T) {
} }
func TestOsFsCategory(t *testing.T) { func TestOsFsCategory(t *testing.T) {
fsys := os.DirFS("testdata") fs := NewRecursiveBasePathFs(afero.NewOsFs(), "testdata")
static := NewFsCategory(fsys, "static") static := NewFsCategory(fs, "static")
if p, err := static.Puzzle(1); err != nil { if p, err := static.Puzzle(1); err != nil {
t.Error(err) t.Error(err)
@ -72,7 +71,7 @@ func TestOsFsCategory(t *testing.T) {
t.Error("Wrong authors", p.Authors) t.Error("Wrong authors", p.Authors)
} }
generated := NewFsCategory(fsys, "generated") generated := NewFsCategory(fs, "generated")
if inv, err := generated.Inventory(); err != nil { if inv, err := generated.Inventory(); err != nil {
t.Error(err) t.Error(err)

View File

@ -4,12 +4,11 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"crypto/sha256" "crypto/sha1"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"net/mail" "net/mail"
"os" "os"
@ -19,7 +18,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dirtbags/moth/pkg/namesubfs" "github.com/spf13/afero"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
@ -38,27 +37,50 @@ type PuzzleDebug struct {
Summary string Summary string
} }
// PuzzleMetadata contains everything about a puzzle that a client would see. // Puzzle contains everything about a puzzle that a client will see.
type PuzzleMetadata struct { type Puzzle struct {
Debug PuzzleDebug // Debug contains debugging information, omitted in mothballs
Authors []string Debug PuzzleDebug
Attachments []string
Scripts []string // Authors names all authors of this puzzle
Body string Authors []string
// Attachments is a list of filenames used by this puzzle
Attachments []string
// Scripts is a list of EMCAScript files needed by the client for this puzzle
Scripts []string
// Body is the HTML rendering of this puzzle
Body string
// AnswerPattern contains the pattern (regular expression?) used to match valid answers
AnswerPattern string AnswerPattern string
AnswerHashes []string
Objective string
KSAs []string
Success struct {
Acceptable string
Mastery string
}
// Answers will be empty in a mothball // AnswerHashes contains hashes of all answers for this puzzle
AnswerHashes []string
// Answers lists all acceptable answers, omitted in mothballs
Answers []string Answers []string
}
type Puzzle interface { // 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
// Mastery describes the work required to be considered mastering this puzzle's concepts
Mastery string
}
} }
func (puzzle *Puzzle) computeAnswerHashes() { func (puzzle *Puzzle) computeAnswerHashes() {
@ -67,9 +89,9 @@ func (puzzle *Puzzle) computeAnswerHashes() {
} }
puzzle.AnswerHashes = make([]string, len(puzzle.Answers)) puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
for i, answer := range puzzle.Answers { for i, answer := range puzzle.Answers {
sum := sha256.Sum256([]byte(answer)) sum := sha1.Sum([]byte(answer))
hexsum := fmt.Sprintf("%x", sum) hexsum := fmt.Sprintf("%x", sum)
puzzle.AnswerHashes[i] = hexsum puzzle.AnswerHashes[i] = hexsum[:4]
} }
} }
@ -79,14 +101,15 @@ 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.
@ -115,27 +138,35 @@ 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) (fs.File, error) Open(filename string) (ReadSeekCloser, 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(fsys fs.FS) (PuzzleProvider, error) { func NewFsPuzzle(fs afero.Fs) PuzzleProvider {
var command string var command string
if bfs, err := namesubfs.Sub(fsys, ""); err != nil { bfs := NewRecursiveBasePathFs(fs, "")
return nil, err if info, err := bfs.Stat("mkpuzzle"); !os.IsNotExist(err) {
} else if info, err := fs.Stat(bfs, "mkpuzzle"); !os.IsNotExist(err) {
if (info.Mode() & 0100) != 0 { if (info.Mode() & 0100) != 0 {
command = bfs.FullName(info.Name()) if command, err = bfs.RealPath(info.Name()); err != nil {
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.")
} }
@ -143,27 +174,26 @@ func NewFsPuzzle(fsys fs.FS) (PuzzleProvider, error) {
if command != "" { if command != "" {
return FsCommandPuzzle{ return FsCommandPuzzle{
fs: fsys, fs: fs,
command: command, command: command,
timeout: 2 * time.Second, timeout: 2 * time.Second,
}, nil }
} }
return FsPuzzle{ return FsPuzzle{
fs: fsys, fs: fs,
}, nil }
} }
// NewFsPuzzlePoints returns a new FsPuzzle for points. // NewFsPuzzlePoints returns a new FsPuzzle for points.
func NewFsPuzzlePoints(fs fs.FS, points int) PuzzleProvider { func NewFsPuzzlePoints(fs afero.Fs, points int) PuzzleProvider {
subfs, _ := namesubfs.Sub(fs, strconv.Itoa(points)) return NewFsPuzzle(NewRecursiveBasePathFs(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 fs.FS fs afero.Fs
mkpuzzle bool mkpuzzle bool
} }
@ -180,6 +210,7 @@ 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
@ -217,7 +248,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)
@ -303,7 +334,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 {
@ -334,7 +365,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)
} }
} }
@ -357,7 +388,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 fs.FS fs afero.Fs
command string command string
timeout time.Duration timeout time.Duration
} }

View File

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

175
theme/background.mjs Normal file
View File

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

View File

@ -1,132 +1,182 @@
/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */ /* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
:root {
--bg: #010e19;
--fg: #edd488;
--bg-main: #000d;
--heading: #cb2408cc;
--bg-heading1: #cb240844;
--fg-link: #b9cbd8;
--bg-input: #ccc4;
--bg-input-hover: #8884;
--bg-notification: #ac8f3944;
--bg-error: #f00;
--fg-error: white;
--bg-category: #ccc4;
--bg-input-invalid: #800;
--fg-input-invalid: white;
--bg-mothball: #ccc;
--bg-debug: #cccc;
--fg-debug: black;
--bg-toast: #333;
--fg-toast: #eee;
--box-toast: #0b0;
}
@media (prefers-color-scheme: light) {
/* We uses the alpha channel to apply hue tinting to elements, to get a
* similar effect in light or dark mode. That means there aren't a whole lot of
* things to change between light and dark mode.
*/
:root {
--bg: #b9cbd8;
--fg: black;
--bg-main: #fffd;
--fg-link: #092b45;
}
}
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;
background: #282a33; margin: 1em auto;
color: #f6efdc; padding: 1px 3px;
border-radius: 5px;
background: var(--bg-main);
} }
body.wide { h1, h2, h3, h4, h5, h6 {
max-width: 100%; color: var(--heading);
}
a:any-link {
color: #8b969a;
} }
h1 { h1 {
background: #5e576b; background: var(--bg-heading1);
color: #9e98a8; padding: 3px;
}
.Fail, .Error, #messages {
background: #3a3119;
color: #ffcc98;
}
.Fail:before {
content: "Fail: ";
}
.Error:before {
content: "Error: ";
} }
p { p {
margin: 1em 0em; margin: 1em 0em;
} }
a:any-link {
color: 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;
} }
nav { input {
border: solid black 2px; background-color: var(--bg-input);
color: inherit;
}
input:hover {
background-color: var(--bg-input-hover);
}
input:active {
background-color: inherit;
}
.notification, .error {
padding: 0 1em;
border-radius: 8px;
}
.notification {
background: var(--bg-notification);
}
.error {
background: var(--bg-error);
color: var(--fg-error);
}
.hidden {
display: none;
}
/** Puzzles list */
.category {
margin: 5px 0;
background: var(--bg-category);
}
.category h2 {
margin: 0 0.2em;
}
.category .solved {
text-decoration: line-through;
} }
nav ul, .category ul { nav ul, .category ul {
padding: 1em; margin: 0;
padding: 0.2em 1em;
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
} }
nav li, .category li { nav li, .category li {
display: inline; display: inline;
margin: 1em;
} }
iframe#body { .category li.entitled {
border: inherit; flex-basis: 100%;
width: 100%;
} }
img { .mothball {
float: right;
text-decoration: none;
border-radius: 5px;
background: var(--bg-mothball);
padding: 4px 8px;
margin: 5px;
}
/** Puzzle content */
#puzzle {
border-bottom: solid;
padding: 0 0.5em;
}
#puzzle img {
max-width: 100%; max-width: 100%;
} }
input:invalid { input:invalid {
border-color: red; background-color: var(--bg-input-invalid);
color: var(--fg-input-invalid);
} }
#messages { .answer_ok {
min-height: 3em; cursor: help;
border: solid black 2px;
}
#rankings {
width: 100%;
position: relative;
} }
#rankings span { /** Development mode information */
font-size: 75%; .debug {
display: inline-block; overflow: auto;
overflow: hidden; padding: 1em;
height: 1.7em; border-radius: 10px;
margin: 2em auto;
background: var(--bg-debug);
color: var(--fg-debug);
} }
#rankings span.teamname { .debug dt {
font-size: inherit; font-weight: bold;
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;
@ -144,6 +194,31 @@ 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 Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

88
theme/common.mjs Normal file
View File

@ -0,0 +1,88 @@
/**
* 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,
}

21
theme/config.json Normal file
View File

@ -0,0 +1,21 @@
{
"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"
}

BIN
theme/fonts/Go-Regular.ttf Normal file

Binary file not shown.

View File

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

203
theme/index.mjs Normal file
View File

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

View File

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

View File

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

1
theme/moment.min.js vendored

File diff suppressed because one or more lines are too long

View File

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

684
theme/moth.mjs Normal file
View File

@ -0,0 +1,684 @@
/**
* 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 Normal file
View File

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

View File

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

View File

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

271
theme/puzzle.mjs Normal file
View File

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

48340
theme/reports/NICEFramework2017.json Executable file

File diff suppressed because it is too large Load Diff

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

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

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

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

19
theme/scoreboard-all.html Normal file
View File

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

120
theme/scoreboard.css Normal file
View File

@ -0,0 +1,120 @@
/* GHC displays: 1024x1820 */
@media screen and (max-aspect-ratio: 4/5) and (min-height: 1600px) {
html {
font-size: 20pt;
}
}
.location {
color: #acf;
background-color: #0008;
position: fixed;
right: 30vw;
bottom: 0;
padding: 1em;
margin: 0;
font-size: 1.2rem;
font-weight:bold;
text-decoration: underline;
}
.no-scores {
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
min-height: calc(100vh - 2em);
}
.no-scores.hidden {
display: none;
}
.no-scores img {
object-fit: cover;
max-height: 60vh;
}
/* Only the first child of a rotate class is visible */
.rotate > div:nth-child(n + 2) {
display: none;
}
/** Scoreboard */
.rankings.classic {
width: 100%;
position: relative;
background-color: #000c;
}
.rankings.classic div {
height: 1.2rem;
display: flex;
align-items: center;
}
.rankings.classic div:nth-child(6n){
background-color: #ccc3;
}
.rankings.classic div:nth-child(6n+3) {
background-color: #0f03;
}
.rankings.classic span {
display: inline-block;
overflow: hidden;
}
.rankings.classic span.category {
font-size: 80%;
}
.rankings.classic span.teamname {
height: auto;
font-size: inherit;
color: white;
background-color: #000e;
border-radius: 3px;
position: absolute;
right: 0.2em;
}
.rankings.classic span.teamname:hover,
.rankings.classic span.category:hover {
width: inherit;
max-width: 100%;
}
.topscore::before {
content: "✩";
font-size: 75%;
vertical-align: top;
}
.rankings.category {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
}
.rankings.category div {
border: solid black 2px;
min-width: 15em;
}
.rankings.category table {
width: 100%;
}
.rankings.category td.number {
text-align: right;
}
@media only screen and (max-width: 450px) {
.rankings.classic span.teamname {
max-width: 6em;
text-overflow: ellipsis;
}
span.teampoints {
max-width: 80%;
}
}
.rankings div * {white-space: nowrap;}
.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;}
.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;}
.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;}
.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;}
.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;}
.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;}
.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}

View File

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

View File

@ -1,251 +0,0 @@
// jshint asi:true
function scoreboardInit() {
chartColors = [
"rgb(255, 99, 132)",
"rgb(255, 159, 64)",
"rgb(255, 205, 86)",
"rgb(75, 192, 192)",
"rgb(54, 162, 235)",
"rgb(153, 102, 255)",
"rgb(201, 203, 207)"
]
function update(state) {
window.state = state
for (let rotate of document.querySelectorAll(".rotate")) {
rotate.appendChild(rotate.firstElementChild)
}
let element = document.getElementById("rankings")
let teamNames = state.TeamNames
let pointsLog = state.PointsLog
// Every machine that's displaying the scoreboard helpfully stores the last 20 values of
// points.json for us, in case of catastrophe. Thanks, y'all!
//
// We have been doing some variation on this "everybody backs up the server state" trick since 2009.
// We have needed it 0 times.
let stateHistory = JSON.parse(localStorage.getItem("stateHistory")) || []
if (stateHistory.length >= 20) {
stateHistory.shift()
}
stateHistory.push(state)
localStorage.setItem("stateHistory", JSON.stringify(stateHistory))
let teams = {}
let highestCategoryScore = {} // map[string]int
// Initialize data structures
for (let teamId in teamNames) {
teams[teamId] = {
categoryScore: {}, // map[string]int
overallScore: 0, // int
historyLine: [], // []{x: int, y: int}
name: teamNames[teamId],
id: teamId
}
}
// Dole out points
for (let entry of pointsLog) {
let timestamp = entry[0]
let teamId = entry[1]
let category = entry[2]
let points = entry[3]
let team = teams[teamId]
let score = team.categoryScore[category] || 0
score += points
team.categoryScore[category] = score
let highest = highestCategoryScore[category] || 0
if (score > highest) {
highestCategoryScore[category] = score
}
}
for (let teamId in teamNames) {
teams[teamId].categoryScore = {}
}
for (let entry of pointsLog) {
let timestamp = entry[0]
let teamId = entry[1]
let category = entry[2]
let points = entry[3]
let team = teams[teamId]
let score = team.categoryScore[category] || 0
score += points
team.categoryScore[category] = score
let overall = 0
for (let cat in team.categoryScore) {
overall += team.categoryScore[cat] / highestCategoryScore[cat]
}
team.historyLine.push({t: new Date(timestamp * 1000), y: overall})
}
// Compute overall scores based on current highest
for (let teamId in teams) {
let team = teams[teamId]
team.overallScore = 0
for (let cat in team.categoryScore) {
team.overallScore += team.categoryScore[cat] / highestCategoryScore[cat]
}
}
// Sort by team score
function teamCompare(a, b) {
return a.overallScore - b.overallScore
}
// Figure out how to order each team on the scoreboard
let winners = []
for (let teamId in teams) {
winners.push(teams[teamId])
}
winners.sort(teamCompare)
winners.reverse()
// Let's make some better names for things we've computed
let winningScore = winners[0].overallScore
let numCategories = Object.keys(highestCategoryScore).length
// Clear out the element we're about to populate
Array.from(element.childNodes).map(e => e.remove())
let maxWidth = 100 / winningScore
for (let team of winners) {
let row = document.createElement("div")
let ncat = 0
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()
}

183
theme/scoreboard.mjs Normal file
View File

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

4
theme/test/index.css Normal file
View File

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

10
theme/test/index.html Normal file
View File

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

42
theme/test/puzzle.html Normal file
View File

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

BIN
theme/test/salad.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
theme/test/salad2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

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

48
theme/token.mjs Normal file
View File

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

View File

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

View File

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