From 657a02e773f1cf2fa3ffac468c886c21496cfbca Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Mon, 2 Sep 2019 18:13:37 -0600 Subject: [PATCH] Refactor state functions into their own doodad --- cmd/mothd/award.go | 35 +-- cmd/mothd/state.go | 356 ++++++++++++++++++++++++ cmd/{mothd => mothdv3}/handlers.go | 0 cmd/{mothd => mothdv3}/instance.go | 0 cmd/{mothd => mothdv3}/instance_test.go | 0 cmd/{mothd => mothdv3}/maintenance.go | 0 cmd/{mothd => mothdv3}/mothd.go | 0 cmd/{mothd => mothdv3}/zipfs.go | 0 cmd/{mothd => mothdv3}/zipfs_test.go | 0 {docs => doc}/CREDITS.md | 0 {docs => doc}/devel-server.md | 0 {docs => doc}/dirtbags.svg | 0 {docs => doc}/overview.md | 0 {docs => doc}/philosophy.md | 0 {docs => doc}/tokens.md | 0 {docs => doc}/writing-puzzles.md | 0 16 files changed, 360 insertions(+), 31 deletions(-) create mode 100644 cmd/mothd/state.go rename cmd/{mothd => mothdv3}/handlers.go (100%) rename cmd/{mothd => mothdv3}/instance.go (100%) rename cmd/{mothd => mothdv3}/instance_test.go (100%) rename cmd/{mothd => mothdv3}/maintenance.go (100%) rename cmd/{mothd => mothdv3}/mothd.go (100%) rename cmd/{mothd => mothdv3}/zipfs.go (100%) rename cmd/{mothd => mothdv3}/zipfs_test.go (100%) rename {docs => doc}/CREDITS.md (100%) rename {docs => doc}/devel-server.md (100%) rename {docs => doc}/dirtbags.svg (100%) rename {docs => doc}/overview.md (100%) rename {docs => doc}/philosophy.md (100%) rename {docs => doc}/tokens.md (100%) rename {docs => doc}/writing-puzzles.md (100%) diff --git a/cmd/mothd/award.go b/cmd/mothd/award.go index 4a8ba75..8fa7772 100644 --- a/cmd/mothd/award.go +++ b/cmd/mothd/award.go @@ -1,14 +1,13 @@ package main import ( - "encoding/json" "fmt" "strings" - "time" ) type Award struct { - When time.Time + // Unix epoch time of this event + When int64 TeamId string Category string Points int @@ -19,44 +18,18 @@ func ParseAward(s string) (*Award, error) { s = strings.TrimSpace(s) - var whenEpoch int64 - - n, err := fmt.Sscanf(s, "%d %s %s %d", &whenEpoch, &ret.TeamId, &ret.Category, &ret.Points) + n, err := fmt.Sscanf(s, "%d %s %s %d", &ret.When, &ret.TeamId, &ret.Category, &ret.Points) if err != nil { return nil, err } else if n != 4 { return nil, fmt.Errorf("Malformed award string: only parsed %d fields", n) } - ret.When = time.Unix(whenEpoch, 0) - return &ret, nil } func (a *Award) String() string { - return fmt.Sprintf("%d %s %s %d", a.When.Unix(), a.TeamId, a.Category, a.Points) -} - -func (a *Award) MarshalJSON() ([]byte, error) { - if a == nil { - return []byte("null"), nil - } - jTeamId, err := json.Marshal(a.TeamId) - if err != nil { - return nil, err - } - jCategory, err := json.Marshal(a.Category) - if err != nil { - return nil, err - } - ret := fmt.Sprintf( - "[%d,%s,%s,%d]", - a.When.Unix(), - jTeamId, - jCategory, - a.Points, - ) - return []byte(ret), nil + return fmt.Sprintf("%d %s %s %d", a.When, a.TeamId, a.Category, a.Points) } func (a *Award) Same(o *Award) bool { diff --git a/cmd/mothd/state.go b/cmd/mothd/state.go new file mode 100644 index 0000000..43cf3b1 --- /dev/null +++ b/cmd/mothd/state.go @@ -0,0 +1,356 @@ +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("")) + } +} diff --git a/cmd/mothd/handlers.go b/cmd/mothdv3/handlers.go similarity index 100% rename from cmd/mothd/handlers.go rename to cmd/mothdv3/handlers.go diff --git a/cmd/mothd/instance.go b/cmd/mothdv3/instance.go similarity index 100% rename from cmd/mothd/instance.go rename to cmd/mothdv3/instance.go diff --git a/cmd/mothd/instance_test.go b/cmd/mothdv3/instance_test.go similarity index 100% rename from cmd/mothd/instance_test.go rename to cmd/mothdv3/instance_test.go diff --git a/cmd/mothd/maintenance.go b/cmd/mothdv3/maintenance.go similarity index 100% rename from cmd/mothd/maintenance.go rename to cmd/mothdv3/maintenance.go diff --git a/cmd/mothd/mothd.go b/cmd/mothdv3/mothd.go similarity index 100% rename from cmd/mothd/mothd.go rename to cmd/mothdv3/mothd.go diff --git a/cmd/mothd/zipfs.go b/cmd/mothdv3/zipfs.go similarity index 100% rename from cmd/mothd/zipfs.go rename to cmd/mothdv3/zipfs.go diff --git a/cmd/mothd/zipfs_test.go b/cmd/mothdv3/zipfs_test.go similarity index 100% rename from cmd/mothd/zipfs_test.go rename to cmd/mothdv3/zipfs_test.go diff --git a/docs/CREDITS.md b/doc/CREDITS.md similarity index 100% rename from docs/CREDITS.md rename to doc/CREDITS.md diff --git a/docs/devel-server.md b/doc/devel-server.md similarity index 100% rename from docs/devel-server.md rename to doc/devel-server.md diff --git a/docs/dirtbags.svg b/doc/dirtbags.svg similarity index 100% rename from docs/dirtbags.svg rename to doc/dirtbags.svg diff --git a/docs/overview.md b/doc/overview.md similarity index 100% rename from docs/overview.md rename to doc/overview.md diff --git a/docs/philosophy.md b/doc/philosophy.md similarity index 100% rename from docs/philosophy.md rename to doc/philosophy.md diff --git a/docs/tokens.md b/doc/tokens.md similarity index 100% rename from docs/tokens.md rename to doc/tokens.md diff --git a/docs/writing-puzzles.md b/doc/writing-puzzles.md similarity index 100% rename from docs/writing-puzzles.md rename to doc/writing-puzzles.md