diff --git a/cmd/mothd/httpd.go b/cmd/mothd/httpd.go index 54ca156..f9cc27c 100644 --- a/cmd/mothd/httpd.go +++ b/cmd/mothd/httpd.go @@ -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) diff --git a/cmd/mothd/httpd_test.go b/cmd/mothd/httpd_test.go index b56f56c..f77e044 100644 --- a/cmd/mothd/httpd_test.go +++ b/cmd/mothd/httpd_test.go @@ -11,11 +11,8 @@ import ( "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) for k, v := range args { vals.Set(k, v) diff --git a/cmd/mothd/issues_test.go b/cmd/mothd/issues_test.go index 26a6f6d..fefd29a 100644 --- a/cmd/mothd/issues_test.go +++ b/cmd/mothd/issues_test.go @@ -15,7 +15,7 @@ func TestIssue156(t *testing.T) { afero.WriteFile(state, "teams/bloop", []byte("bloop: the team"), 0644) state.refresh() - handler := server.NewHandler("", "bloop") + handler := server.NewHandler("bloop") es := handler.ExportState() if _, ok := es.TeamNames["self"]; !ok { t.Fail() diff --git a/cmd/mothd/server.go b/cmd/mothd/server.go index 0c5de3f..c0d588f 100644 --- a/cmd/mothd/server.go +++ b/cmd/mothd/server.go @@ -55,13 +55,19 @@ type ThemeProvider interface { type StateProvider interface { Messages() string PointsLog() award.List - TeamName(teamID string) (string, error) + TeamName(teamID string) (Team, error) SetTeamName(teamID, teamName string) error - AwardPoints(teamID string, cat string, points int) error - LogEvent(event, participantID, teamID, cat string, points int, extra ...string) + AwardPoints(team Team, cat string, points int) error + LogEvent(event, teamID, cat string, points int, extra ...string) Maintainer } +// Team defines a team entry +type Team struct { + Name string + ID string +} + // Maintainer is something that can be maintained. type Maintainer interface { // Maintain is the maintenance loop. @@ -92,19 +98,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. @@ -131,7 +135,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 @@ -139,6 +143,11 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) ( // CheckAnswer returns an error if answer is not a correct answer for puzzle points in category cat func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string) error { + team, err := mh.State.TeamName(mh.teamID) + if err != nil { + return fmt.Errorf("invalid team ID") + } + correct := false for _, provider := range mh.PuzzleProviders { if ok, err := provider.CheckAnswer(cat, points, answer); err != nil { @@ -148,19 +157,16 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string) } } 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") } - mh.State.LogEvent("correct", mh.participantID, mh.teamID, cat, points) - - if _, err := mh.State.TeamName(mh.teamID); err != nil { - return fmt.Errorf("invalid team ID") - } - if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil { + if err := mh.State.AwardPoints(team, cat, points); err != nil { return fmt.Errorf("error awarding points: %s", err) } + mh.State.LogEvent("correct", mh.teamID, cat, points) + return nil } @@ -175,7 +181,7 @@ func (mh *MothRequestHandler) Register(teamName string) error { if teamName == "" { 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) } @@ -194,7 +200,7 @@ func (mh *MothRequestHandler) exportStateIfRegistered(forceRegistered bool) *Sta export := StateExport{} export.Config = mh.Config - teamName, err := mh.State.TeamName(mh.teamID) + team, err := mh.State.TeamName(mh.teamID) registered := forceRegistered || mh.Config.Devel || (err == nil) export.Messages = mh.State.Messages() @@ -207,7 +213,7 @@ func (mh *MothRequestHandler) exportStateIfRegistered(forceRegistered bool) *Sta export.PointsLog = make(award.List, len(pointsLog)) if registered { - export.TeamNames["self"] = teamName + export.TeamNames["self"] = team.Name exportIDs[mh.teamID] = "self" } for logno, awd := range pointsLog { @@ -215,10 +221,10 @@ func (mh *MothRequestHandler) exportStateIfRegistered(forceRegistered bool) *Sta awd.TeamID = id } else { exportID := strconv.Itoa(logno) - name, _ := mh.State.TeamName(awd.TeamID) + team, _ := mh.State.TeamName(awd.TeamID) exportIDs[awd.TeamID] = exportID awd.TeamID = exportID - export.TeamNames[exportID] = name + export.TeamNames[exportID] = team.Name } export.PointsLog[logno] = awd diff --git a/cmd/mothd/server_test.go b/cmd/mothd/server_test.go index caaf61a..828d08b 100644 --- a/cmd/mothd/server_test.go +++ b/cmd/mothd/server_test.go @@ -42,7 +42,7 @@ func (ts TestServer) refresh() { func TestDevelServer(t *testing.T) { server := NewTestServer() server.Config.Devel = true - anonHandler := server.NewHandler("badParticipantId", "badTeamId") + anonHandler := server.NewHandler("badTeamId") { es := anonHandler.ExportState() @@ -57,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() diff --git a/cmd/mothd/state.go b/cmd/mothd/state.go index aa5a1f0..086bdaf 100644 --- a/cmd/mothd/state.go +++ b/cmd/mothd/state.go @@ -27,6 +27,10 @@ const DistinguishableChars = "34678abcdefhikmnpqrtwxy=" // This is also a valid RFC3339 format. const RFC3339Space = "2006-01-02 15:04:05Z07:00" +// TeamLinkPrefix is prepended to a Team ID and stored as a team name +// to handle "linked" team IDs, used for participant ID to team mapping. +const TeamLinkPrefix = "link>" + // ErrAlreadyRegistered means a team cannot be registered because it was registered previously. var ErrAlreadyRegistered = errors.New("team ID has already been registered") @@ -45,11 +49,11 @@ type State struct { 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 + teamsLastChange time.Time + teams map[string]Team + pointsLog award.List + messages string + lock sync.RWMutex } // NewState returns a new State struct backed by the given Fs @@ -60,7 +64,7 @@ func NewState(fs afero.Fs) *State { refreshNow: make(chan bool, 5), eventStream: make(chan []string, 80), - teamNames: make(map[string]string), + teams: make(map[string]Team), } if err := s.reopenEventLog(); err != nil { log.Fatal(err) @@ -121,29 +125,33 @@ func (s *State) updateEnabled() { s.Enabled = nextEnabled log.Printf("Setting enabled=%v: %s", s.Enabled, why) if s.Enabled { - s.LogEvent("enabled", "", "", "", 0, why) + s.LogEvent("enabled", "", "", 0, why) } else { - s.LogEvent("disabled", "", "", "", 0, why) + s.LogEvent("disabled", "", "", 0, why) } } } -// TeamName returns team name given a team ID. -func (s *State) TeamName(teamID string) (string, error) { +// TeamName returns a Team given a team ID. +// +// The returned team ID will be the dereferenced filename of the team. +// This allows you to have symbolic links to team IDs, +// in order to implement participant IDs +func (s *State) TeamName(teamID string) (Team, error) { s.lock.RLock() - name, ok := s.teamNames[teamID] + team, ok := s.teams[teamID] s.lock.RUnlock() if !ok { - return "", fmt.Errorf("unregistered team ID: %s", teamID) + return Team{}, fmt.Errorf("unregistered team ID: %s", teamID) } - return name, nil + return team, 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] + _, ok := s.teams[teamID] s.lock.RUnlock() if ok { return ErrAlreadyRegistered @@ -166,6 +174,9 @@ func (s *State) SetTeamName(teamID, teamName string) error { return fmt.Errorf("team ID not found in list of valid team IDs") } + for strings.HasPrefix(teamName, TeamLinkPrefix) { + teamName = teamName[len(TeamLinkPrefix):] + } teamFilename := filepath.Join("teams", teamID) teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644) if os.IsExist(err) { @@ -199,20 +210,20 @@ func (s *State) Messages() string { return s.messages } -// AwardPoints gives points to teamID in category. -// This doesn't attempt to ensure the teamID has been registered. +// AwardPoints gives points to team in category. +// This doesn't attempt to ensure the team ID has been registered. // It first checks to make sure these are not duplicate points. // This is not a perfect check, you can trigger a race condition here. // It's just a courtesy to the user. // The update task makes sure we never have duplicate points in the log. -func (s *State) AwardPoints(teamID, category string, points int) error { - return s.awardPointsAtTime(time.Now().Unix(), teamID, category, points) +func (s *State) AwardPoints(team Team, category string, points int) error { + return s.awardPointsAtTime(time.Now().Unix(), team, category, points) } -func (s *State) awardPointsAtTime(when int64, teamID string, category string, points int) error { +func (s *State) awardPointsAtTime(when int64, team Team, category string, points int) error { a := award.T{ When: when, - TeamID: teamID, + TeamID: team.ID, Category: category, Points: points, } @@ -322,7 +333,7 @@ func (s *State) maybeInitialize() { if err := s.reopenEventLog(); err != nil { log.Fatal(err) } - s.LogEvent("init", "", "", "", 0) + s.LogEvent("init", "", "", 0) // Make sure various subdirectories exist s.Mkdir("points.tmp", 0755) @@ -381,12 +392,11 @@ func (s *State) maybeInitialize() { } // 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( []string{ strconv.FormatInt(time.Now().Unix(), 10), event, - participantID, teamID, cat, strconv.Itoa(points), @@ -443,12 +453,12 @@ func (s *State) updateCaches() { _, ismmfs := s.Fs.(*afero.MemMapFs) // Tests run so quickly that the time check isn't precise enough if fi, err := s.Fs.Stat("teams"); err != nil { log.Printf("Getting modification time of teams directory: %v", err) - } else if ismmfs || s.teamNamesLastChange.Before(fi.ModTime()) { - s.teamNamesLastChange = fi.ModTime() + } else if ismmfs || s.teamsLastChange.Before(fi.ModTime()) { + s.teamsLastChange = fi.ModTime() // The compiler recognizes this as an optimization case - for k := range s.teamNames { - delete(s.teamNames, k) + for k := range s.teams { + delete(s.teams, k) } teamsFs := afero.NewBasePathFs(s.Fs, "teams") @@ -456,13 +466,31 @@ func (s *State) updateCaches() { log.Printf("Reading team ids: %v", err) } else { for _, dirent := range dirents { - teamID := dirent.Name() - if teamNameBytes, err := afero.ReadFile(teamsFs, teamID); err != nil { - log.Printf("Reading team %s: %v", teamID, err) - } else { - teamName := strings.TrimSpace(string(teamNameBytes)) - s.teamNames[teamID] = teamName + team := Team{ + Name: "", + ID: dirent.Name(), } + + // Dereference links a few times before giving up + for i := 0; i < 2; i++ { + teamNameBytes, err := afero.ReadFile(teamsFs, team.ID) + if err != nil { + log.Printf("Reading team %s: %v", team.ID, err) + team.Name = err.Error() + } + + team.Name = string(teamNameBytes) + if !strings.HasPrefix(team.Name, TeamLinkPrefix) { + team.Name = strings.TrimSpace(team.Name) + if team.Name == "" { + team.Name = "∅" // Empty set + } + break + } + team.ID = team.Name[len(TeamLinkPrefix):] + team.Name = "[Too Many Links]" + } + s.teams[team.ID] = team } } } @@ -520,11 +548,15 @@ func NewDevelState(sp StateProvider) *DevelState { // // If one's registered, it will use it. // Otherwise, it returns "" -func (ds *DevelState) TeamName(teamID string) (string, error) { - if name, err := ds.StateProvider.TeamName(teamID); err == nil { - return name, nil +func (ds *DevelState) TeamName(teamID string) (Team, error) { + if team, err := ds.StateProvider.TeamName(teamID); err == nil { + return team, nil } - return fmt.Sprintf("«devel:%s»", teamID), nil + team := Team{ + Name: fmt.Sprintf("«devel:%s»", teamID), + ID: teamID, + } + return team, nil } // SetTeamName associates a team name with any teamID diff --git a/cmd/mothd/state_test.go b/cmd/mothd/state_test.go index 21f1ea2..95cb1ca 100644 --- a/cmd/mothd/state_test.go +++ b/cmd/mothd/state_test.go @@ -17,8 +17,16 @@ func NewTestState() *State { return s } +func slurp(c chan bool) { + for range c { + // Nothing + } +} + func TestState(t *testing.T) { s := NewTestState() + defer close(s.refreshNow) + go slurp(s.refreshNow) mustExist := func(path string) { _, err := s.Fs.Stat(path) @@ -63,15 +71,19 @@ func TestState(t *testing.T) { t.Errorf("Registering team a second time didn't fail") } s.refresh() - if name, err := s.TeamName(teamID); err != nil { + + team, err := s.TeamName(teamID) + if err != nil { t.Error(err) - } else if name != teamName { - t.Error("Incorrect team name:", name) + } else if teamName != team.Name { + t.Errorf("Incorrect team name: %#v != %#v", teamName, team.Name) + } else if teamID != team.ID { + t.Error("Incorrect team ID", team.ID) } category := "poot" points := 3928 - if err := s.AwardPoints(teamID, category, points); err != nil { + if err := s.AwardPoints(team, category, points); err != nil { t.Error(err) } // Flex duplicate detection with different timestamp @@ -82,7 +94,7 @@ func TestState(t *testing.T) { f.Close() } - s.AwardPoints(teamID, category, points) + s.AwardPoints(team, category, points) s.refresh() pl = s.PointsLog() if len(pl) != 1 { @@ -94,11 +106,11 @@ func TestState(t *testing.T) { t.Errorf("Incorrect logged award %v", pl) } - if err := s.AwardPoints(teamID, category, points); err == nil { + if err := s.AwardPoints(team, category, points); err == nil { t.Error("Duplicate points award after refresh didn't fail") } - if err := s.AwardPoints(teamID, category, points+1); err != nil { + if err := s.AwardPoints(team, category, points+1); err != nil { t.Error("Awarding more points:", err) } @@ -112,7 +124,7 @@ func TestState(t *testing.T) { if len(s.PointsLog()) != 0 { t.Errorf("Intentional parse error breaks pointslog") } - if err := s.AwardPoints(teamID, category, points); err != nil { + if err := s.AwardPoints(team, category, points); err != nil { t.Error(err) } s.refresh() @@ -139,10 +151,10 @@ func TestStateOutOfOrderAward(t *testing.T) { points := 100 now := time.Now().Unix() - if err := s.awardPointsAtTime(now+20, "AA", category, points); err != nil { + if err := s.awardPointsAtTime(now+20, Team{ID: "AA"}, category, points); err != nil { t.Error("Awarding points to team ZZ:", err) } - if err := s.awardPointsAtTime(now+10, "ZZ", category, points); err != nil { + if err := s.awardPointsAtTime(now+10, Team{ID: "ZZ"}, category, points); err != nil { t.Error("Awarding points to team AA:", err) } s.refresh() @@ -157,16 +169,16 @@ func TestStateOutOfOrderAward(t *testing.T) { func TestStateEvents(t *testing.T) { s := NewTestState() - s.LogEvent("moo", "", "", "", 0) - s.LogEvent("moo 2", "", "", "", 0) + s.LogEvent("moo", "", "", 0) + s.LogEvent("moo 2", "", "", 0) - if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "init::::0" { + if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "init:::0" { t.Error("Wrong message from event stream:", msg) } - if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo::::0" { + if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo:::0" { t.Error("Wrong message from event stream:", msg) } - if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo 2::::0" { + if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo 2:::0" { t.Error("Wrong message from event stream:", msg) } } @@ -266,7 +278,7 @@ func TestStateMaintainer(t *testing.T) { t.Error("Team ID too short:", teamID) } - s.LogEvent("Hello!", "", "", "", 0) + s.LogEvent("Hello!", "", "", 0) if len(s.PointsLog()) != 0 { t.Error("Points log is not empty") @@ -274,7 +286,7 @@ func TestStateMaintainer(t *testing.T) { if err := s.SetTeamName(teamID, "The Patricks"); err != nil { t.Error(err) } - if err := s.AwardPoints(teamID, "pategory", 31337); err != nil { + if err := s.AwardPoints(Team{ID: teamID}, "pategory", 31337); err != nil { t.Error(err) } time.Sleep(updateInterval) @@ -307,13 +319,14 @@ func TestDevelState(t *testing.T) { } else if err == nil { t.Error("Registering a team that doesn't exist didn't return ErrAlreadyRegistered") } - if n, err := ds.TeamName("boog"); err != nil { + team, err := ds.TeamName("boog") + if err != nil { t.Error("Devel State returned error on team name lookup") - } else if n != "«devel:boog»" { - t.Error("Wrong team name", n) + } else if team.Name != "«devel:boog»" { + t.Error("Wrong team name", team.Name) } - if err := ds.AwardPoints("blerg", "dog", 82); err != nil { + if err := ds.AwardPoints(team, "dog", 82); err != nil { t.Error("Devel State AwardPoints returned an error", err) } } diff --git a/theme/index.html b/theme/index.html index f780301..32e2115 100644 --- a/theme/index.html +++ b/theme/index.html @@ -16,11 +16,6 @@
- Team ID:
Team name:
diff --git a/theme/moth.js b/theme/moth.js index eb1742c..f7bafc1 100644 --- a/theme/moth.js +++ b/theme/moth.js @@ -83,7 +83,6 @@ function renderPuzzles(obj) { 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() } } @@ -105,7 +104,6 @@ function renderState(obj) { 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) @@ -115,12 +113,8 @@ function renderState(obj) { 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) @@ -152,8 +146,6 @@ 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", @@ -166,7 +158,6 @@ function login(e) { if ((obj.status == "success") || (obj.data.short == "Already registered")) { toast("Logged in") sessionStorage.id = teamId - sessionStorage.pid = participantId showPuzzles() heartbeat() } else {