Remove participant IDs, add team ID links

This commit is contained in:
Neale Pickett 2022-05-19 20:53:51 -06:00
parent 8e0f4561a5
commit ec2a547637
9 changed files with 136 additions and 104 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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 "<devel:$ID>"
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

View File

@ -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)
}
}

View File

@ -16,11 +16,6 @@
</div>
<form id="login">
<!--
<span id="pid">
Participant ID: <input name="pid"> (optional) <br>
</span>
-->
Team ID: <input name="id"> <br>
Team name: <input name="name"> <br>
<input type="submit" value="Sign In">

View File

@ -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 {