Compare commits

...

110 Commits

Author SHA1 Message Date
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 b6eea388d9 Remove github workflow 2021-11-05 14:30:06 -06:00
Neale Pickett bb41697ba6 v4.4.6 2021-10-26 13:33:57 -06:00
Neale Pickett 471ded7303 Cache state 2021-10-26 12:48:23 -06:00
Neale Pickett 4bb6819319 Fix to reopening all mothballs every 2s
#180
2021-10-25 13:37:56 -06:00
Neale Pickett ace940ba12 further update events.csv description 2021-10-21 18:25:49 -06:00
Neale Pickett 2003b20cc4 fix documentation error 2021-10-21 17:40:40 -06:00
Neale Pickett 40f8f71778 oops, add in dockerhub repo to ci.sh 2021-10-20 14:49:40 -06:00
Neale Pickett a2ce3682ab Push images to docker hub, but say to use ghcr 2021-10-20 14:47:32 -06:00
Neale Pickett 127beca1fc Remove superfluous CI build script line in 2021-10-20 14:35:21 -06:00
Neale Pickett e15a505d7b v4.4.4. Sigh. 2021-10-20 14:27:32 -06:00
Neale Pickett e1e9157841 v4.3.3? 2021-10-20 14:22:30 -06:00
Neale Pickett 6e5e2c3adf v4.3.3 release 2021-10-20 14:22:21 -06:00
Neale Pickett 6f1f889be7 Attempt to reproduce #154 2021-10-20 13:10:24 -06:00
Neale Pickett ce037ebca3 Stop pushing images from Github 2021-10-20 11:43:48 -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
Neale Pickett d51e4c2504 CI 2021-10-19 20:17:22 -06:00
Neale Pickett 62b043354b CI 2021-10-19 20:12:36 -06:00
Neale Pickett fd6d319218 CI 2021-10-19 20:11:15 -06:00
Neale Pickett 781217d2ef CI 2021-10-19 20:09:51 -06:00
Neale Pickett 2952c1b21c CI 2021-10-19 20:05:02 -06:00
Neale Pickett 4934396936 CI 2021-10-19 20:03:54 -06:00
Neale Pickett b5d4ab5c15 CI 2021-10-19 20:01:27 -06:00
Neale Pickett e2e7d37300 CI 2021-10-19 19:57:35 -06:00
Neale Pickett de1fdc0691 CI 2021-10-19 19:55:42 -06:00
Neale Pickett 31fd9c2fab CI 2021-10-19 18:53:18 -06:00
Neale Pickett c0092751b5 CI 2021-10-19 18:52:19 -06:00
Neale Pickett 79f58ff83c try to spiff up the CI build 2021-10-19 18:49:46 -06:00
Neale Pickett c743148eeb CI 2021-10-19 18:45:43 -06:00
Neale Pickett 7a672d2fcf CI 2021-10-19 17:36:35 -06:00
Neale Pickett 61e09eaa71 CI 2021-10-19 17:33:22 -06:00
Neale Pickett 30dcda05e4 CI 2021-10-19 17:24:08 -06:00
Neale Pickett e64e4dfa67 CI 2021-10-19 17:22:29 -06:00
Neale Pickett 362ba11cf5 CI 2021-10-19 17:22:11 -06:00
Neale Pickett e05bc90b3a CI/CD 2021-10-18 19:22:49 -06:00
Neale Pickett 54442ed1b9 CI/CD 2021-10-18 19:21:49 -06:00
Neale Pickett c477c1d2ea CI/CD 2021-10-18 19:09:58 -06:00
Neale Pickett 0d7a8fc935 CI/CD 2021-10-18 19:05:17 -06:00
Neale Pickett 16a02b93ab CI/CD 2021-10-18 19:03:12 -06:00
Neale Pickett ec9b2e2772 CI/CD 2021-10-18 18:59:15 -06:00
Neale Pickett 4dbeaac0bf CI/CD 2021-10-18 18:52:05 -06:00
Neale Pickett 5adf32f456 CI/CD 2021-10-18 18:30:11 -06:00
Neale Pickett a585afdd8d CI/CD 2021-10-18 18:22:00 -06:00
Neale Pickett d9299f5e59 Fix test failure 2021-10-14 19:17:38 -06:00
Neale Pickett 41a0e6dffc Prepend timestamp to award filenames
Fixes #168
2021-10-14 19:01:12 -06:00
Neale Pickett 454e643886 Fix broken build? 2021-10-14 15:57:17 -06:00
Neale Pickett 46fea903a6 Remove debug log message 2021-10-13 18:35:33 -06:00
Neale Pickett f68201ab53 Check for new mothballs with the same name
Also updated some error messages to pass newer linter.

Fixes #172
2021-10-13 18:25:27 -06:00
Neale Pickett dd8ca81186 Test case for #144 2021-10-13 22:43:51 +00:00
Neale Pickett d36c0da5bf Minor version release 2021-09-30 22:50:32 +00:00
57 changed files with 51597 additions and 1341 deletions

View File

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

49
.gitlab-ci.yml Normal file
View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,9 +11,10 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/dirtbags/moth/pkg/award"
"github.com/dirtbags/moth/v4/pkg/award"
"github.com/spf13/afero"
)
@ -27,7 +28,7 @@ const DistinguishableChars = "34678abcdefhikmnpqrtwxy="
const RFC3339Space = "2006-01-02 15:04:05Z07:00"
// ErrAlreadyRegistered means a team cannot be registered because it was registered previously.
var ErrAlreadyRegistered = errors.New("Team ID has already been registered")
var ErrAlreadyRegistered = errors.New("team ID has already been registered")
// State defines the current state of a MOTH instance.
// We use the filesystem for synchronization between threads.
@ -38,10 +39,18 @@ type State struct {
// Enabled tracks whether the current State system is processing updates
Enabled bool
enabledWhy string
refreshNow chan bool
eventStream chan []string
eventWriter *csv.Writer
eventWriterFile afero.File
// Caches, so we're not hammering NFS with metadata operations
teamNamesLastChange time.Time
teamNames map[string]string
pointsLog award.List
messages string
lock sync.RWMutex
}
// NewState returns a new State struct backed by the given Fs
@ -51,6 +60,8 @@ func NewState(fs afero.Fs) *State {
Enabled: true,
refreshNow: make(chan bool, 5),
eventStream: make(chan []string, 80),
teamNames: make(map[string]string),
}
if err := s.reopenEventLog(); err != nil {
log.Fatal(err)
@ -61,11 +72,10 @@ func NewState(fs afero.Fs) *State {
// updateEnabled checks a few things to see if this state directory is "enabled".
func (s *State) updateEnabled() {
nextEnabled := true
why := "`state/enabled` present, `state/hours.txt` missing"
why := "state/hours.txt has no timestamps before now"
if untilFile, err := s.Open("hours.txt"); err == nil {
defer untilFile.Close()
why = "`state/hours.txt` present"
scanner := bufio.NewScanner(untilFile)
for scanner.Scan() {
@ -85,59 +95,64 @@ func (s *State) updateEnabled() {
case '#':
continue
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)
until, err := time.Parse(time.RFC3339, line)
if err != nil {
until, err = time.Parse(RFC3339Space, line)
}
if err != nil {
log.Println("Suspended: Unparseable until date:", line)
until := time.Time{}
if len(line) == 0 {
// Let it stay as zero time, so it's always before now
} else if until, err = time.Parse(time.RFC3339, line); err == nil {
// Great, it was RFC 3339
} else if until, err = time.Parse(RFC3339Space, line); err == nil {
// Great, it was RFC 3339 with a space instead of a 'T'
} else {
log.Println("state/hours.txt has bad timestamp:", line)
continue
}
if until.Before(time.Now()) {
nextEnabled = thisEnabled
why = fmt.Sprint("state/hours.txt most recent timestamp:", line)
}
}
}
if _, err := s.Stat("enabled"); os.IsNotExist(err) {
nextEnabled = false
why = "`state/enabled` missing"
}
if nextEnabled != s.Enabled {
if (nextEnabled != s.Enabled) || (why != s.enabledWhy) {
s.Enabled = nextEnabled
log.Printf("Setting enabled=%v: %s", s.Enabled, why)
s.enabledWhy = why
log.Printf("Setting enabled=%v: %s", s.Enabled, s.enabledWhy)
if s.Enabled {
s.LogEvent("enabled", "", "", "", 0, why)
s.LogEvent("enabled", "", "", 0, s.enabledWhy)
} else {
s.LogEvent("disabled", "", "", "", 0, why)
s.LogEvent("disabled", "", "", 0, s.enabledWhy)
}
}
}
// TeamName returns team name given a team ID.
func (s *State) TeamName(teamID string) (string, error) {
teamFs := afero.NewBasePathFs(s.Fs, "teams")
teamNameBytes, err := afero.ReadFile(teamFs, teamID)
if os.IsNotExist(err) {
return "", fmt.Errorf("Unregistered team ID: %s", teamID)
} else if err != nil {
return "", fmt.Errorf("Unregistered team ID: %s (%s)", teamID, err)
s.lock.RLock()
name, ok := s.teamNames[teamID]
s.lock.RUnlock()
if !ok {
return "", fmt.Errorf("unregistered team ID: %s", teamID)
}
teamName := strings.TrimSpace(string(teamNameBytes))
return teamName, nil
return name, nil
}
// SetTeamName writes out team name.
// This can only be done once per team.
func (s *State) SetTeamName(teamID, teamName string) error {
s.lock.RLock()
_, ok := s.teamNames[teamID]
s.lock.RUnlock()
if ok {
return ErrAlreadyRegistered
}
idsFile, err := s.Open("teamids.txt")
if err != nil {
return fmt.Errorf("Team IDs file does not exist"