diff --git a/CHANGELOG.md b/CHANGELOG.md index 2942bb8..939dd2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,14 @@ 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.0.0] - Unreleased +## [v4.0.0-rc2] - Unreleased +### Fixed +- Multiple bugs preventing production server from working properly +- CI builds should be working now +- Team registration now correctly writes names to files +- Anonymized team names now only computed once per team + +## [v4.0-rc1] - 2020-10-13 ### Changed - Major rewrite/refactor of `mothd` - Clear separation of roles: State, Puzzles, and Theme @@ -37,14 +44,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -## [Unreleased] -### Changed - - Endpoints `/points.json`, `/puzzles.json`, and `/messages.html` (optional theme file) combine into `/state` - - No more `__devel__` category for dev server: this is now `.config.devel` in the `/state` endpoint - - Development server no longer serves a static `/` with links: it now redirects you to a randomly-generated seed URL - - Default theme modifications to handle all this - - Default theme now automatically "logs you in" with Team ID if it's getting state from the devel server - ## [v3.5.1] - 2020-03-16 ### Fixed - Support insta-checking for legacy puzzles diff --git a/cmd/mothd/httpd.go b/cmd/mothd/httpd.go index 66eb6f4..87f8acf 100644 --- a/cmd/mothd/httpd.go +++ b/cmd/mothd/httpd.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "log" "net/http" "strconv" @@ -109,7 +110,15 @@ func (h *HTTPServer) StateHandler(mh MothRequestHandler, w http.ResponseWriter, // RegisterHandler handles attempts to register a team func (h *HTTPServer) RegisterHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) { teamName := req.FormValue("name") - if err := mh.Register(teamName); err != nil { + teamName = strings.TrimSpace(teamName) + if teamName == "" { + jsend.Sendf(w, jsend.Fail, "empty name", "Team name may not be empty") + return + } + + if err := mh.Register(teamName); err == ErrAlreadyRegistered { + 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") @@ -171,11 +180,12 @@ func (h *HTTPServer) MothballerHandler(mh MothRequestHandler, w http.ResponseWri // parts[0] == "mothballer" filename := parts[1] cat := strings.TrimSuffix(filename, ".mb") - mothball, err := mh.Mothball(cat) - if err != nil { + mb := new(bytes.Buffer) + if err := mh.Mothball(cat, mb); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - http.ServeContent(w, req, filename, time.Now(), mothball) + mbReader := bytes.NewReader(mb.Bytes()) + http.ServeContent(w, req, filename, time.Now(), mbReader) } diff --git a/cmd/mothd/httpd_test.go b/cmd/mothd/httpd_test.go index 6bd66dc..f8bbb5b 100644 --- a/cmd/mothd/httpd_test.go +++ b/cmd/mothd/httpd_test.go @@ -50,8 +50,8 @@ func TestHttpd(t *testing.T) { if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 { t.Error(r.Result()) - } else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{"self":""},"PointsLog":[],"Puzzles":{"pategory":[1]}}` { - t.Error("Unexpected state") + } else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{},"PointsLog":[],"Puzzles":{}}` { + t.Error("Unexpected state", r.Body.String()) } if r := hs.TestRequest("/register", map[string]string{"id": "bad team id", "name": "GoTeam"}); r.Result().StatusCode != 200 { @@ -66,6 +66,12 @@ func TestHttpd(t *testing.T) { 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"}}` { + t.Error("Register failed", r.Body.String()) + } + 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]}}` { @@ -108,6 +114,10 @@ func TestHttpd(t *testing.T) { time.Sleep(TestMaintenanceInterval) + if r := hs.TestRequest("/content/pategory/2/puzzle.json", nil); r.Result().StatusCode != 200 { + t.Error(r.Result()) + } + state := StateExport{} if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 { t.Error(r.Result()) diff --git a/cmd/mothd/mothballs.go b/cmd/mothd/mothballs.go index deab589..8d44a0e 100644 --- a/cmd/mothd/mothballs.go +++ b/cmd/mothd/mothballs.go @@ -3,7 +3,6 @@ package main import ( "archive/zip" "bufio" - "bytes" "fmt" "io" "log" @@ -52,7 +51,7 @@ func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekClose return nil, time.Time{}, fmt.Errorf("No such category: %s", cat) } - f, err := zc.Open(fmt.Sprintf("content/%d/%s", points, filename)) + f, err := zc.Open(fmt.Sprintf("%d/%s", points, filename)) if err != nil { return nil, time.Time{}, err } @@ -174,8 +173,8 @@ func (m *Mothballs) refresh() { } // Mothball just returns an error -func (m *Mothballs) Mothball(cat string) (*bytes.Reader, error) { - return nil, fmt.Errorf("Can't repackage a compiled mothball") +func (m *Mothballs) Mothball(cat string, w io.Writer) error { + return fmt.Errorf("Refusing to repackage a compiled mothball") } // Maintain performs housekeeping for Mothballs. diff --git a/cmd/mothd/mothballs_test.go b/cmd/mothd/mothballs_test.go index e32ca17..9b75838 100644 --- a/cmd/mothd/mothballs_test.go +++ b/cmd/mothd/mothballs_test.go @@ -13,12 +13,12 @@ var testFiles = []struct { }{ {"puzzles.txt", "1\n3\n2\n"}, {"answers.txt", "1 answer123\n1 answer456\n2 wat\n"}, - {"content/1/puzzle.json", `{"name": "moo"}`}, - {"content/1/moo.txt", `moo`}, - {"content/2/puzzle.json", `{}`}, - {"content/2/moo.txt", `moo`}, - {"content/3/puzzle.json", `{}`}, - {"content/3/moo.txt", `moo`}, + {"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) { diff --git a/cmd/mothd/server.go b/cmd/mothd/server.go index a355cf7..0b35618 100644 --- a/cmd/mothd/server.go +++ b/cmd/mothd/server.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "fmt" "io" "strconv" @@ -42,7 +41,7 @@ type PuzzleProvider interface { Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) Inventory() []Category CheckAnswer(cat string, points int, answer string) (bool, error) - Mothball(cat string) (*bytes.Reader, error) + Mothball(cat string, w io.Writer) error Maintainer } @@ -108,7 +107,7 @@ type MothRequestHandler struct { // PuzzlesOpen opens a file associated with a puzzle. // BUG(neale): Multiple providers with the same category name are not detected or handled well. func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r ReadSeekCloser, ts time.Time, err error) { - export := mh.ExportState() + export := mh.exportStateIfRegistered(true) found := false for _, p := range export.Puzzles[cat] { if p == points { @@ -116,7 +115,7 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) ( } } if !found { - return nil, time.Time{}, fmt.Errorf("Category not found") + return nil, time.Time{}, fmt.Errorf("Puzzle does not exist or is locked") } // Try every provider until someone doesn't return an error @@ -173,27 +172,37 @@ func (mh *MothRequestHandler) Register(teamName string) error { // the anonymized team name for this teamID has the special value "self". // If not, the puzzles list is empty. func (mh *MothRequestHandler) ExportState() *StateExport { + return mh.exportStateIfRegistered(false) +} + +func (mh *MothRequestHandler) exportStateIfRegistered(override bool) *StateExport { export := StateExport{} export.Config = mh.Config - teamName, _ := mh.State.TeamName(mh.teamID) + teamName, err := mh.State.TeamName(mh.teamID) + registered := override || (err == nil) export.Messages = mh.State.Messages() - export.TeamNames = map[string]string{"self": teamName} + export.TeamNames = make(map[string]string) // Anonymize team IDs in points log, and write out team names pointsLog := mh.State.PointsLog() - exportIDs := map[string]string{mh.teamID: "self"} - maxSolved := map[string]int{} + exportIDs := make(map[string]string) + maxSolved := make(map[string]int) export.PointsLog = make(award.List, len(pointsLog)) + + if registered { + export.TeamNames["self"] = teamName + exportIDs[mh.teamID] = "self" + } for logno, awd := range pointsLog { if id, ok := exportIDs[awd.TeamID]; ok { awd.TeamID = id } else { exportID := strconv.Itoa(logno) name, _ := mh.State.TeamName(awd.TeamID) + exportIDs[awd.TeamID] = exportID awd.TeamID = exportID - exportIDs[awd.TeamID] = awd.TeamID export.TeamNames[exportID] = name } export.PointsLog[logno] = awd @@ -205,11 +214,10 @@ func (mh *MothRequestHandler) ExportState() *StateExport { } export.Puzzles = make(map[string][]int) - if _, ok := export.TeamNames["self"]; ok { + if registered { // We used to hand this out to everyone, // but then we got a bad reputation on some secretive blacklist, // and now the Navy can't register for events. - for _, provider := range mh.PuzzleProviders { for _, category := range provider.Inventory() { // Append sentry (end of puzzles) @@ -233,14 +241,16 @@ func (mh *MothRequestHandler) ExportState() *StateExport { } // Mothball generates a mothball for the given category. -func (mh *MothRequestHandler) Mothball(cat string) (r *bytes.Reader, err error) { +func (mh *MothRequestHandler) Mothball(cat string, w io.Writer) error { + var err error + if !mh.Config.Devel { - return nil, fmt.Errorf("Cannot mothball in production mode") + return fmt.Errorf("Cannot mothball in production mode") } for _, provider := range mh.PuzzleProviders { - if r, err = provider.Mothball(cat); err == nil { - return r, nil + if err = provider.Mothball(cat, w); err == nil { + return nil } } - return nil, err + return err } diff --git a/cmd/mothd/server_test.go b/cmd/mothd/server_test.go index 2c5f732..4b0df96 100644 --- a/cmd/mothd/server_test.go +++ b/cmd/mothd/server_test.go @@ -34,9 +34,28 @@ func TestServer(t *testing.T) { server := NewTestServer() handler := server.NewHandler(participantID, teamID) + anonHandler := server.NewHandler("badParticipantId", "badTeamId") + + { + es := handler.ExportState() + if es.Config.Devel { + t.Error("Marked as development server", es.Config) + } + if len(es.Puzzles) != 0 { + t.Log("State", es) + t.Error("Unauthenticated state has non-empty puzzles list") + } + } + if err := handler.Register(teamName); err != nil { t.Error(err) } + if err := handler.Register(teamName); err == nil { + t.Error("Registering twice should have raised an error") + } else if err != ErrAlreadyRegistered { + t.Error("Wrong error for duplicate registration:", err) + } + if r, _, err := handler.ThemeOpen("/index.html"); err != nil { t.Error(err) } else if contents, err := ioutil.ReadAll(r); err != nil { @@ -45,24 +64,26 @@ func TestServer(t *testing.T) { t.Error("index.html wrong contents", contents) } - 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") - } - if es.Messages != "messages.html" { - t.Error("Messages has wrong contents") - } - if len(es.PointsLog) != 0 { - t.Error("Points log not empty") - } - if len(es.TeamNames) != 1 { - t.Error("Wrong number of team names") - } - if es.TeamNames["self"] != teamName { - t.Error("TeamNames['self'] wrong") + { + 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") + } + if es.Messages != "messages.html" { + t.Error("Messages has wrong contents") + } + if len(es.PointsLog) != 0 { + t.Error("Points log not empty") + } + if len(es.TeamNames) != 1 { + t.Error("Wrong number of team names") + } + if es.TeamNames["self"] != teamName { + t.Error("TeamNames['self'] wrong") + } } if r, _, err := handler.PuzzlesOpen("pategory", 1, "moo.txt"); err != nil { @@ -77,12 +98,12 @@ func TestServer(t *testing.T) { r.Close() } - if r, _, err := handler.PuzzlesOpen("pategory", 2, "puzzles.json"); err == nil { + if r, _, err := handler.PuzzlesOpen("pategory", 2, "puzzle.json"); err == nil { t.Error("Opening locked puzzle shouldn't work") r.Close() } - if r, _, err := handler.PuzzlesOpen("pategory", 20, "puzzles.json"); err == nil { + if r, _, err := handler.PuzzlesOpen("pategory", 20, "puzzle.json"); err == nil { t.Error("Opening non-existent puzzle shouldn't work") r.Close() } @@ -93,9 +114,53 @@ func TestServer(t *testing.T) { time.Sleep(TestMaintenanceInterval) - es = handler.ExportState() - if len(es.PointsLog) != 1 { - t.Error("I didn't get my points!") + { + es := handler.ExportState() + if len(es.PointsLog) != 1 { + t.Error("I didn't get my points!") + } + if len(es.Puzzles["pategory"]) != 2 { + t.Error("The next puzzle didn't unlock!") + } else if es.Puzzles["pategory"][1] != 2 { + t.Error("The 2-point puzzle should have unlocked!") + } + } + + if r, _, err := handler.PuzzlesOpen("pategory", 2, "puzzle.json"); err != nil { + t.Error("Opening unlocked puzzle should work") + } else { + r.Close() + } + if r, _, err := anonHandler.PuzzlesOpen("pategory", 2, "puzzle.json"); err != nil { + t.Error("Opening unlocked puzzle anonymously should work") + } else { + r.Close() + } + + if err := handler.CheckAnswer("pategory", 2, "wat"); err != nil { + t.Error("Right answer marked wrong:", err) + } + + time.Sleep(TestMaintenanceInterval) + + { + es := anonHandler.ExportState() + if len(es.TeamNames) != 1 { + 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.Error("Second point log didn't anonymize team ID correctly:", es.PointsLog[1]) + } + } + + { + es := handler.ExportState() + if len(es.TeamNames) != 1 { + t.Error("TeamNames is wrong:", es.TeamNames) + } } // BUG(neale): We aren't currently testing the various ways to disable the server diff --git a/cmd/mothd/state.go b/cmd/mothd/state.go index 3296749..ed10197 100644 --- a/cmd/mothd/state.go +++ b/cmd/mothd/state.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "errors" "fmt" "log" "math/rand" @@ -23,6 +24,9 @@ const DistinguishableChars = "34678abcdefhikmnpqrtwxy=" // This is also a valid RFC3339 format. 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") + // State defines the current state of a MOTH instance. // We use the filesystem for synchronization between threads. // The only thing State methods need to know is the path to the state directory. @@ -127,7 +131,7 @@ func (s *State) TeamName(teamID string) (string, error) { } // SetTeamName writes out team name. -// This can only be done once. +// This can only be done once per team. func (s *State) SetTeamName(teamID, teamName string) error { idsFile, err := s.Open("teamids.txt") if err != nil { @@ -147,14 +151,16 @@ func (s *State) SetTeamName(teamID, teamName string) error { } teamFilename := filepath.Join("teams", teamID) - teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_EXCL, 0644) + teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644) if os.IsExist(err) { - return fmt.Errorf("Team ID is already registered") + return ErrAlreadyRegistered } else if err != nil { return err } defer teamFile.Close() + log.Println("Setting team name to:", teamName, teamFilename, teamFile) fmt.Fprintln(teamFile, teamName) + teamFile.Close() return nil } diff --git a/cmd/mothd/state_test.go b/cmd/mothd/state_test.go index 7c6e156..c05bd3b 100644 --- a/cmd/mothd/state_test.go +++ b/cmd/mothd/state_test.go @@ -55,12 +55,18 @@ func TestState(t *testing.T) { t.Errorf("Setting bad team ID didn't raise an error") } - if err := s.SetTeamName(teamID, "My Team"); err != nil { - t.Errorf("Setting team name: %v", err) + teamName := "My Team" + if err := s.SetTeamName(teamID, teamName); err != nil { + t.Errorf("Setting team name: %w", err) } if err := s.SetTeamName(teamID, "wat"); err == nil { t.Errorf("Registering team a second time didn't fail") } + if name, err := s.TeamName(teamID); err != nil { + t.Error(err) + } else if name != teamName { + t.Error("Incorrect team name:", name) + } category := "poot" points := 3928 @@ -83,6 +89,10 @@ func TestState(t *testing.T) { t.Error("Duplicate points award didn't fail") } + if err := s.AwardPoints(teamID, category, points+1); err != nil { + t.Error("Awarding more points:", err) + } + pl = s.PointsLog() if len(pl) != 1 { t.Errorf("After awarding points, points log has length %d", len(pl)) @@ -98,7 +108,7 @@ func TestState(t *testing.T) { t.Error(err) } s.refresh() - if len(s.PointsLog()) != 1 { + if len(s.PointsLog()) != 2 { t.Error("Intentional parse error screws up all parsing") } diff --git a/cmd/mothd/transpiler.go b/cmd/mothd/transpiler.go index ddda86b..7103b20 100644 --- a/cmd/mothd/transpiler.go +++ b/cmd/mothd/transpiler.go @@ -70,9 +70,9 @@ func (p TranspilerProvider) CheckAnswer(cat string, points int, answer string) ( } // Mothball packages up a category into a mothball. -func (p TranspilerProvider) Mothball(cat string) (*bytes.Reader, error) { +func (p TranspilerProvider) Mothball(cat string, w io.Writer) error { c := transpile.NewFsCategory(p.fs, cat) - return transpile.Mothball(c) + return transpile.Mothball(c, w) } // Maintain performs housekeeping. diff --git a/cmd/transpile/main.go b/cmd/transpile/main.go index 4c95343..e8f8331 100644 --- a/cmd/transpile/main.go +++ b/cmd/transpile/main.go @@ -16,11 +16,13 @@ import ( // T represents the state of things type T struct { - Stdout io.Writer - Stderr io.Writer - Args []string - BaseFs afero.Fs - fs afero.Fs + Stdout io.Writer + Stderr io.Writer + Args []string + BaseFs afero.Fs + fs afero.Fs + + // Arguments filename string answer string } @@ -51,19 +53,21 @@ func (t *T) ParseArgs() (Command, error) { } flags := flag.NewFlagSet(t.Args[1], flag.ContinueOnError) + flags.SetOutput(t.Stderr) directory := flags.String("dir", "", "Work directory") switch t.Args[1] { case "mothball": cmd = t.DumpMothball + flags.StringVar(&t.filename, "out", "", "Path to create mothball (empty for stdout)") case "inventory": cmd = t.PrintInventory case "open": - flags.StringVar(&t.filename, "file", "puzzle.json", "Filename to open") cmd = t.DumpFile + flags.StringVar(&t.filename, "file", "puzzle.json", "Filename to open") case "answer": - flags.StringVar(&t.answer, "answer", "", "Answer to check") cmd = t.CheckAnswer + flags.StringVar(&t.answer, "answer", "", "Answer to check") case "help": usage(t.Stderr) return nothing, nil @@ -73,12 +77,10 @@ func (t *T) ParseArgs() (Command, error) { return nothing, fmt.Errorf("Invalid command") } - flags.SetOutput(t.Stderr) if err := flags.Parse(t.Args[2:]); err != nil { return nothing, err } if *directory != "" { - log.Println(*directory) t.fs = afero.NewBasePathFs(t.BaseFs, *directory) } else { t.fs = t.BaseFs @@ -140,14 +142,30 @@ func (t *T) DumpFile() error { return nil } -// DumpMothball writes a mothball to the writer. +// DumpMothball writes a mothball to the writer, or an output file if specified. func (t *T) DumpMothball() error { + var w io.Writer + c := transpile.NewFsCategory(t.fs, "") - mb, err := transpile.Mothball(c) - if err != nil { - return err + + removeOnError := false + switch t.filename { + case "", "-": + w = t.Stdout + default: + removeOnError = true + log.Println("Writing mothball to", t.filename) + outf, err := t.BaseFs.Create(t.filename) + if err != nil { + return err + } + defer outf.Close() + w = outf } - if _, err := io.Copy(t.Stdout, mb); err != nil { + if err := transpile.Mothball(c, w); err != nil { + if removeOnError { + t.BaseFs.Remove(t.filename) + } return err } return nil @@ -165,8 +183,6 @@ func (t *T) CheckAnswer() error { } func main() { - // XXX: Convert puzzle.py to standalone thingies - t := &T{ Stdout: os.Stdout, Stderr: os.Stderr, diff --git a/cmd/transpile/main_test.go b/cmd/transpile/main_test.go index ad79cef..52e1a1e 100644 --- a/cmd/transpile/main_test.go +++ b/cmd/transpile/main_test.go @@ -1,8 +1,10 @@ package main import ( + "archive/zip" "bytes" "encoding/json" + "io/ioutil" "testing" "github.com/dirtbags/moth/pkg/transpile" @@ -83,16 +85,72 @@ func TestEverything(t *testing.T) { if stdout.String() != "Moo." { t.Error("Wrong file pulled", stdout.String()) } +} + +func TestMothballs(t *testing.T) { + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + tp := T{ + Stdout: stdout, + Stderr: stderr, + BaseFs: newTestFs(), + } stdout.Reset() - if err := tp.Run("mothball", "-dir=unbroken"); err != nil { - t.Log(tp.fs) + if err := tp.Run("mothball", "-dir=unbroken", "-out=unbroken.mb"); err != nil { + t.Error(err) + return + } + + // afero.WriteFile(tp.BaseFs, "unbroken.mb", []byte("moo"), 0644) + fis, err := afero.ReadDir(tp.BaseFs, "/") + if err != nil { t.Error(err) } - if stdout.Len() < 200 { - t.Error("That's way too short to be a mothball") + for _, fi := range fis { + t.Log(fi.Name()) } - if stdout.String()[:2] != "PK" { - t.Error("This mothball isn't a zip file!") + + mb, err := tp.BaseFs.Open("unbroken.mb") + if err != nil { + t.Error(err) + return + } + defer mb.Close() + + info, err := mb.Stat() + if err != nil { + t.Error(err) + return + } + + zmb, err := zip.NewReader(mb, info.Size()) + if err != nil { + t.Error(err) + return + } + for _, zf := range zmb.File { + f, err := zf.Open() + if err != nil { + t.Error(err) + continue + } + defer f.Close() + buf, err := ioutil.ReadAll(f) + if err != nil { + t.Error(err) + continue + } + + switch zf.Name { + case "answers.txt": + if len(buf) == 0 { + t.Error("answers.txt empty") + } + case "puzzles.txt": + if len(buf) == 0 { + t.Error("puzzles.txt empty") + } + } } } diff --git a/docs/administration.md b/docs/administration.md index aa49c73..7a13145 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -70,7 +70,7 @@ Adjusting scores ------------------ rm /srv/moth/state/enabled # Suspend scoring - nano /srv/moth/state/points.log + nano /srv/moth/state/points.log # Replace nano with your preferred editor touch /srv/moth/state/enabled # Resume scoring We don't warn participants before we do this: @@ -78,10 +78,8 @@ any points scored while scoring is suspended are queued up and processed as soon It's very important to suspend scoring before mucking around with the points log. The maintenance loop assumes it is the only thing writing to this file, -and any edits you make could blow aware points scored. +and any edits you make will remove points scored while you were editing. -No, I don't use nano. -None of us use nano. Changing a team name diff --git a/pkg/transpile/mothball.go b/pkg/transpile/mothball.go index b393802..6647d5d 100644 --- a/pkg/transpile/mothball.go +++ b/pkg/transpile/mothball.go @@ -10,23 +10,16 @@ import ( ) // Mothball packages a Category up for a production server run. -func Mothball(c Category) (*bytes.Reader, error) { - buf := new(bytes.Buffer) - zf := zip.NewWriter(buf) +func Mothball(c Category, w io.Writer) error { + zf := zip.NewWriter(w) inv, err := c.Inventory() if err != nil { - return nil, err + return err } - puzzlesTxt, err := zf.Create("puzzles.txt") - if err != nil { - return nil, err - } - answersTxt, err := zf.Create("answers.txt") - if err != nil { - return nil, err - } + puzzlesTxt := new(bytes.Buffer) + answersTxt := new(bytes.Buffer) for _, points := range inv { fmt.Fprintln(puzzlesTxt, points) @@ -34,11 +27,11 @@ func Mothball(c Category) (*bytes.Reader, error) { puzzlePath := fmt.Sprintf("%d/puzzle.json", points) pw, err := zf.Create(puzzlePath) if err != nil { - return nil, err + return err } puzzle, err := c.Puzzle(points) if err != nil { - return nil, fmt.Errorf("Puzzle %d: %s", points, err) + return fmt.Errorf("Puzzle %d: %s", points, err) } // Record answers in answers.txt @@ -55,7 +48,7 @@ func Mothball(c Category) (*bytes.Reader, error) { // Write out Puzzle object penc := json.NewEncoder(pw) if err := penc.Encode(puzzle); err != nil { - return nil, fmt.Errorf("Puzzle %d: %s", points, err) + return fmt.Errorf("Puzzle %d: %s", points, err) } // Write out all attachments and scripts @@ -64,20 +57,33 @@ func Mothball(c Category) (*bytes.Reader, error) { attPath := fmt.Sprintf("%d/%s", points, att) aw, err := zf.Create(attPath) if err != nil { - return nil, err + return err } ar, err := c.Open(points, att) if exerr, ok := err.(*exec.ExitError); ok { - return nil, fmt.Errorf("Puzzle %d: %s: %s: %s", points, att, err, string(exerr.Stderr)) + return fmt.Errorf("Puzzle %d: %s: %s: %s", points, att, err, string(exerr.Stderr)) } else if err != nil { - return nil, fmt.Errorf("Puzzle %d: %s: %s", points, att, err) + return fmt.Errorf("Puzzle %d: %s: %s", points, att, err) } if _, err := io.Copy(aw, ar); err != nil { - return nil, fmt.Errorf("Puzzle %d: %s: %s", points, att, err) + return fmt.Errorf("Puzzle %d: %s: %s", points, att, err) } } } + + pf, err := zf.Create("puzzles.txt") + if err != nil { + return err + } + puzzlesTxt.WriteTo(pf) + + af, err := zf.Create("answers.txt") + if err != nil { + return err + } + answersTxt.WriteTo(af) + zf.Close() - return bytes.NewReader(buf.Bytes()), nil + return nil } diff --git a/pkg/transpile/mothball_test.go b/pkg/transpile/mothball_test.go index 753d536..8865b6f 100644 --- a/pkg/transpile/mothball_test.go +++ b/pkg/transpile/mothball_test.go @@ -2,6 +2,7 @@ package transpile import ( "archive/zip" + "bytes" "io/ioutil" "os" "path" @@ -14,7 +15,8 @@ import ( func TestMothballsMemFs(t *testing.T) { static := NewFsCategory(newTestFs(), "cat1") - if _, err := Mothball(static); err != nil { + mb := new(bytes.Buffer) + if err := Mothball(static, mb); err != nil { t.Error(err) } } @@ -25,13 +27,15 @@ func TestMothballsOsFs(t *testing.T) { fs := NewRecursiveBasePathFs(afero.NewOsFs(), "testdata") static := NewFsCategory(fs, "static") - mb, err := Mothball(static) + mb := new(bytes.Buffer) + err := Mothball(static, mb) if err != nil { t.Error(err) return } - mbr, err := zip.NewReader(mb, int64(mb.Len())) + mbReader := bytes.NewReader(mb.Bytes()) + mbr, err := zip.NewReader(mbReader, int64(mb.Len())) if err != nil { t.Error(err) } @@ -43,7 +47,7 @@ func TestMothballsOsFs(t *testing.T) { defer f.Close() if buf, err := ioutil.ReadAll(f); err != nil { t.Error(err) - } else if string(buf) != "" { + } else if string(buf) != "1\n2\n3\n" { t.Error("Bad puzzles.txt", string(buf)) } } diff --git a/theme/moth.js b/theme/moth.js index ccf2080..2724411 100644 --- a/theme/moth.js +++ b/theme/moth.js @@ -59,7 +59,7 @@ function renderPuzzles(obj) { pdiv.appendChild(l) for (let puzzle of puzzles) { let points = puzzle - let id = puzzle + let id = null if (Array.isArray(puzzle)) { points = puzzle[0] @@ -80,7 +80,7 @@ function renderPuzzles(obj) { let url = new URL("puzzle.html", window.location) url.searchParams.set("cat", cat) url.searchParams.set("points", points) - url.searchParams.set("pid", id) + if (id) { url.searchParams.set("pid", id) } a.href = url.toString() } } @@ -97,6 +97,7 @@ function renderPuzzles(obj) { } function renderState(obj) { + window.state = obj devel = obj.Config.Devel if (devel) { let params = new URLSearchParams(window.location.search) diff --git a/theme/points.json b/theme/points.json deleted file mode 100644 index 36e0aef..0000000 --- a/theme/points.json +++ /dev/null @@ -1,255 +0,0 @@ -{ - "__comment__": [ - "This file is to help debug themes.", - "MOTHd will ignore it." - ], - "teams": { - "0": "4HED Followers", - "1": "Dirtbags", - "17": "Eyeball", - "2": "Soup Giver!!!!!!!!!", - "24": "Dumb freshmans 3", - "25": "Winner", - "2d": "Cool team name", - "2f": "Dumm freshmans #1", - "4": "K19 the Widow Maker", - "5": "2T2", - "6": "Apples", - "7": "Top Minds", - "8": "DIRTBAGS", - "b": "Antiderivative of Pizza" - }, - "points": [ - [1573007086,"0","codebreaking",1], - [1573007096,"1","codebreaking",1], - [1573007114,"2","codebreaking",1], - [1573007153,"0","codebreaking",2], - [1573007159,"4","codebreaking",1], - [1573007169,"5","codebreaking",1], - [1573007181,"6","sequence",1], - [1573007184,"7","codebreaking",1], - [1573007209,"8","codebreaking",1], - [1573007212,"2","codebreaking",2], - [1573007240,"1","sequence",1], - [1573007244,"b","codebreaking",1], - [1573007246,"1","nocode",1], - [1573007258,"5","nocode",1], - [1573007271,"5","nocode",2], - [1573007284,"1","steg",1], - [1573007295,"7","codebreaking",2], - [1573007298,"2","codebreaking",4], - [1573007305,"5","nocode",3], - [1573007316,"7","codebreaking",4], - [1573007321,"0","codebreaking",4], - [1573007328,"5","nocode",4], - [1573007331,"7","nocode",1], - [1573007336,"17","codebreaking",1], - [1573007340,"7","nocode",2], - [1573007367,"0","nocode",10], - [1573007369,"7","nocode",3], - [1573007371,"b","nocode",1], - [1573007379,"7","nocode",4], - [1573007388,"b","nocode",2], - [1573007391,"6","sequence",2], - [1573007397,"4","codebreaking",2], - [1573007407,"b","nocode",3], - [1573007411,"7","nocode",10], - [1573007413,"5","nocode",10], - [1573007429,"b","nocode",4], - [1573007442,"24","codebreaking",2], - [1573007451,"25","codebreaking",1], - [1573007456,"7","sequence",1], - [1573007460,"b","nocode",10], - [1573007467,"5","sequence",1], - [1573007471,"7","sequence",2], - [1573007478,"5","sequence",2], - [1573007479,"17","codebreaking",2], - [1573007490,"2","codebreaking",5], - [1573007509,"2d","codebreaking",1], - [1573007536,"8","codebreaking",2], - [1573007544,"2f","codebreaking",1], - [1573007546,"b","sequence",1], - [1573007574,"24","codebreaking",4], - [1573007581,"b","sequence",2], - [1573007591,"25","codebreaking",2], - [1573007603,"8","codebreaking",4], - [1573007614,"0","nocode",20], - [1573007639,"4","codebreaking",4], - [1573007678,"6","codebreaking",1], - [1573007692,"8","nocode",1], - [1573007695,"24","codebreaking",5], - [1573007705,"7","codebreaking",5], - [1573007707,"8","nocode",2], - [1573007713,"17","nocode",1], - [1573007727,"17","nocode",2], - [1573007735,"8","nocode",3], - [1573007737,"b","steg",1], - [1573007739,"25","codebreaking",4], - [1573007749,"8","nocode",4], - [1573007757,"17","codebreaking",4], - [1573007768,"8","nocode",10], - [1573007795,"0","sequence",1], - [1573007799,"8","sequence",1], - [1573007816,"0","sequence",2], - [1573007822,"8","sequence",2], - [1573007834,"24","codebreaking",6], - [1573007853,"2d","codebreaking",2], - [1573007905,"1","codebreaking",2], - [1573007941,"4","codebreaking",5], - [1573007956,"1","codebreaking",4], - [1573007974,"6","codebreaking",2], - [1573007998,"17","sequence",1], - [1573008022,"b","codebreaking",4], - [1573008055,"24","sequence",2], - [1573008063,"6","codebreaking",4], - [1573008066,"2d","codebreaking",4], - [1573008074,"24","sequence",1], - [1573008099,"17","nocode",4], - [1573008101,"0","codebreaking",7], - [1573008108,"2d","nocode",1], - [1573008135,"24","nocode",30], - [1573008146,"1","codebreaking",5], - [1573008162,"2d","nocode",2], - [1573008174,"b","codebreaking",2], - [1573008191,"2","codebreaking",6], - [1573008234,"6","codebreaking",5], - [1573008240,"2","nocode",10], - [1573008291,"5","steg",1], - [1573008310,"6","nocode",1], - [1573008323,"2d","nocode",3], - [1573008327,"6","nocode",2], - [1573008330,"25","codebreaking",5], - [1573008334,"2f","codebreaking",2], - [1573008348,"6","nocode",3], - [1573008356,"2d","nocode",4], - [1573008362,"b","codebreaking",5], - [1573008364,"6","nocode",4], - [1573008364,"17","codebreaking",5], - [1573008371,"24","nocode",4], - [1573008385,"24","nocode",3], - [1573008390,"6","nocode",10], - [1573008397,"24","nocode",2], - [1573008400,"25","nocode",1], - [1573008402,"2d","nocode",10], - [1573008408,"24","nocode",1], - [1573008419,"25","nocode",2], - [1573008429,"24","steg",1], - [1573008437,"25","nocode",3], - [1573008451,"25","nocode",4], - [1573008479,"25","nocode",10], - [1573008502,"2d","sequence",1], - [1573008506,"17","codebreaking",6], - [1573008537,"2d","sequence",2], - [1573008649,"17","codebreaking",7], - [1573008668,"2f","codebreaking",4], - [1573008716,"1","codebreaking",6], - [1573008768,"8","steg",1], - [1573008808,"7","nocode",50], - [1573008817,"24","steg",2], - [1573008832,"2f","codebreaking",5], - [1573008890,"17","steg",1], - [1573008902,"b","steg",2], - [1573008932,"7","steg",1], - [1573008944,"24","steg",3], - [1573008978,"2","steg",1], - [1573009006,"24","steg",4], - [1573009032,"6","steg",1], - [1573009038,"b","steg",3], - [1573009052,"2d","codebreaking",5], - [1573009098,"b","steg",4], - [1573009122,"8","steg",2], - [1573009125,"4","nocode",1], - [1573009160,"24","nocode",10], - [1573009161,"4","nocode",2], - [1573009179,"2","steg",2], - [1573009180,"1","steg",2], - [1573009194,"24","nocode",20], - [1573009203,"0","nocode",50], - [1573009212,"2f","codebreaking",6], - [1573009240,"2f","nocode",1], - [1573009250,"4","nocode",4], - [1573009255,"2f","nocode",2], - [1573009258,"2","steg",4], - [1573009282,"4","nocode",10], - [1573009299,"25","sequence",1], - [1573009305,"6","steg",4], - [1573009308,"17","steg",3], - [1573009310,"1","steg",3], - [1573009334,"7","steg",4], - [1573009345,"1","steg",4], - [1573009345,"7","steg",3], - [1573009354,"8","steg",4], - [1573009357,"25","sequence",2], - [1573009402,"6","steg",3], - [1573009402,"b","sequence",8], - [1573009413,"2f","nocode",3], - [1573009437,"17","steg",2], - [1573009455,"2f","nocode",10], - [1573009481,"b","sequence",16], - [1573009502,"b","sequence",19], - [1573009520,"b","sequence",25], - [1573009525,"17","steg",4], - [1573009559,"7","steg",2], - [1573009561,"b","sequence",35], - [1573009571,"0","sequence",35], - [1573009588,"25","steg",1], - [1573009602,"24","sequence",8], - [1573009607,"2","steg",5], - [1573009614,"1","steg",5], - [1573009617,"17","sequence",35], - [1573009620,"7","sequence",50], - [1573009621,"6","steg",5], - [1573009629,"5","steg",3], - [1573009632,"7","sequence",35], - [1573009644,"17","sequence",25], - [1573009670,"6","steg",6], - [1573009698,"8","steg",6], - [1573009700,"17","sequence",19], - [1573009703,"24","steg",6], - [1573009703,"4","sequence",1], - [1573009707,"0","sequence",50], - [1573009710,"25","steg",2], - [1573009729,"2f","sequence",1], - [1573009768,"1","steg",6], - [1573009814,"2","codebreaking",8], - [1573009842,"0","steg",1], - [1573009844,"2f","sequence",2], - [1573009882,"4","steg",1], - [1573009896,"25","steg",3], - [1573009931,"1","sequence",2], - [1573009937,"25","steg",4], - [1573010066,"7","steg",6], - [1573010101,"25","steg",5], - [1573010114,"5","steg",4], - [1573010137,"25","steg",6], - [1573010185,"4","sequence",2], - [1573010229,"17","nocode",80], - [1573010256,"24","sequence",35], - [1573010281,"6","codebreaking",7], - [1573010336,"25","codebreaking",6], - [1573010390,"7","codebreaking",7], - [1573010468,"2f","steg",1], - [1573010712,"0","steg",2], - [1573010739,"0","steg",3], - [1573010754,"0","steg",4], - [1573010778,"0","steg",5], - [1573010784,"7","nocode",90], - [1573010792,"0","steg",6], - [1573011760,"7","sequence",60], - [1573056120,"0","sequence",100], - [1573056324,"0","sequence",200], - [1573056791,"0","sequence",300], - [1573057092,"0","sequence",400], - [1573076767,"25","sequence",400], - [1573076809,"25","sequence",300], - [1573076838,"25","sequence",200], - [1573076936,"25","nocode",20], - [1573077275,"25","nocode",50], - [1573078364,"0","sequence",19], - [1573078432,"0","sequence",25], - [1573078487,"25","sequence",35], - [1573078501,"25","sequence",50], - [1573079359,"0","nocode",90], - [1573079714,"25","nocode",9] - ] -} diff --git a/theme/puzzle.js b/theme/puzzle.js index 9c8e42a..0dd3797 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -132,7 +132,7 @@ async function loadPuzzle(categoryName, points, puzzleId) { document.getElementById("authors").textContent = window.puzzle.Pre.Authors.join(", ") // If answers are provided, this is the devel server - if (window.puzzle.Answers) { + if (window.puzzle.Answers.length > 0) { devel_addin(document.getElementById("devel")) } @@ -204,8 +204,8 @@ function init() { let points = params.get("points") let puzzleId = params.get("pid") - if (categoryName && points && puzzleId) { - loadPuzzle(categoryName, points, puzzleId) + if (categoryName && points) { + loadPuzzle(categoryName, points, puzzleId || points) } let teamId = sessionStorage.getItem("id") diff --git a/theme/puzzles.json b/theme/puzzles.json deleted file mode 100644 index 5a6735d..0000000 --- a/theme/puzzles.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "__comment__": [ - "This file is to help debug themes.", - "MOTHd will ignore it." - ], - "codebreaking": [ - [1,"37117e6b034696b86c6516477cc0bc60bc1e642e"], - [2,"546b586428979771b061608489327da4940086a7"], - [4,"6f2a33c93f56b4f29cc79e6576ba4d1000aa1756"], - [5,"c654fe263909b1940d7aad8c572363a0569c07c6"], - [6,"f30bd32bf940f2bb03506ec334d2d204efc4695b"], - [7,"128b119083b6ae70c380a8eb70ec6a518425e7af"], - [8,"edd4f57aeb565b3b053fa194f5e677cb77ef0285"], - [15,"9781863bca9f596972e2a10460932ec5ec6be3fe"] - ], - "nocode": [ - [1,"37117e6b034696b86c6516477cc0bc60bc1e642e"], - [2,"546b586428979771b061608489327da4940086a7"], - [3,"79c08697a1923da1118fd0c2e922b5d3899cabcc"], - [4,"6f2a33c93f56b4f29cc79e6576ba4d1000aa1756"], - [10,"bf4fae263bf6e4243b143f4ecd64e471f3ec75dd"], - [20,"9f374f6dac9f972fac4693099a7bfa7c535f7503"], - [30,"02de1196d43976b2d050c6c597f068623d2df201"], - [50,"9acb3af947cb4aa10a9c1221c04518f956cdc0d0"], - [80,"78f807ac44f3cbf537861e7cdf1ac53937e4ee47"], - [90,"6d537653aa599178c72528f7e1f2fbb36e6333f9"], - [100,"4f5982a3a7cc9b9af0320130132e8cab39a1fd2c"] - ], - "sequence": [ - [1,"37117e6b034696b86c6516477cc0bc60bc1e642e"], - [2,"546b586428979771b061608489327da4940086a7"], - [8,"edd4f57aeb565b3b053fa194f5e677cb77ef0285"], - [16,"a9ace4b773f045c422260edefaa8563dcd80ac59"], - [19,"f11ca0172451f37ba6f4d66ff9add80013480a49"], - [25,"0458533d28705548829e53d686215cc6fbeec8f5"], - [35,"91aac06bae090ae7d1699b5a78601ef8d29e9271"], - [50,"9acb3af947cb4aa10a9c1221c04518f956cdc0d0"], - [60,"bf84beed9e382268ab40d0113dfeb73c96aa919a"], - [100,"4f5982a3a7cc9b9af0320130132e8cab39a1fd2c"], - [200,"3b9b8993fe639cf0c19a58b39ebbf6077828887a"], - [300,"0f13c4d19bc5d2e10d43e8cd2e40f759e731cece"], - [400,"db7a59f313818fc9598969d2a0a04e21bd26697f"], - [500,"81c5389eb5406aa44053662f6482f246b8a12e0c"] - ], - "steg": [ - [1,"200e8cd902ba7304765c463f6ed1322bc25f3454"], - [2,"707328988c3986d450d8fe419eb49f078fb7998c"], - [3,"d0b336ad59cbcd4415ddf200c6c099db5c3fea1d"], - [4,"f071503b403ffee2b38e186e800bfd5dd28e8f0e"], - [5,"186f425fa5762ef37f874cc602fe0edc4325a5d2"], - [6,"c6527c3c30c4e6a33026192d358d83d259cd17a7"], - [10,"84973f77a1b14e4666f3d8a8bdeead7633c4ed56"] - ] -} diff --git a/theme/scoreboard.js b/theme/scoreboard.js index c5b64f1..104efcb 100644 --- a/theme/scoreboard.js +++ b/theme/scoreboard.js @@ -13,6 +13,8 @@ function scoreboardInit() { ] function update(state) { + window.state = state + for (let rotate of document.querySelectorAll(".rotate")) { rotate.appendChild(rotate.firstElementChild) }