package main import ( "fmt" "strconv" "log" "path/filepath" "strings" "os" "io/ioutil" "bufio" "time" "math/rand" ) // Stuff people with mediocre handwriting could write down unambiguously, and can be entered without holding down shift const distinguishableChars = "234678abcdefhijkmnpqrtwxyz=" func mktoken() string { a := make([]byte, 8) for i := range a { char := rand.Intn(len(distinguishableChars)) a[i] = distinguishableChars[char] } return string(a) } type StateExport struct { TeamNames map[string]string PointsLog []Award Messages []string } // We use the filesystem for synchronization between threads. // The only thing State methods need to know is the path to the state directory. type State struct { StateDir string update chan bool } func NewState(stateDir string) (*State) { return &State{ StateDir: stateDir, update: make(chan bool, 10), } } // Returns a cleaned up join of path parts relative to func (s *State) path(parts ...string) string { rel := filepath.Clean(filepath.Join(parts...)) parts = filepath.SplitList(rel) for i, part := range parts { part = strings.TrimLeft(part, "./\\:") parts[i] = part } rel = filepath.Join(parts...) return filepath.Join(s.StateDir, rel) } // Check a few things to see if this state directory is "enabled". func (s *State) Enabled() bool { if _, err := os.Stat(s.path("enabled")); os.IsNotExist(err) { log.Print("Suspended: enabled file missing") return false } untilspec, err := ioutil.ReadFile(s.path("until")) if err == nil { untilspecs := strings.TrimSpace(string(untilspec)) until, err := time.Parse(time.RFC3339, untilspecs) if err != nil { log.Printf("Suspended: Unparseable until date: %s", untilspec) return false } if until.Before(time.Now()) { log.Print("Suspended: until time reached, suspending maintenance") return false } } return true } // Returns team name given a team ID. func (s *State) TeamName(teamId string) (string, error) { teamFile := s.path("teams", teamId) teamNameBytes, err := ioutil.ReadFile(teamFile) teamName := strings.TrimSpace(string(teamNameBytes)) if os.IsNotExist(err) { return "", fmt.Errorf("Unregistered team ID: %s", teamId) } else if err != nil { return "", fmt.Errorf("Unregistered team ID: %s (%s)", teamId, err) } return teamName, nil } // Write out team name. This can only be done once. func (s *State) SetTeamName(teamId string, teamName string) error { teamFile := s.path("teams", teamId) err := ioutil.WriteFile(teamFile, []byte(teamName), os.ModeExclusive | 0644) return err } // Retrieve the current points log func (s *State) PointsLog() ([]*Award) { pointsFile := s.path("points.log") f, err := os.Open(pointsFile) if err != nil { log.Println(err) return nil } defer f.Close() pointsLog := make([]*Award, 0, 200) scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() cur, err := ParseAward(line) if err != nil { log.Printf("Skipping malformed award line %s: %s", line, err) continue } pointsLog = append(pointsLog, cur) } return pointsLog } // Return an exportable points log, // This anonymizes teamId with either an integer, or the string "self" // for the requesting teamId. func (s *State) Export(teamId string) (*StateExport) { teamName, _ := s.TeamName(teamId) pointsLog := s.PointsLog() export := StateExport{ PointsLog: make([]Award, len(pointsLog)), Messages: make([]string, 0, 10), TeamNames: map[string]string{"self": teamName}, } // Read in messages messagesFile := s.path("messages.txt") if f, err := os.Open(messagesFile); err != nil { log.Print(err) } else { defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { message := scanner.Text() if strings.HasPrefix(message, "#") { continue } export.Messages = append(export.Messages, message) } } // Read in points exportIds := map[string]string{teamId: "self"} for logno, award := range pointsLog { exportAward := award if id, ok := exportIds[award.TeamId]; ok { exportAward.TeamId = id } else { exportId := strconv.Itoa(logno) exportAward.TeamId = exportId exportIds[award.TeamId] = exportAward.TeamId name, err := s.TeamName(award.TeamId) if err != nil { name = "Rodney" // https://en.wikipedia.org/wiki/Rogue_(video_game)#Gameplay } export.TeamNames[exportId] = name } export.PointsLog[logno] = *exportAward } return &export } // AwardPoints gives points to teamId in category. // 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 { a := Award{ When: time.Now().Unix(), TeamId: teamId, Category: category, Points: points, } _, err := s.TeamName(teamId) if err != nil { return err } for _, e := range s.PointsLog() { if a.Same(e) { return fmt.Errorf("Points already awarded to this team in this category") } } fn := fmt.Sprintf("%s-%s-%d", teamId, category, points) tmpfn := s.path("points.tmp", fn) newfn := s.path("points.new", fn) if err := ioutil.WriteFile(tmpfn, []byte(a.String()), 0644); err != nil { return err } if err := os.Rename(tmpfn, newfn); err != nil { return err } s.update <- true return nil } // collectPoints gathers up files in points.new/ and appends their contents to points.log, // removing each points.new/ file as it goes. func (s *State) collectPoints() { files, err := ioutil.ReadDir(s.path("points.new")) if err != nil { log.Print(err) return } for _, f := range files { filename := s.path("points.new", f.Name()) awardstr, err := ioutil.ReadFile(filename) if err != nil { log.Print("Opening new points: ", err) continue } award, err := ParseAward(string(awardstr)) if err != nil { log.Print("Can't parse award file ", filename, ": ", err) continue } duplicate := false for _, e := range s.PointsLog() { if award.Same(e) { duplicate = true break } } if duplicate { log.Print("Skipping duplicate points: ", award.String()) } else { log.Print("Award: ", award.String()) logf, err := os.OpenFile(s.path("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { log.Print("Can't append to points log: ", err) return } fmt.Fprintln(logf, award.String()) logf.Close() } if err := os.Remove(filename); err != nil { log.Print("Unable to remove new points file: ", err) } } } func (s *State) maybeInitialize() { // Are we supposed to re-initialize? if _, err := os.Stat(s.path("initialized")); ! os.IsNotExist(err) { return } log.Print("initialized file missing, re-initializing") // Remove any extant control and state files os.Remove(s.path("enabled")) os.Remove(s.path("until")) os.Remove(s.path("points.log")) os.Remove(s.path("messages.txt")) os.RemoveAll(s.path("points.tmp")) os.RemoveAll(s.path("points.new")) os.RemoveAll(s.path("teams")) // Make sure various subdirectories exist os.Mkdir(s.path("points.tmp"), 0755) os.Mkdir(s.path("points.new"), 0755) os.Mkdir(s.path("teams"), 0755) // Preseed available team ids if file doesn't exist if f, err := os.OpenFile(s.path("teamids.txt"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil { defer f.Close() for i := 0; i <= 100; i += 1 { fmt.Fprintln(f, mktoken()) } } // Create some files ioutil.WriteFile( s.path("initialized"), []byte("Remove this file to re-initialized the contest\n"), 0644, ) ioutil.WriteFile( s.path("enabled"), []byte("Remove this file to suspend the contest\n"), 0644, ) ioutil.WriteFile( s.path("until"), []byte("3009-10-31T00:00:00Z\n"), 0644, ) ioutil.WriteFile( s.path("messages.txt"), []byte(fmt.Sprintf("[%s] Initialized.\n", time.Now())), 0644, ) ioutil.WriteFile( s.path("points.log"), []byte(""), 0644, ) } func (s *State) Run(updateInterval time.Duration) { for { s.maybeInitialize() if s.Enabled() { s.collectPoints() } select { case <-s.update: case <-time.After(updateInterval): } } } func main() { s := NewState("./state") go s.Run(2 * time.Second) for { select { case <-time.After(5 * time.Second): } fmt.Println(s.Export("")) } }