diff --git a/cmd/mothd/award.go b/cmd/mothd/award.go deleted file mode 100644 index 9f06425..0000000 --- a/cmd/mothd/award.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "strings" -) - -type Award struct { - // Unix epoch time of this event - When int64 - TeamID string - Category string - Points int -} - -type AwardList []*Award - -// Implement sort.Interface on AwardList -func (awards AwardList) Len() int { - return len(awards) -} - -func (awards AwardList) Less(i, j int) bool { - return awards[i].When < awards[j].When -} - -func (awards AwardList) Swap(i, j int) { - tmp := awards[i] - awards[i] = awards[j] - awards[j] = tmp -} - -func ParseAward(s string) (*Award, error) { - ret := Award{} - - s = strings.TrimSpace(s) - - 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) - } - - return &ret, nil -} - -func (a *Award) String() string { - return fmt.Sprintf("%d %s %s %d", a.When, a.TeamID, a.Category, a.Points) -} - -func (a *Award) MarshalJSON() ([]byte, error) { - if a == nil { - return []byte("null"), nil - } - ao := []interface{}{ - a.When, - a.TeamID, - a.Category, - a.Points, - } - - return json.Marshal(ao) -} - -func (a *Award) Same(o *Award) bool { - switch { - case a.TeamID != o.TeamID: - return false - case a.Category != o.Category: - return false - case a.Points != o.Points: - return false - } - return true -} diff --git a/cmd/mothd/award_test.go b/cmd/mothd/award_test.go deleted file mode 100644 index 12eb536..0000000 --- a/cmd/mothd/award_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "sort" - "testing" -) - -func TestAward(t *testing.T) { - entry := "1536958399 1a2b3c4d counting 1" - a, err := ParseAward(entry) - if err != nil { - t.Error(err) - return - } - if a.TeamID != "1a2b3c4d" { - t.Error("TeamID parsed wrong") - } - if a.Category != "counting" { - t.Error("Category parsed wrong") - } - if a.Points != 1 { - t.Error("Points parsed wrong") - } - - if a.String() != entry { - t.Error("String conversion wonky") - } - - if ja, err := a.MarshalJSON(); err != nil { - t.Error(err) - } else if string(ja) != `[1536958399,"1a2b3c4d","counting",1]` { - t.Error("JSON wrong") - } - - if _, err := ParseAward("bad bad bad 1"); err == nil { - t.Error("Not throwing error on bad timestamp") - } - if _, err := ParseAward("1 bad bad bad"); err == nil { - t.Error("Not throwing error on bad points") - } -} - -func TestAwardList(t *testing.T) { - a, _ := ParseAward("1536958399 1a2b3c4d counting 1") - b, _ := ParseAward("1536958400 1a2b3c4d counting 1") - c, _ := ParseAward("1536958300 1a2b3c4d counting 1") - list := AwardList{a, b, c} - - if sort.IsSorted(list) { - t.Error("Unsorted list thinks it's sorted") - } - - sort.Stable(list) - if (list[0] != c) || (list[1] != a) || (list[2] != b) { - t.Error("Sorting didn't") - } - - if !sort.IsSorted(list) { - t.Error("Sorted list thinks it isn't") - } -} diff --git a/cmd/mothd/httpd.go b/cmd/mothd/httpd.go index e62725b..7342260 100644 --- a/cmd/mothd/httpd.go +++ b/cmd/mothd/httpd.go @@ -5,6 +5,8 @@ import ( "net/http" "strconv" "strings" + + "github.com/dirtbags/moth/pkg/jsend" ) // HTTPServer is a MOTH HTTP server @@ -44,7 +46,7 @@ func (h *HTTPServer) HandleMothFunc( // ServeHTTP provides the http.Handler interface func (h *HTTPServer) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) { - w := MothResponseWriter{ + w := StatusResponseWriter{ statusCode: new(int), ResponseWriter: wOrig, } @@ -58,14 +60,14 @@ func (h *HTTPServer) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) { ) } -// MothResponseWriter provides a ResponseWriter that remembers what the status code was -type MothResponseWriter struct { +// StatusResponseWriter provides a ResponseWriter that remembers what the status code was +type StatusResponseWriter struct { statusCode *int http.ResponseWriter } // WriteHeader sends an HTTP response header with the provided status code -func (w MothResponseWriter) WriteHeader(statusCode int) { +func (w StatusResponseWriter) WriteHeader(statusCode int) { *w.statusCode = statusCode w.ResponseWriter.WriteHeader(statusCode) } @@ -94,16 +96,16 @@ func (h *HTTPServer) ThemeHandler(mh MothRequestHandler, w http.ResponseWriter, // StateHandler returns the full JSON-encoded state of the event func (h *HTTPServer) StateHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) { - JSONWrite(w, mh.ExportState()) + jsend.JSONWrite(w, mh.ExportState()) } // RegisterHandler handles attempts to register a team func (h *HTTPServer) RegisterHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) { teamName := req.FormValue("name") if err := mh.Register(teamName); err != nil { - JSendf(w, JSendFail, "not registered", err.Error()) + jsend.Sendf(w, jsend.Fail, "not registered", err.Error()) } else { - JSendf(w, JSendSuccess, "registered", "Team ID registered") + jsend.Sendf(w, jsend.Success, "registered", "Team ID registered") } } @@ -116,9 +118,9 @@ func (h *HTTPServer) AnswerHandler(mh MothRequestHandler, w http.ResponseWriter, points, _ := strconv.Atoi(pointstr) if err := mh.CheckAnswer(cat, points, answer); err != nil { - JSendf(w, JSendFail, "not accepted", err.Error()) + jsend.Sendf(w, jsend.Fail, "not accepted", err.Error()) } else { - JSendf(w, JSendSuccess, "accepted", "%d points awarded in %s", points, cat) + jsend.Sendf(w, jsend.Success, "accepted", "%d points awarded in %s", points, cat) } } diff --git a/cmd/mothd/main.go b/cmd/mothd/main.go index 4da75dc..adc7f6d 100644 --- a/cmd/mothd/main.go +++ b/cmd/mothd/main.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/afero" ) -func custodian(updateInterval time.Duration, components []Component) { +func custodian(updateInterval time.Duration, components []Provider) { update := func() { for _, c := range components { c.Update() @@ -39,7 +39,7 @@ func main() { mothballPath := flag.String( "mothballs", "mothballs", - "Path to mothballs to host", + "Path to mothball files", ) refreshInterval := flag.Duration( "refresh", @@ -67,7 +67,7 @@ func main() { mime.AddExtensionType(".json", "application/json") mime.AddExtensionType(".zip", "application/zip") - go custodian(*refreshInterval, []Component{theme, state, puzzles}) + go custodian(*refreshInterval, []Provider{theme, state, puzzles}) server := NewMothServer(puzzles, theme, state) httpd := NewHTTPServer(*base, server) diff --git a/cmd/mothd/main_test.go b/cmd/mothd/main_test.go new file mode 100644 index 0000000..250f9cb --- /dev/null +++ b/cmd/mothd/main_test.go @@ -0,0 +1,12 @@ +package main + +import ( + "testing" +) + +func TestEverything(t *testing.T) { + state := NewTestState() + t.Error("No test") + + state.Update() +} diff --git a/cmd/mothd/mothballs.go b/cmd/mothd/mothballs.go index bca7e42..8e93bd9 100644 --- a/cmd/mothd/mothballs.go +++ b/cmd/mothd/mothballs.go @@ -1,20 +1,23 @@ package main import ( - "github.com/spf13/afero" - "log" - "strings" "bufio" - "strconv" - "time" "fmt" + "log" + "strconv" + "strings" + "time" + + "github.com/spf13/afero" ) +// Mothballs provides a collection of active mothball files (puzzle categories) type Mothballs struct { categories map[string]*Zipfs afero.Fs } +// NewMothballs returns a new Mothballs structure backed by the provided directory func NewMothballs(fs afero.Fs) *Mothballs { return &Mothballs{ Fs: fs, @@ -22,9 +25,10 @@ func NewMothballs(fs afero.Fs) *Mothballs { } } +// Open returns a ReadSeekCloser corresponding to the filename in a puzzle's category and points func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) { mb, ok := m.categories[cat] - if ! ok { + if !ok { return nil, time.Time{}, fmt.Errorf("No such category: %s", cat) } @@ -32,6 +36,7 @@ func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekClose return f, mb.ModTime(), err } +// Inventory returns the list of current categories func (m *Mothballs) Inventory() []Category { categories := make([]Category, 0, 20) for cat, zfs := range m.categories { @@ -55,18 +60,19 @@ func (m *Mothballs) Inventory() []Category { return categories } +// CheckAnswer returns an error if the provided answer is in any way incorrect for the given category and points func (m *Mothballs) CheckAnswer(cat string, points int, answer string) error { zfs, ok := m.categories[cat] - if ! ok { + if !ok { return fmt.Errorf("No such category: %s", cat) } - + af, err := zfs.Open("answers.txt") if err != nil { return fmt.Errorf("No answers.txt file") } defer af.Close() - + needle := fmt.Sprintf("%d %s", points, answer) scanner := bufio.NewScanner(af) for scanner.Scan() { @@ -78,6 +84,8 @@ func (m *Mothballs) CheckAnswer(cat string, points int, answer string) error { return fmt.Errorf("Invalid answer") } +// Update refreshes internal state. +// It looks for changes to the directory listing, and caches any new mothballs. func (m *Mothballs) Update() { // Any new categories? files, err := afero.ReadDir(m.Fs, "/") diff --git a/cmd/mothd/mothballs_test.go b/cmd/mothd/mothballs_test.go new file mode 100644 index 0000000..24cee09 --- /dev/null +++ b/cmd/mothd/mothballs_test.go @@ -0,0 +1,9 @@ +package main + +import ( + "testing" +) + +func TestMothballs(t *testing.T) { + t.Error("moo") +} diff --git a/cmd/mothd/server.go b/cmd/mothd/server.go index 2fcdf31..df672ad 100644 --- a/cmd/mothd/server.go +++ b/cmd/mothd/server.go @@ -5,60 +5,71 @@ import ( "io" "strconv" "time" + + "github.com/dirtbags/moth/pkg/award" ) +// Category represents a puzzle category. type Category struct { Name string Puzzles []int } +// ReadSeekCloser defines a struct that can read, seek, and close. type ReadSeekCloser interface { io.Reader io.Seeker io.Closer } +// StateExport is given to clients requesting the current state. type StateExport struct { Config struct { Devel bool } Messages string TeamNames map[string]string - PointsLog []Award + PointsLog award.List Puzzles map[string][]int } +// PuzzleProvider defines what's required to provide puzzles. type PuzzleProvider interface { Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) Inventory() []Category CheckAnswer(cat string, points int, answer string) error - Component + Provider } +// ThemeProvider defines what's required to provide a theme. type ThemeProvider interface { Open(path string) (ReadSeekCloser, time.Time, error) - Component + Provider } +// StateProvider defines what's required to provide MOTH state. type StateProvider interface { Messages() string - PointsLog() []*Award - TeamName(teamId string) (string, error) - SetTeamName(teamId, teamName string) error - AwardPoints(teamId string, cat string, points int) error - Component + PointsLog() award.List + TeamName(teamID string) (string, error) + SetTeamName(teamID, teamName string) error + AwardPoints(teamID string, cat string, points int) error + Provider } -type Component interface { +// Provider defines providers that can be updated. +type Provider interface { Update() } +// MothServer gathers together the providers that make up a MOTH server. type MothServer struct { Puzzles PuzzleProvider Theme ThemeProvider State StateProvider } +// NewMothServer returns a new MothServer. func NewMothServer(puzzles PuzzleProvider, theme ThemeProvider, state StateProvider) *MothServer { return &MothServer{ Puzzles: puzzles, @@ -67,21 +78,23 @@ func NewMothServer(puzzles PuzzleProvider, theme ThemeProvider, state StateProvi } } -func (s *MothServer) NewHandler(teamId string) MothRequestHandler { +// NewHandler returns a new http.RequestHandler for the provided teamID. +func (s *MothServer) NewHandler(teamID string) MothRequestHandler { return MothRequestHandler{ MothServer: s, - teamId: teamId, + teamID: teamID, } } -// XXX: Come up with a better name for this. +// MothRequestHandler provides http.RequestHandler for a MothServer. type MothRequestHandler struct { *MothServer - teamId string + teamID string } +// PuzzlesOpen opens a file associated with a puzzle. func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (ReadSeekCloser, time.Time, error) { - export := mh.ExportAllState() + export := mh.ExportState() fmt.Println(export.Puzzles) for _, p := range export.Puzzles[cat] { fmt.Println(points, p) @@ -93,93 +106,91 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) ( return nil, time.Time{}, fmt.Errorf("Puzzle locked") } +// ThemeOpen opens a file from a theme. func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, error) { return mh.Theme.Open(path) } +// Register associates a team name with a team ID. func (mh *MothRequestHandler) Register(teamName string) error { // XXX: Should we just return success if the team is already registered? // XXX: Should this function be renamed to Login? if teamName == "" { return fmt.Errorf("Empty team name") } - return mh.State.SetTeamName(mh.teamId, teamName) + return mh.State.SetTeamName(mh.teamID, teamName) } +// 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 { if err := mh.Puzzles.CheckAnswer(cat, points, answer); err != nil { return err } - if err := mh.State.AwardPoints(mh.teamId, cat, points); err != nil { + if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil { return err } return nil } -func (mh *MothRequestHandler) ExportAllState() *StateExport { +// ExportState anonymizes team IDs and returns StateExport. +// If a teamID has been specified for this MothRequestHandler, +// the anonymized team name for this teamID has the special value "self". +// If not, the puzzles list is empty. +func (mh *MothRequestHandler) ExportState() *StateExport { export := StateExport{} - teamName, _ := mh.State.TeamName(mh.teamId) + teamName, _ := mh.State.TeamName(mh.teamID) export.Messages = mh.State.Messages() export.TeamNames = map[string]string{"self": teamName} // Anonymize team IDs in points log, and write out team names pointsLog := mh.State.PointsLog() - exportIds := map[string]string{mh.teamId: "self"} + exportIDs := map[string]string{mh.teamID: "self"} maxSolved := map[string]int{} - export.PointsLog = make([]Award, len(pointsLog)) - for logno, award := range pointsLog { - exportAward := *award - if id, ok := exportIds[award.TeamID]; ok { - exportAward.TeamID = id + export.PointsLog = make(award.List, len(pointsLog)) + for logno, awd := range pointsLog { + if id, ok := exportIDs[awd.TeamID]; ok { + awd.TeamID = id } else { - exportId := strconv.Itoa(logno) - name, _ := mh.State.TeamName(award.TeamID) - exportAward.TeamID = exportId - exportIds[award.TeamID] = exportAward.TeamID - export.TeamNames[exportId] = name + exportID := strconv.Itoa(logno) + name, _ := mh.State.TeamName(awd.TeamID) + awd.TeamID = exportID + exportIDs[awd.TeamID] = awd.TeamID + export.TeamNames[exportID] = name } - export.PointsLog[logno] = exportAward + export.PointsLog[logno] = awd // Record the highest-value unlocked puzzle in each category - if award.Points > maxSolved[award.Category] { - maxSolved[award.Category] = award.Points + if awd.Points > maxSolved[awd.Category] { + maxSolved[awd.Category] = awd.Points } } export.Puzzles = make(map[string][]int) - for _, category := range mh.Puzzles.Inventory() { - // Append sentry (end of puzzles) - allPuzzles := append(category.Puzzles, 0) + if _, ok := export.TeamNames["self"]; ok { + // We used to hand this out to everyone, + // but then we got a bad reputation on some secretive blacklist, + // and now the Navy can't register for events. - max := maxSolved[category.Name] + for _, category := range mh.Puzzles.Inventory() { + // Append sentry (end of puzzles) + allPuzzles := append(category.Puzzles, 0) - puzzles := make([]int, 0, len(allPuzzles)) - for i, val := range allPuzzles { - puzzles = allPuzzles[:i+1] - if val > max { - break + max := maxSolved[category.Name] + + puzzles := make([]int, 0, len(allPuzzles)) + for i, val := range allPuzzles { + puzzles = allPuzzles[:i+1] + if val > max { + break + } } + export.Puzzles[category.Name] = puzzles } - export.Puzzles[category.Name] = puzzles } return &export } - -func (mh *MothRequestHandler) ExportState() *StateExport { - export := mh.ExportAllState() - - // We don't give this out to just anybody, - // because back when we did, - // we got a bad reputation on some secretive blacklist, - // and now the Navy can't register for events. - if export.TeamNames["self"] == "" { - export.Puzzles = map[string][]int{} - } - - return export -} diff --git a/cmd/mothd/state.go b/cmd/mothd/state.go index 1bfdfab..5caf843 100644 --- a/cmd/mothd/state.go +++ b/cmd/mothd/state.go @@ -10,12 +10,16 @@ import ( "strings" "time" + "github.com/dirtbags/moth/pkg/award" "github.com/spf13/afero" ) -// Stuff people with mediocre handwriting could write down unambiguously, and can be entered without holding down shift +// DistinguishableChars are visually unambiguous glyphs. +// People with mediocre handwriting could write these down unambiguously, +// and they can be entered without holding down shift. const DistinguishableChars = "234678abcdefhikmnpqrtwxyz=" +// State defines the current state of a MOTH instance. // 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 { @@ -23,6 +27,7 @@ type State struct { Enabled bool } +// NewState returns a new State struct backed by the given Fs func NewState(fs afero.Fs) *State { return &State{ Fs: fs, @@ -30,7 +35,7 @@ func NewState(fs afero.Fs) *State { } } -// Check a few things to see if this state directory is "enabled". +// UpdateEnabled checks a few things to see if this state directory is "enabled". func (s *State) UpdateEnabled() { if _, err := s.Stat("enabled"); os.IsNotExist(err) { s.Enabled = false @@ -81,7 +86,7 @@ func (s *State) UpdateEnabled() { } } -// Returns team name given a team ID. +// TeamName returns team name given a team ID. func (s *State) TeamName(teamID string) (string, error) { // XXX: directory traversal teamFile := filepath.Join("teams", teamID) @@ -97,35 +102,35 @@ func (s *State) TeamName(teamID string) (string, error) { return teamName, nil } -// Write out team name. This can only be done once. +// SetTeamName writes out team name. +// This can only be done once. func (s *State) SetTeamName(teamID, teamName string) error { - if f, err := s.Open("teamids.txt"); err != nil { + f, err := s.Open("teamids.txt") + if err != nil { return fmt.Errorf("Team IDs file does not exist") - } else { - found := false - scanner := bufio.NewScanner(f) - for scanner.Scan() { - if scanner.Text() == teamID { - found = true - break - } - } - f.Close() - if !found { - return fmt.Errorf("Team ID not found in list of valid Team IDs") + } + found := false + scanner := bufio.NewScanner(f) + for scanner.Scan() { + if scanner.Text() == teamID { + found = true + break } } + f.Close() + if !found { + return fmt.Errorf("Team ID not found in list of valid Team IDs") + } teamFile := filepath.Join("teams", teamID) - err := afero.WriteFile(s, teamFile, []byte(teamName), os.ModeExclusive|0644) - if os.IsExist(err) { + if err := afero.WriteFile(s, teamFile, []byte(teamName), os.ModeExclusive|0644); os.IsExist(err) { return fmt.Errorf("Team ID is already registered") } return err } -// Retrieve the current points log -func (s *State) PointsLog() []*Award { +// PointsLog retrieves the current points log. +func (s *State) PointsLog() award.List { f, err := s.Open("points.log") if err != nil { log.Println(err) @@ -133,11 +138,11 @@ func (s *State) PointsLog() []*Award { } defer f.Close() - pointsLog := make([]*Award, 0, 200) + pointsLog := make(award.List, 0, 200) scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() - cur, err := ParseAward(line) + cur, err := award.Parse(line) if err != nil { log.Printf("Skipping malformed award line %s: %s", line, err) continue @@ -147,7 +152,7 @@ func (s *State) PointsLog() []*Award { return pointsLog } -// Retrieve current messages +// Messages retrieves the current messages. func (s *State) Messages() string { bMessages, _ := afero.ReadFile(s, "messages.html") return string(bMessages) @@ -159,7 +164,7 @@ func (s *State) Messages() string { // 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{ + a := award.T{ When: time.Now().Unix(), TeamID: teamID, Category: category, @@ -172,7 +177,7 @@ func (s *State) AwardPoints(teamID, category string, points int) error { } for _, e := range s.PointsLog() { - if a.Same(e) { + if a.Equal(e) { return fmt.Errorf("Points already awarded to this team in this category") } } @@ -208,7 +213,7 @@ func (s *State) collectPoints() { log.Print("Opening new points: ", err) continue } - award, err := ParseAward(string(awardstr)) + awd, err := award.Parse(string(awardstr)) if err != nil { log.Print("Can't parse award file ", filename, ": ", err) continue @@ -216,23 +221,23 @@ func (s *State) collectPoints() { duplicate := false for _, e := range s.PointsLog() { - if award.Same(e) { + if awd.Equal(e) { duplicate = true break } } if duplicate { - log.Print("Skipping duplicate points: ", award.String()) + log.Print("Skipping duplicate points: ", awd.String()) } else { - log.Print("Award: ", award.String()) + log.Print("Award: ", awd.String()) logf, err := s.OpenFile("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()) + fmt.Fprintln(logf, awd.String()) logf.Close() } @@ -268,7 +273,7 @@ func (s *State) maybeInitialize() { // 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 { id := make([]byte, 8) - for i := 0; i < 100; i += 1 { + for i := 0; i < 100; i++ { for i := range id { char := rand.Intn(len(DistinguishableChars)) id[i] = DistinguishableChars[char] @@ -317,6 +322,7 @@ func (s *State) maybeInitialize() { } +// Update performs housekeeping on a State struct. func (s *State) Update() { s.maybeInitialize() s.UpdateEnabled() diff --git a/cmd/mothd/state_test.go b/cmd/mothd/state_test.go index a8ce98f..617a942 100644 --- a/cmd/mothd/state_test.go +++ b/cmd/mothd/state_test.go @@ -8,19 +8,22 @@ import ( "github.com/spf13/afero" ) +func NewTestState() *State { + s := NewState(new(afero.MemMapFs)) + s.Update() + return s +} + func TestState(t *testing.T) { - fs := new(afero.MemMapFs) + s := NewTestState() mustExist := func(path string) { - _, err := fs.Stat(path) + _, err := s.Fs.Stat(path) if os.IsNotExist(err) { t.Errorf("File %s does not exist", path) } } - s := NewState(fs) - s.Update() - pl := s.PointsLog() if len(pl) != 0 { t.Errorf("Empty points log is not empty") @@ -30,38 +33,38 @@ func TestState(t *testing.T) { mustExist("enabled") mustExist("hours") - teamidsBuf, err := afero.ReadFile(fs, "teamids.txt") + teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt") if err != nil { t.Errorf("Reading teamids.txt: %v", err) } - teamids := bytes.Split(teamidsBuf, []byte("\n")) - if (len(teamids) != 101) || (len(teamids[100]) > 0) { - t.Errorf("There weren't 100 teamids, there were %d", len(teamids)) + teamIDs := bytes.Split(teamIDsBuf, []byte("\n")) + if (len(teamIDs) != 101) || (len(teamIDs[100]) > 0) { + t.Errorf("There weren't 100 teamIDs, there were %d", len(teamIDs)) } - teamId := string(teamids[0]) + teamID := string(teamIDs[0]) if err := s.SetTeamName("bad team ID", "bad team name"); err == nil { t.Errorf("Setting bad team ID didn't raise an error") } - if err := s.SetTeamName(teamId, "My Team"); err != nil { + if err := s.SetTeamName(teamID, "My Team"); err != nil { t.Errorf("Setting team name: %v", err) } category := "poot" points := 3928 - s.AwardPoints(teamId, category, points) + s.AwardPoints(teamID, category, points) s.Update() pl = s.PointsLog() if len(pl) != 1 { t.Errorf("After awarding points, points log has length %d", len(pl)) - } else if (pl[0].TeamID != teamId) || (pl[0].Category != category) || (pl[0].Points != points) { + } else if (pl[0].TeamID != teamID) || (pl[0].Category != category) || (pl[0].Points != points) { t.Errorf("Incorrect logged award %v", pl) } - fs.Remove("initialized") + s.Fs.Remove("initialized") s.Update() pl = s.PointsLog() diff --git a/cmd/mothd/theme.go b/cmd/mothd/theme.go index e020fe7..6bd1bf9 100644 --- a/cmd/mothd/theme.go +++ b/cmd/mothd/theme.go @@ -1,36 +1,40 @@ package main import ( - "github.com/spf13/afero" "time" + + "github.com/spf13/afero" ) +// Theme defines a filesystem-backed ThemeProvider. type Theme struct { afero.Fs } +// NewTheme returns a new Theme, backed by Fs. func NewTheme(fs afero.Fs) *Theme { return &Theme{ Fs: fs, } } -// I don't understand why I need this. The type checking system is weird here. +// Open returns a new opened file. func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) { f, err := t.Fs.Open(name) if err != nil { return nil, time.Time{}, err } - + fi, err := f.Stat() if err != nil { f.Close() return nil, time.Time{}, err } - + return f, fi.ModTime(), nil } +// Update performs housekeeping for a Theme. func (t *Theme) Update() { // No periodic tasks for a theme } diff --git a/cmd/mothd/theme_test.go b/cmd/mothd/theme_test.go index eb38056..ad09ee9 100644 --- a/cmd/mothd/theme_test.go +++ b/cmd/mothd/theme_test.go @@ -7,18 +7,21 @@ import ( "github.com/spf13/afero" ) +func NewTestTheme() *Theme { + return NewTheme(new(afero.MemMapFs)) +} + func TestTheme(t *testing.T) { + s := NewTestTheme() + filename := "/index.html" - fs := new(afero.MemMapFs) index := "this is the index" - afero.WriteFile(fs, filename, []byte(index), 0644) - fileInfo, err := fs.Stat(filename) + afero.WriteFile(s.Fs, filename, []byte(index), 0644) + fileInfo, err := s.Fs.Stat(filename) if err != nil { t.Error(err) } - s := NewTheme(fs) - if f, timestamp, err := s.Open("/index.html"); err != nil { t.Error(err) } else if buf, err := ioutil.ReadAll(f); err != nil { diff --git a/cmd/mothd/zipfs.go b/cmd/mothd/zipfs.go index e9d21fe..5847355 100644 --- a/cmd/mothd/zipfs.go +++ b/cmd/mothd/zipfs.go @@ -3,13 +3,15 @@ package main import ( "archive/zip" "fmt" - "github.com/spf13/afero" "io" "io/ioutil" "strings" "time" + + "github.com/spf13/afero" ) +// Zipfs defines a Zip Filesystem structure type Zipfs struct { f io.Closer zf *zip.Reader @@ -164,7 +166,7 @@ func (zfs *Zipfs) Refresh() error { return nil } -func (zfs *Zipfs) ModTime() (time.Time) { +func (zfs *Zipfs) ModTime() time.Time { return zfs.mtime } diff --git a/doc/philosophy.md b/doc/philosophy.md index 5320580..745e76e 100644 --- a/doc/philosophy.md +++ b/doc/philosophy.md @@ -30,3 +30,8 @@ This pretty much set the entire design: * It should be easy to remember in your head everything it does * Server is also compiled * Static type-checking helps assure no run-time errors +* Server only tracks who scored how many points at what time + * This means the scoreboard program determines rankings + * Want to provide a time bonus for quick answers? I don't, but if you do, you can just modify the scoreboard to do so. + * Maybe you want to show a graph of team rankings over time: just replay the event log. + * Want to do some analysis of what puzzles take the longest to answer? It's all there. diff --git a/go.mod b/go.mod index 3ac2f9c..24f4000 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module github.com/dirtbags/moth go 1.13 require ( - github.com/namsral/flag v1.7.4-pre + github.com/namsral/flag v1.7.4-pre // indirect github.com/pkg/sftp v1.11.0 // indirect github.com/russross/blackfriday v2.0.0+incompatible // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect - github.com/spf13/afero v1.2.2 - golang.org/x/tools v0.0.0-20200814172026-c4923e618c08 // indirect - gopkg.in/yaml.v2 v2.3.0 + github.com/spf13/afero v1.3.4 + golang.org/x/tools v0.0.0-20200817190302-118ac038d721 // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect ) diff --git a/go.sum b/go.sum index 3cea630..9a9da8a 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,7 @@ github.com/namsral/flag v1.7.4-pre h1:b2ScHhoCUkbsq0d2C15Mv+VU8bl8hAXV8arnWiOHNZ github.com/namsral/flag v1.7.4-pre/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/sftp v1.11.0 h1:4Zv0OGbpkg4yNuUtH0s8rvoYxRCNyT29NVUo6pgPmxI= github.com/pkg/sftp v1.11.0/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -16,6 +17,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5I github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/afero v1.3.4 h1:8q6vk3hthlpb2SouZcnBVKboxWQWMDNF38bwholZrJc= +github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= @@ -42,9 +45,16 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20u golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200814172026-c4923e618c08 h1:sfBQLM20fzeXhOixVQirwEbuW4PGStP773EXQpsBB6E= golang.org/x/tools v0.0.0-20200814172026-c4923e618c08/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200817023811-d00afeaade8f h1:33yHANSyO/TeglgY9rBhUpX43wtonTXoFOsMRtNB6qE= +golang.org/x/tools v0.0.0-20200817023811-d00afeaade8f/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200817190302-118ac038d721 h1:ZMR6guGpa1BJujpPXaMYw3au+XbIfCsRWe68e6KqBKo= +golang.org/x/tools v0.0.0-20200817190302-118ac038d721/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= diff --git a/award.go b/pkg/award/award.go similarity index 83% rename from award.go rename to pkg/award/award.go index 3e22f50..be39fef 100644 --- a/award.go +++ b/pkg/award/award.go @@ -17,7 +17,7 @@ type T struct { } // List is a collection of award events. -type List []*T +type List []T // Len implements sort.Interface. func (awards List) Len() int { @@ -37,31 +37,28 @@ func (awards List) Swap(i, j int) { } // Parse parses a string log entry into an award.T. -func Parse(s string) (*T, error) { +func Parse(s string) (T, error) { ret := T{} s = strings.TrimSpace(s) n, err := fmt.Sscanf(s, "%d %s %s %d", &ret.When, &ret.TeamID, &ret.Category, &ret.Points) if err != nil { - return nil, err + return ret, err } else if n != 4 { - return nil, fmt.Errorf("Malformed award string: only parsed %d fields", n) + return ret, fmt.Errorf("Malformed award string: only parsed %d fields", n) } - return &ret, nil + return ret, nil } // String returns a log entry string for an award.T. -func (a *T) String() string { +func (a T) String() string { return fmt.Sprintf("%d %s %s %d", a.When, a.TeamID, a.Category, a.Points) } // MarshalJSON returns the award event, encoded as a list. -func (a *T) MarshalJSON() ([]byte, error) { - if a == nil { - return []byte("null"), nil - } +func (a T) MarshalJSON() ([]byte, error) { ao := []interface{}{ a.When, a.TeamID, @@ -74,7 +71,7 @@ func (a *T) MarshalJSON() ([]byte, error) { // Equal returns true if two award events represent the same award. // Timestamps are ignored in this comparison! -func (a *T) Equal(o *T) bool { +func (a T) Equal(o T) bool { switch { case a.TeamID != o.TeamID: return false diff --git a/award_test.go b/pkg/award/award_test.go similarity index 100% rename from award_test.go rename to pkg/award/award_test.go diff --git a/cmd/mothd/jsend.go b/pkg/jsend/jsend.go similarity index 53% rename from cmd/mothd/jsend.go rename to pkg/jsend/jsend.go index 3c461aa..66dccaa 100644 --- a/cmd/mothd/jsend.go +++ b/pkg/jsend/jsend.go @@ -1,4 +1,4 @@ -package main +package jsend import ( "encoding/json" @@ -10,11 +10,17 @@ import ( // https://github.com/omniti-labs/jsend const ( - JSendSuccess = "success" - JSendFail = "fail" - JSendError = "error" + // Success is the return code indicating "All went well, and (usually) some data was returned". + Success = "success" + + // Fail is the return code indicating "There was a problem with the data submitted, or some pre-condition of the API call wasn't satisfied". + Fail = "fail" + + // Error is the return code indicating "An error occurred in processing the request, i.e. an exception was thrown". + Error = "error" ) +// JSONWrite writes out data as JSON, sending headers and content length func JSONWrite(w http.ResponseWriter, data interface{}) { respBytes, err := json.Marshal(data) if err != nil { @@ -28,7 +34,8 @@ func JSONWrite(w http.ResponseWriter, data interface{}) { w.Write(respBytes) } -func JSend(w http.ResponseWriter, status string, data interface{}) { +// Send sends arbitrary data as a JSend response +func Send(w http.ResponseWriter, status string, data interface{}) { resp := struct { Status string `json:"status"` Data interface{} `json:"data"` @@ -39,7 +46,8 @@ func JSend(w http.ResponseWriter, status string, data interface{}) { JSONWrite(w, resp) } -func JSendf(w http.ResponseWriter, status, short string, format string, a ...interface{}) { +// Sendf sends a Sprintf()-formatted string as a JSend response +func Sendf(w http.ResponseWriter, status, short string, format string, a ...interface{}) { data := struct { Short string `json:"short"` Description string `json:"description"` @@ -47,5 +55,5 @@ func JSendf(w http.ResponseWriter, status, short string, format string, a ...int data.Short = short data.Description = fmt.Sprintf(format, a...) - JSend(w, status, data) + Send(w, status, data) } diff --git a/pkg/jsend/jsend_test.go b/pkg/jsend/jsend_test.go new file mode 100644 index 0000000..4f6b920 --- /dev/null +++ b/pkg/jsend/jsend_test.go @@ -0,0 +1,18 @@ +package jsend + +import ( + "net/http/httptest" + "testing" +) + +func TestEverything(t *testing.T) { + w := httptest.NewRecorder() + + Sendf(w, Success, "You have cows", "You have %d cows", 12) + if w.Result().StatusCode != 200 { + t.Errorf("HTTP Status code: %d", w.Result().StatusCode) + } + if w.Body.String() != `{"status":"success","data":{"short":"You have cows","description":"You have 12 cows"}}` { + t.Errorf("HTTP Body %s", w.Body.Bytes()) + } +} diff --git a/theme/moth.js b/theme/moth.js index 5e3c8fa..ccf2080 100644 --- a/theme/moth.js +++ b/theme/moth.js @@ -176,7 +176,7 @@ function login(e) { 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:"" + let participantId = pide?pide.value:Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) fetch("register", { method: "POST",