Adding everything needed to implement admin API, still needs testing,

though
This commit is contained in:
Donaldson 2021-12-30 17:00:14 -08:00
parent 7fc2eec35f
commit b4b867bed8
2 changed files with 402 additions and 73 deletions

View File

@ -58,26 +58,26 @@ type StateProvider interface {
PointsLog() award.List // GET /admin/state/points PointsLog() award.List // GET /admin/state/points
AwardPoints(teamID string, cat string, points int) error // POST /admin/state/points AwardPoints(teamID string, cat string, points int) error // POST /admin/state/points
// AwardPoints(teamID string, cat string, points int, when int64) error // POST /admin/state/points AwardPointsAtTime(teamID string, cat string, points int, when int64) error // POST /admin/state/points
// Points(teamID string, cat string, points int) bool // Check if point entry exists // HEAD /admin/state/points/<teamID>/<cat>/<points> PointExists(teamID string, cat string, points int) bool // Check if point entry exists // HEAD /admin/state/points/<teamID>/<cat>/<points>
// Points(teamID string, cat string, points int, when int64) bool // Check if point entry exists // HEAD /admin/state/points/<teamID>/<cat>/<points>/<when> PointExistsAtTime(teamID string, cat string, points int, when int64) bool // Check if point entry exists // HEAD /admin/state/points/<teamID>/<cat>/<points>/<when>
// RemovePoints(teamID string, cat string, points int) error // DELETE /admin/state/points/<teamID>/<cat>/<points> RemovePoints(teamID string, cat string, points int) error // DELETE /admin/state/points/<teamID>/<cat>/<points>
// RemovePoints(teamID string, cat string, points int, when int64) error // DELETE /admin/state/points/<teamID>/<cat>/<points>/<when> RemovePointsAtTime(teamID string, cat string, points int, when int64) error // DELETE /admin/state/points/<teamID>/<cat>/<points>/<when>
// SetPoints(award.List) error // PUT /admin/state/points SetPoints(award.List) error // PUT /admin/state/points
// TeamIDs() []string // GET /admin/state/team_ids TeamIDs() ([]string, error) // GET /admin/state/team_ids
// SetTeamIDs([] string) error // PUT /admin/state/team_ids SetTeamIDs([] string) error // PUT /admin/state/team_ids
// AddTeamID(teamID string) error // POST /admin/state/team_ids/<teamID> AddTeamID(teamID string) error // POST /admin/state/team_ids/<teamID>
// RemoveTeamID(teamID string) error // DELETE /admin/state/team_ids/<teamID> RemoveTeamID(teamID string) error // DELETE /admin/state/team_ids/<teamID>
// TeamID(teamID string) bool // HEAD /admin/state/team_ids/<teamID> TeamIDExists(teamID string) (bool, error) // HEAD /admin/state/team_ids/<teamID>
// TeamNames() (map[string]string, error) // GET /admin/state/teams TeamNames() map[string]string // GET /admin/state/teams
// SetTeamNames(map[string]string) error // PUT /admin/state/teams SetTeamNames(map[string]string) error // PUT /admin/state/teams
TeamName(teamID string) (string, error) // GET /admin/state/teams/id/<teamID> TeamName(teamID string) (string, error) // GET /admin/state/teams/id/<teamID>
// TeamIDFromName(teamName string) (string, error) // GET /admin/state/teams/name/<teamName> TeamIDFromName(teamName string) (string, error) // GET /admin/state/teams/name/<teamName>
SetTeamName(teamID, teamName string) error // POST /admin/state/teams/id/<teamID>/<teamName> SetTeamName(teamID, teamName string) error // POST /admin/state/teams/id/<teamID>/<teamName>
// DeleteTeamName(teamID string) error // DELETE /admin/state/teams/id/<teamID>, /admin/state/teams/name/<teamName> DeleteTeamName(teamID string) error // DELETE /admin/state/teams/id/<teamID>, /admin/state/teams/name/<teamName>
LogEvent(event, participantID, teamID, cat string, points int, extra ...string) LogEvent(event, participantID, teamID, cat string, points int, extra ...string)
Maintainer Maintainer

View File

@ -48,7 +48,13 @@ type State struct {
teamNames map[string]string teamNames map[string]string
pointsLog award.List pointsLog award.List
messages string messages string
lock sync.RWMutex teamIDLock sync.RWMutex
teamIDFileLock sync.RWMutex
teamNameLock sync.RWMutex
teamNameFileLock sync.RWMutex
pointsLock sync.RWMutex
pointsLogFileLock sync.RWMutex // Sometimes, we need to fiddle with the file, while leaving the internal state alone
messageFileLock sync.RWMutex
} }
// NewState returns a new State struct backed by the given Fs // NewState returns a new State struct backed by the given Fs
@ -127,33 +133,167 @@ func (s *State) updateEnabled() {
} }
} }
/* ****************** Team ID functions ****************** */
func (s *State) TeamIDs() ([]string, error) {
var teamIDs []string
s.teamIDFileLock.RLock()
defer s.teamIDFileLock.RUnlock()
idsFile, err := s.Open("teamids.txt")
if err != nil {
return teamIDs, fmt.Errorf("team IDs file does not exist")
}
defer idsFile.Close()
scanner := bufio.NewScanner(idsFile)
for scanner.Scan() {
teamIDs = append(teamIDs, scanner.Text())
}
return teamIDs, nil
}
func (s *State) writeTeamIDs(teamIDs []string) error {
s.teamIDFileLock.Lock()
defer s.teamIDFileLock.Unlock()
if f, err := s.OpenFile("teamids.txt", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
defer f.Close()
for _, teamID := range teamIDs {
fmt.Fprintln(f, string(teamID))
}
} else {
return err
}
return nil
}
func (s *State) SetTeamIDs(teamIDs []string) error {
s.teamIDLock.Lock()
defer s.teamIDLock.Unlock()
return s.writeTeamIDs(teamIDs)
}
func (s *State) AddTeamID(newTeamID string) error {
s.teamIDLock.Lock()
defer s.teamIDLock.Unlock()
teamIDs, err := s.TeamIDs()
if err != nil {
return err
}
for _, teamID := range teamIDs {
if newTeamID == teamID {
return fmt.Errorf("Team ID already exists")
}
}
teamIDs = append(teamIDs, newTeamID)
return s.writeTeamIDs(teamIDs)
}
func (s *State) RemoveTeamID(removeTeamID string) error {
s.teamIDLock.Lock()
defer s.teamIDLock.Unlock()
teamIDs, err := s.TeamIDs()
if err != nil {
return err
}
for _, teamID := range teamIDs {
if removeTeamID != teamID {
teamIDs = append(teamIDs, teamID)
}
}
return s.writeTeamIDs(teamIDs)
}
func (s *State) TeamIDExists(teamID string) (bool, error) {
s.teamIDLock.RLock()
defer s.teamIDLock.RUnlock()
teamIDs, err := s.TeamIDs()
if err != nil {
return false, err
}
for _, candidateTeamID := range teamIDs {
if teamID == candidateTeamID {
return true, nil
}
}
return false, nil
}
/* ********************* Team Name functions ********* */
// TeamName returns team name given a team ID. // TeamName returns team name given a team ID.
func (s *State) TeamName(teamID string) (string, error) { func (s *State) TeamName(teamID string) (string, error) {
s.lock.RLock() s.teamNameLock.RLock()
defer s.teamNameLock.RUnlock()
name, ok := s.teamNames[teamID] name, ok := s.teamNames[teamID]
s.lock.RUnlock()
if !ok { if !ok {
return "", fmt.Errorf("unregistered team ID: %s", teamID) return "", fmt.Errorf("unregistered team ID: %s", teamID)
} }
return name, nil return name, nil
} }
func (s *State) TeamNames() map[string]string {
var teamNames map[string]string
s.teamNameFileLock.RLock()
defer s.teamNameFileLock.RUnlock()
teamsFs := afero.NewBasePathFs(s.Fs, "teams")
if dirents, err := afero.ReadDir(teamsFs, "."); err != nil {
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))
teamNames[teamID] = teamName
}
}
}
return teamNames
}
// SetTeamName writes out team name. // SetTeamName writes out team name.
// This can only be done once per team. // This can only be done once per team.
func (s *State) SetTeamName(teamID, teamName string) error { func (s *State) SetTeamName(teamID, teamName string) error {
idsFile, err := s.Open("teamids.txt") teamIDs, err := s.TeamIDs()
if err != nil { if err != nil {
return fmt.Errorf("team IDs file does not exist") return err
} }
defer idsFile.Close()
found := false found := false
scanner := bufio.NewScanner(idsFile) for _, validTeamID := range teamIDs {
for scanner.Scan() { if validTeamID == teamID {
if scanner.Text() == teamID {
found = true found = true
break break
} }
} }
if !found { if !found {
return fmt.Errorf("team ID not found in list of valid team IDs") return fmt.Errorf("team ID not found in list of valid team IDs")
} }
@ -175,32 +315,69 @@ func (s *State) SetTeamName(teamID, teamName string) error {
return nil return nil
} }
// PointsLog retrieves the current points log. func (s *State) SetTeamNames(teams map[string]string) error {
func (s *State) PointsLog() award.List { return s.writeTeamNames(teams)
s.lock.RLock()
ret := make(award.List, len(s.pointsLog))
copy(ret, s.pointsLog)
s.lock.RUnlock()
return ret
} }
// Messages retrieves the current messages. func (s *State) TeamIDFromName(teamName string) (string, error) {
func (s *State) Messages() string { for name, id := range s.TeamNames() {
s.lock.RLock() // It's not clear to me that this actually needs to happen if name == teamName {
defer s.lock.RUnlock() return id, nil
return s.messages }
}
return "", fmt.Errorf("team name not found")
} }
// SetMessages sets the current message func (s *State) DeleteTeamName(teamID string) error {
func (s *State) SetMessages(message string) error { newTeams := s.TeamNames()
s.lock.Lock()
defer s.lock.Unlock()
err := afero.WriteFile(s, "messages.html", []byte(message), 0600) _, ok := newTeams[teamID];
if ok {
delete(newTeams, teamID)
} else {
return fmt.Errorf("team not found")
}
return s.writeTeamNames(newTeams)
}
func (s *State) writeTeamNames(teams map[string]string) error {
s.teamNameFileLock.Lock()
defer s.teamNameFileLock.Unlock()
s.RemoveAll("teams")
s.Mkdir("teams", 0755)
// Write out all of the new team names
for teamID, teamName := range teams {
teamFilename := filepath.Join("teams", teamID)
teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
if err != nil {
return err
}
defer teamFile.Close()
log.Printf("Setting team name [%s] in file %s", teamName, teamFilename)
fmt.Fprintln(teamFile, teamName)
teamFile.Close()
}
s.refreshNow <- true s.refreshNow <- true
return err return nil
}
/* **************** Point log functions ************ */
// PointsLog retrieves the current points log.
func (s *State) PointsLog() award.List {
s.pointsLock.RLock()
ret := make(award.List, len(s.pointsLog))
copy(ret, s.pointsLog)
s.pointsLock.RUnlock()
return ret
} }
// AwardPoints gives points to teamID in category. // AwardPoints gives points to teamID in category.
@ -213,6 +390,10 @@ func (s *State) AwardPoints(teamID, category string, points int) error {
return s.awardPointsAtTime(time.Now().Unix(), teamID, category, points) return s.awardPointsAtTime(time.Now().Unix(), teamID, category, points)
} }
func (s *State) AwardPointsAtTime(teamID, category string, points int, when int64) error {
return s.awardPointsAtTime(when, teamID, category, points)
}
func (s *State) awardPointsAtTime(when int64, teamID string, category string, points int) error { func (s *State) awardPointsAtTime(when int64, teamID string, category string, points int) error {
a := award.T{ a := award.T{
When: when, When: when,
@ -246,6 +427,119 @@ func (s *State) awardPointsAtTime(when int64, teamID string, category string, po
return nil return nil
} }
func (s *State) PointExists(teamID string, cat string, points int) bool {
for _, pointEntry := range s.pointsLog {
if (pointEntry.TeamID == teamID) && (pointEntry.Category == cat) && (pointEntry.Points == points) {
return true
}
}
return false
}
func (s *State) PointExistsAtTime(teamID string, cat string, points int, when int64) bool {
for _, pointEntry := range s.pointsLog {
if (pointEntry.TeamID == teamID) && (pointEntry.Category == cat) && (pointEntry.Points == points) && (pointEntry.When == when) {
return true
}
if (pointEntry.When > when) { // Since the points log is sorted, we can bail out earlier, if we see that current points are from later than our event
return false
}
}
return false
}
func (s *State) flushPointsLog(newPoints award.List) error {
s.pointsLogFileLock.Lock()
defer s.pointsLogFileLock.Unlock()
logf, err := s.OpenFile("points.log", os.O_CREATE|os.O_WRONLY, 0644)
defer logf.Close()
if err != nil {
return fmt.Errorf("Can't write to points log: ", err)
}
for _, pointEntry := range newPoints {
fmt.Fprintln(logf, pointEntry.String())
}
return nil
}
func (s *State) RemovePoints(teamID string, cat string, points int) error {
s.pointsLock.Lock()
defer s.pointsLock.Unlock()
var newPoints award.List
removed := false
for _, pointEntry := range s.pointsLog {
if (pointEntry.TeamID == teamID) && (pointEntry.Category == cat) && (pointEntry.Points == points) {
removed = true
} else {
newPoints = append(newPoints, pointEntry)
}
}
if (! removed) {
return fmt.Errorf("Unable to find matching point entry")
}
err := s.flushPointsLog(newPoints)
if err != nil {
return err
}
s.refreshNow <- true
return nil
}
func (s *State) RemovePointsAtTime(teamID string, cat string, points int, when int64) error {
s.pointsLock.Lock()
defer s.pointsLock.Unlock()
var newPoints award.List
removed := false
for _, pointEntry := range s.pointsLog {
if (pointEntry.TeamID == teamID) && (pointEntry.Category == cat) && (pointEntry.Points == points) && (pointEntry.When == when) {
removed = true
} else {
newPoints = append(newPoints, pointEntry)
}
}
if (! removed) {
return fmt.Errorf("Unable to find matching point entry")
}
err := s.flushPointsLog(newPoints)
if err != nil {
return err
}
s.refreshNow <- true
return nil
}
func (s *State) SetPoints(newPoints award.List) error {
err := s.flushPointsLog(newPoints)
if err != nil {
return err
}
s.refreshNow <- true
return nil
}
// collectPoints gathers up files in points.new/ and appends their contents to points.log, // collectPoints gathers up files in points.new/ and appends their contents to points.log,
// removing each points.new/ file as it goes. // removing each points.new/ file as it goes.
func (s *State) collectPoints() { func (s *State) collectPoints() {
@ -268,20 +562,23 @@ func (s *State) collectPoints() {
} }
duplicate := false duplicate := false
s.lock.RLock() s.pointsLock.RLock()
for _, e := range s.pointsLog { for _, e := range s.pointsLog {
if awd.Equal(e) { if awd.Equal(e) {
duplicate = true duplicate = true
break break
} }
} }
s.lock.RUnlock() s.pointsLock.RUnlock()
if duplicate { if duplicate {
log.Print("Skipping duplicate points: ", awd.String()) log.Print("Skipping duplicate points: ", awd.String())
} else { } else {
log.Print("Award: ", awd.String()) log.Print("Award: ", awd.String())
s.pointsLogFileLock.Lock()
defer s.pointsLogFileLock.Unlock()
logf, err := s.OpenFile("points.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) logf, err := s.OpenFile("points.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
log.Print("Can't append to points log: ", err) log.Print("Can't append to points log: ", err)
@ -291,9 +588,9 @@ func (s *State) collectPoints() {
logf.Close() logf.Close()
// Stick this on the cache too // Stick this on the cache too
s.lock.Lock() s.pointsLock.Lock()
defer s.pointsLock.Unlock()
s.pointsLog = append(s.pointsLog, awd) s.pointsLog = append(s.pointsLog, awd)
s.lock.Unlock()
} }
if err := s.Remove(filename); err != nil { if err := s.Remove(filename); err != nil {
@ -302,24 +599,57 @@ func (s *State) collectPoints() {
} }
} }
/* ******************* Message functions *********** */
// Messages retrieves the current messages.
func (s *State) Messages() string {
return s.messages
}
// SetMessages sets the current message
func (s *State) SetMessages(message string) error {
s.messageFileLock.Lock()
defer s.messageFileLock.Unlock()
err := afero.WriteFile(s, "messages.html", []byte(message), 0600)
s.refreshNow <- true
return err
}
/* ***************** Other utilitity functions ******* */
func (s *State) maybeInitialize() { func (s *State) maybeInitialize() {
// Are we supposed to re-initialize? // Are we supposed to re-initialize?
if _, err := s.Stat("initialized"); !os.IsNotExist(err) { if _, err := s.Stat("initialized"); !os.IsNotExist(err) {
return return
} }
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
log.Print("initialized file missing, re-initializing") log.Print("initialized file missing, re-initializing")
// Remove any extant control and state files // Remove any extant control and state files
s.Remove("enabled") s.Remove("enabled")
s.Remove("hours.txt") s.Remove("hours.txt")
s.pointsLogFileLock.Lock()
s.Remove("points.log") s.Remove("points.log")
s.pointsLogFileLock.Unlock()
s.messageFileLock.Lock()
s.Remove("messages.html") s.Remove("messages.html")
s.messageFileLock.Unlock()
s.Remove("mothd.log") s.Remove("mothd.log")
s.RemoveAll("points.tmp") s.RemoveAll("points.tmp")
s.RemoveAll("points.new") s.RemoveAll("points.new")
s.teamNameFileLock.Lock()
s.RemoveAll("teams") s.RemoveAll("teams")
s.Mkdir("teams", 0755)
s.teamNameFileLock.Unlock()
// Open log file // Open log file
if err := s.reopenEventLog(); err != nil { if err := s.reopenEventLog(); err != nil {
@ -330,20 +660,18 @@ func (s *State) maybeInitialize() {
// Make sure various subdirectories exist // Make sure various subdirectories exist
s.Mkdir("points.tmp", 0755) s.Mkdir("points.tmp", 0755)
s.Mkdir("points.new", 0755) s.Mkdir("points.new", 0755)
s.Mkdir("teams", 0755)
// Preseed available team ids if file doesn't exist // Preseed available team ids if file doesn't exist
if f, err := s.OpenFile("teamids.txt", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil { var teamIDs []string
id := make([]byte, 8) id := make([]byte, 8)
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
for i := range id { for i := range id {
char := rand.Intn(len(DistinguishableChars)) char := rand.Intn(len(DistinguishableChars))
id[i] = DistinguishableChars[char] id[i] = DistinguishableChars[char]
}
fmt.Fprintln(f, string(id))
} }
f.Close() teamIDs = append(teamIDs, string(id))
} }
s.SetTeamIDs(teamIDs)
// Create some files // Create some files
if f, err := s.Create("initialized"); err == nil { if f, err := s.Create("initialized"); err == nil {
@ -373,10 +701,12 @@ func (s *State) maybeInitialize() {
f.Close() f.Close()
} }
s.messageFileLock.Lock()
if f, err := s.Create("messages.html"); err == nil { if f, err := s.Create("messages.html"); err == nil {
fmt.Fprintln(f, "<!-- messages.html: put client broadcast messages here. -->") fmt.Fprintln(f, "<!-- messages.html: put client broadcast messages here. -->")
f.Close() f.Close()
} }
s.messageFileLock.Unlock()
if f, err := s.Create("points.log"); err == nil { if f, err := s.Create("points.log"); err == nil {
f.Close() f.Close()
@ -418,8 +748,11 @@ func (s *State) reopenEventLog() error {
} }
func (s *State) updateCaches() { func (s *State) updateCaches() {
s.lock.Lock() s.pointsLock.RLock()
defer s.lock.Unlock() defer s.pointsLock.RUnlock()
s.pointsLogFileLock.RLock()
defer s.pointsLogFileLock.RUnlock()
if f, err := s.Open("points.log"); err != nil { if f, err := s.Open("points.log"); err != nil {
log.Println(err) log.Println(err)
@ -437,32 +770,28 @@ func (s *State) updateCaches() {
} }
pointsLog = append(pointsLog, cur) pointsLog = append(pointsLog, cur)
} }
s.pointsLog = pointsLog s.pointsLog = pointsLog
} }
{ {
s.teamNameLock.Lock()
defer s.teamNameLock.Unlock()
// The compiler recognizes this as an optimization case // The compiler recognizes this as an optimization case
for k := range s.teamNames { for k := range s.teamNames {
delete(s.teamNames, k) delete(s.teamNames, k)
} }
teamsFs := afero.NewBasePathFs(s.Fs, "teams") for teamID, teamName := range s.TeamNames() {
if dirents, err := afero.ReadDir(teamsFs, "."); err != nil { s.teamNames[teamID] = teamName
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
}
}
} }
} }
s.messageFileLock.RLock()
defer s.messageFileLock.RUnlock()
if bMessages, err := afero.ReadFile(s, "messages.html"); err == nil { if bMessages, err := afero.ReadFile(s, "messages.html"); err == nil {
s.messages = string(bMessages) s.messages = string(bMessages)
} }