diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb2c9d..e2d725a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - URL parameter to points.json to allow returning only the JSON for a single team by its team id (e.g., points.json?id=abc123). +### Changed +- Abstract state mechanisms so that it is easier to move to different backends ## [3.4.2] - 2019-11-18 ### Fixed diff --git a/src/LegacyMOTHState.go b/src/LegacyMOTHState.go new file mode 100644 index 0000000..282b6e6 --- /dev/null +++ b/src/LegacyMOTHState.go @@ -0,0 +1,292 @@ +package main + +import ( + "bufio" + "fmt" + "io/ioutil" + "log" + "os" + "path" + "strings" + "time" +) + +type LegacyMOTHState struct { + StateDir string + update chan bool + maintenanceInterval time.Duration +} + +func (state *LegacyMOTHState) Initialize() (bool, error) { + + if _, err := os.Stat(state.StateDir); err != nil { + return false, err + } + + state.MaybeInitialize() + + if state.update == nil { + state.update = make(chan bool, 10) + go state.Maintenance(state.maintenanceInterval) + } + return true, nil +} + +func (state *LegacyMOTHState) login(teamName string, token string) (bool, error) { + for a, _ := range state.getTeams() { + if a == token { + f, err := os.OpenFile(state.StatePath("teams", token), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + if os.IsExist(err) { + return true, ErrAlreadyRegistered + } else { + return false, ErrRegistrationError + } + } + defer f.Close() + + fmt.Fprintln(f, teamName) + return true, nil + } + } + + return false, ErrInvalidTeamID +} + +func (state *LegacyMOTHState) StatePath(parts ...string) string { + tail := pathCleanse(parts) + return path.Join(state.StateDir, tail) +} + +func (state *LegacyMOTHState) TeamName(teamId string) (string, error) { + teamNameBytes, err := ioutil.ReadFile(state.StatePath("teams", teamId)) + teamName := strings.TrimSpace(string(teamNameBytes)) + return teamName, err +} + +func (state *LegacyMOTHState) isEnabled() bool { + if _, err := os.Stat(state.StatePath("disabled")); err == nil { + return false + } + + untilspec, err := ioutil.ReadFile(state.StatePath("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 +} + +// 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 maintenance task makes sure we never have duplicate points in the log. +func (state *LegacyMOTHState) AwardPoints(teamId, category string, points int) error { + a := Award{ + When: time.Now(), + TeamId: teamId, + Category: category, + Points: points, + } + + _, err := state.TeamName(teamId) + if err != nil { + return fmt.Errorf("No registered team with this hash") + } + + for _, e := range state.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 := state.StatePath("points.tmp", fn) + newfn := state.StatePath("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 + } + + state.update <- true + log.Printf("Award %s %s %d", teamId, category, points) + return nil +} + +func (state *LegacyMOTHState) PointsLog(teamId string) []*Award { + var ret []*Award + + fn := state.StatePath("points.log") + f, err := os.Open(fn) + if err != nil { + log.Printf("Unable to open %s: %s", fn, err) + return ret + } + defer f.Close() + + 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 + } + if len(teamId) > 0 && cur.TeamId != teamId { + continue + } + ret = append(ret, cur) + } + + return ret +} + +func (state *LegacyMOTHState) getConfig(configName string) (string, error) { + fn := state.StatePath(configName) + data, err := ioutil.ReadFile(fn) + + if err != nil { + log.Printf("Unable to open %s: %s", fn, err) + return "", err + } + + return string(data), nil +} + +func (state *LegacyMOTHState) Maintenance(maintenanceInterval time.Duration) { + for { + if state.isEnabled() { + state.collectPoints() + } + select { + case <-state.update: + // log.Print("Forced update") + case <-time.After(maintenanceInterval): + // log.Print("Housekeeping") + } + } +} + +func (state *LegacyMOTHState) getTeams() map[string]struct{} { + filepath := state.StatePath("teamids.txt") + teamids, err := os.Open(filepath) + teams := make(map[string]struct{}) + if err != nil { + log.Printf("Error openining %s: %s", filepath, err) + return teams + } + defer teamids.Close() + + // List out team IDs + scanner := bufio.NewScanner(teamids) + for scanner.Scan() { + teamId := scanner.Text() + if (teamId == "..") || strings.ContainsAny(teamId, "/") { + log.Printf("Dangerous team ID dropped: %s", teamId) + continue + } + teams[scanner.Text()] = struct{}{} + //newList = append(newList, scanner.Text()) + } + return teams + //return newList +} + +// collectPoints gathers up files in points.new/ and appends their contents to points.log, +// removing each points.new/ file as it goes. +func (state *LegacyMOTHState) collectPoints() { + logf, err := os.OpenFile(state.StatePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Printf("Can't append to points log: %s", err) + return + } + defer logf.Close() + + files, err := ioutil.ReadDir(state.StatePath("points.new")) + if err != nil { + log.Printf("Error reading packages: %s", err) + } + for _, f := range files { + filename := state.StatePath("points.new", f.Name()) + s, err := ioutil.ReadFile(filename) + if err != nil { + log.Printf("Can't read points file %s: %s", filename, err) + continue + } + award, err := ParseAward(string(s)) + if err != nil { + log.Printf("Can't parse award file %s: %s", filename, err) + continue + } + + duplicate := false + for _, e := range state.PointsLog("") { + if award.Same(e) { + duplicate = true + break + } + } + + if duplicate { + log.Printf("Skipping duplicate points: %s", award.String()) + } else { + fmt.Fprintf(logf, "%s\n", award.String()) + } + + logf.Sync() + if err := os.Remove(filename); err != nil { + log.Printf("Unable to remove %s: %s", filename, err) + } + } +} + +func (state *LegacyMOTHState) MaybeInitialize() { + // Only do this if it hasn't already been done + if _, err := os.Stat(state.StatePath("initialized")); err == nil { + return + } + log.Printf("initialized file missing, re-initializing") + + // Remove any extant control and state files + os.Remove(state.StatePath("until")) + os.Remove(state.StatePath("disabled")) + os.Remove(state.StatePath("points.log")) + os.RemoveAll(state.StatePath("points.tmp")) + os.RemoveAll(state.StatePath("points.new")) + os.RemoveAll(state.StatePath("teams")) + + // Make sure various subdirectories exist + os.Mkdir(state.StatePath("points.tmp"), 0755) + os.Mkdir(state.StatePath("points.new"), 0755) + os.Mkdir(state.StatePath("teams"), 0755) + + // Preseed available team ids if file doesn't exist + if f, err := os.OpenFile(state.StatePath("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 initialized file that signals whether we're set up + f, err := os.OpenFile(state.StatePath("initialized"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err != nil { + log.Print(err) + } + defer f.Close() + fmt.Fprintln(f, "Remove this file to reinitialize the contest") +} diff --git a/src/handlers.go b/src/handlers.go index a0ceded..af7b2ef 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -63,41 +63,34 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) { teamName := req.FormValue("name") teamId := req.FormValue("id") - if !ctx.ValidTeamId(teamId) { + success, err := ctx.State.login(teamName, teamId) + + if (success && err == nil) { + respond( + w, req, JSendSuccess, + "Team registered", + "Your team has been named and you may begin using your team ID!", + ) + } else if (err == ErrInvalidTeamID) { respond( w, req, JSendFail, "Invalid Team ID", "I don't have a record of that team ID. Maybe you used capital letters accidentally?", ) - return + } else if (err == ErrAlreadyRegistered) { + respond( + w, req, JSendFail, + "Already registered", + "This team ID has already been registered.", + ) + } else if (err != nil) { + log.Print(err) + respond( + w, req, JSendFail, + "Registration failed", + "Unable to register. Perhaps a teammate has already registered?", + ) } - - f, err := os.OpenFile(ctx.StatePath("teams", teamId), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) - if err != nil { - if os.IsExist(err) { - respond( - w, req, JSendFail, - "Already registered", - "This team ID has already been registered.", - ) - } else { - log.Print(err) - respond( - w, req, JSendFail, - "Registration failed", - "Unable to register. Perhaps a teammate has already registered?", - ) - } - return - } - defer f.Close() - - fmt.Fprintln(f, teamName) - respond( - w, req, JSendSuccess, - "Team registered", - "Your team has been named and you may begin using your team ID!", - ) } func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) { @@ -155,7 +148,7 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) { return } - if err := ctx.AwardPoints(teamId, category, points); err != nil { + if err := ctx.State.AwardPoints(teamId, category, points); err != nil { respond( w, req, JSendError, "Cannot award points", @@ -172,7 +165,7 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) { func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { teamId := req.FormValue("id") - if _, err := ctx.TeamName(teamId); err != nil { + if _, err := ctx.State.TeamName(teamId); err != nil { http.Error(w, "Must provide team ID", http.StatusUnauthorized) return } @@ -262,7 +255,7 @@ func (ctx *Instance) manifestHandler(w http.ResponseWriter, req *http.Request) { } teamId := req.FormValue("id") - if _, err := ctx.TeamName(teamId); err != nil { + if _, err := ctx.State.TeamName(teamId); err != nil { http.Error(w, "Must provide a valid team ID", http.StatusUnauthorized) return } diff --git a/src/instance.go b/src/instance.go index 0ec5d8e..4acd091 100644 --- a/src/instance.go +++ b/src/instance.go @@ -1,11 +1,8 @@ package main import ( - "bufio" "fmt" "io" - "io/ioutil" - "log" "math/rand" "net/http" "os" @@ -24,6 +21,9 @@ type Instance struct { MothballDir string StateDir string ThemeDir string + + State MOTHState + AttemptInterval time.Duration Runtime RuntimeConfig @@ -43,7 +43,8 @@ func (ctx *Instance) Initialize() error { if _, err := os.Stat(ctx.MothballDir); err != nil { return err } - if _, err := os.Stat(ctx.StateDir); err != nil { + + if _, err := ctx.State.Initialize(); err != nil { return err } @@ -55,7 +56,6 @@ func (ctx *Instance) Initialize() error { ctx.mux = http.NewServeMux() ctx.BindHandlers() - ctx.MaybeInitialize() return nil } @@ -72,43 +72,6 @@ func mktoken() string { return string(a) } -func (ctx *Instance) MaybeInitialize() { - // Only do this if it hasn't already been done - if _, err := os.Stat(ctx.StatePath("initialized")); err == nil { - return - } - log.Print("initialized file missing, re-initializing") - - // Remove any extant control and state files - os.Remove(ctx.StatePath("until")) - os.Remove(ctx.StatePath("disabled")) - os.Remove(ctx.StatePath("points.log")) - os.RemoveAll(ctx.StatePath("points.tmp")) - os.RemoveAll(ctx.StatePath("points.new")) - os.RemoveAll(ctx.StatePath("teams")) - - // Make sure various subdirectories exist - os.Mkdir(ctx.StatePath("points.tmp"), 0755) - os.Mkdir(ctx.StatePath("points.new"), 0755) - os.Mkdir(ctx.StatePath("teams"), 0755) - - // Preseed available team ids if file doesn't exist - if f, err := os.OpenFile(ctx.StatePath("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 initialized file that signals whether we're set up - f, err := os.OpenFile(ctx.StatePath("initialized"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) - if err != nil { - log.Print(err) - } - defer f.Close() - fmt.Fprintln(f, "Remove this file to reinitialize the contest") -} - func pathCleanse(parts []string) string { clean := make([]string, len(parts)) for i := range parts { @@ -127,11 +90,6 @@ func (ctx Instance) MothballPath(parts ...string) string { return path.Join(ctx.MothballDir, tail) } -func (ctx *Instance) StatePath(parts ...string) string { - tail := pathCleanse(parts) - return path.Join(ctx.StateDir, tail) -} - func (ctx *Instance) ThemePath(parts ...string) string { tail := pathCleanse(parts) return path.Join(ctx.ThemeDir, tail) @@ -151,75 +109,6 @@ func (ctx *Instance) TooFast(teamId string) bool { return now.Before(next) } -func (ctx *Instance) PointsLog(teamId string) []*Award { - var ret []*Award - - fn := ctx.StatePath("points.log") - f, err := os.Open(fn) - if err != nil { - log.Printf("Unable to open %s: %s", fn, err) - return ret - } - defer f.Close() - - 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 - } - if len(teamId) > 0 && cur.TeamId != teamId { - continue - } - ret = append(ret, cur) - } - - return ret -} - -// 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 maintenance task makes sure we never have duplicate points in the log. -func (ctx *Instance) AwardPoints(teamId, category string, points int) error { - a := Award{ - When: time.Now(), - TeamId: teamId, - Category: category, - Points: points, - } - - _, err := ctx.TeamName(teamId) - if err != nil { - return fmt.Errorf("No registered team with this hash") - } - - for _, e := range ctx.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 := ctx.StatePath("points.tmp", fn) - newfn := ctx.StatePath("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 - } - - ctx.update <- true - log.Printf("Award %s %s %d", teamId, category, points) - return nil -} - func (ctx *Instance) OpenCategoryFile(category string, parts ...string) (io.ReadCloser, error) { mb, ok := ctx.categories[category] if !ok { @@ -238,9 +127,3 @@ func (ctx *Instance) ValidTeamId(teamId string) bool { return ok } - -func (ctx *Instance) TeamName(teamId string) (string, error) { - teamNameBytes, err := ioutil.ReadFile(ctx.StatePath("teams", teamId)) - teamName := strings.TrimSpace(string(teamNameBytes)) - return teamName, err -} diff --git a/src/maintenance.go b/src/maintenance.go index 0174902..d04160d 100644 --- a/src/maintenance.go +++ b/src/maintenance.go @@ -1,12 +1,10 @@ package main import ( - "bufio" "encoding/json" "fmt" "io/ioutil" "log" - "os" "strconv" "strings" "time" @@ -28,7 +26,7 @@ func (pm *PuzzleMap) MarshalJSON() ([]byte, error) { func (ctx *Instance) generatePuzzleList() { maxByCategory := map[string]int{} - for _, a := range ctx.PointsLog("") { + for _, a := range ctx.State.PointsLog("") { if a.Points > maxByCategory[a.Category] { maxByCategory[a.Category] = a.Points } @@ -73,13 +71,13 @@ func (ctx *Instance) generatePointsLog(teamId string) []byte { Points []*Award `json:"points"` } ret.Teams = map[string]string{} - ret.Points = ctx.PointsLog(teamId) + ret.Points = ctx.State.PointsLog(teamId) teamNumbersById := map[string]int{} for nr, a := range ret.Points { teamNumber, ok := teamNumbersById[a.TeamId] if !ok { - teamName, err := ctx.TeamName(a.TeamId) + teamName, err := ctx.State.TeamName(a.TeamId) if err != nil { teamName = "Rodney" // https://en.wikipedia.org/wiki/Rogue_(video_game)#Gameplay } @@ -95,7 +93,7 @@ func (ctx *Instance) generatePointsLog(teamId string) []byte { log.Printf("Marshalling points.js: %v", err) return nil } - + if len(teamId) == 0 { ctx.jPointsLog = jpl } @@ -105,7 +103,7 @@ func (ctx *Instance) generatePointsLog(teamId string) []byte { // maintenance runs func (ctx *Instance) tidy() { // Do they want to reset everything? - ctx.MaybeInitialize() + ctx.State.Initialize() // Check set config ctx.UpdateConfig() @@ -148,37 +146,19 @@ func (ctx *Instance) tidy() { // readTeams reads in the list of team IDs, // so we can quickly validate them. func (ctx *Instance) readTeams() { - filepath := ctx.StatePath("teamids.txt") - teamids, err := os.Open(filepath) - if err != nil { - log.Printf("Error openining %s: %s", filepath, err) - return - } - defer teamids.Close() - - // List out team IDs - newList := map[string]bool{} - scanner := bufio.NewScanner(teamids) - for scanner.Scan() { - teamId := scanner.Text() - if (teamId == "..") || strings.ContainsAny(teamId, "/") { - log.Printf("Dangerous team ID dropped: %s", teamId) - continue - } - newList[scanner.Text()] = true - } + teamList := ctx.State.getTeams() // For any new team IDs, set their next attempt time to right now now := time.Now() added := 0 - for k, _ := range newList { + for teamName, _ := range teamList { ctx.nextAttemptMutex.RLock() - _, ok := ctx.nextAttempt[k] + _, ok := ctx.nextAttempt[teamName] ctx.nextAttemptMutex.RUnlock() if !ok { ctx.nextAttemptMutex.Lock() - ctx.nextAttempt[k] = now + ctx.nextAttempt[teamName] = now ctx.nextAttemptMutex.Unlock() added += 1 @@ -188,9 +168,9 @@ func (ctx *Instance) readTeams() { // For any removed team IDs, remove them removed := 0 ctx.nextAttemptMutex.Lock() // XXX: This could be less of a cludgel - for k, _ := range ctx.nextAttempt { - if _, ok := newList[k]; !ok { - delete(ctx.nextAttempt, k) + for teamName, _ := range ctx.nextAttempt { + if _, ok := teamList[teamName]; !ok { + delete(ctx.nextAttempt, teamName) } } ctx.nextAttemptMutex.Unlock() @@ -200,81 +180,9 @@ func (ctx *Instance) readTeams() { } } -// collectPoints gathers up files in points.new/ and appends their contents to points.log, -// removing each points.new/ file as it goes. -func (ctx *Instance) collectPoints() { - logf, err := os.OpenFile(ctx.StatePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - log.Printf("Can't append to points log: %s", err) - return - } - defer logf.Close() - - files, err := ioutil.ReadDir(ctx.StatePath("points.new")) - if err != nil { - log.Printf("Error reading packages: %s", err) - } - for _, f := range files { - filename := ctx.StatePath("points.new", f.Name()) - s, err := ioutil.ReadFile(filename) - if err != nil { - log.Printf("Can't read points file %s: %s", filename, err) - continue - } - award, err := ParseAward(string(s)) - if err != nil { - log.Printf("Can't parse award file %s: %s", filename, err) - continue - } - - duplicate := false - for _, e := range ctx.PointsLog("") { - if award.Same(e) { - duplicate = true - break - } - } - - if duplicate { - log.Printf("Skipping duplicate points: %s", award.String()) - } else { - fmt.Fprintf(logf, "%s\n", award.String()) - } - - logf.Sync() - if err := os.Remove(filename); err != nil { - log.Printf("Unable to remove %s: %s", filename, err) - } - } -} - -func (ctx *Instance) isEnabled() bool { - // Skip if we've been disabled - if _, err := os.Stat(ctx.StatePath("disabled")); err == nil { - log.Print("Suspended: disabled file found") - return false - } - - untilspec, err := ioutil.ReadFile(ctx.StatePath("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 -} - func (ctx *Instance) UpdateConfig() { // Handle export manifest - if _, err := os.Stat(ctx.StatePath("export_manifest")); err == nil { + if _, err := ctx.State.getConfig("export_manifest"); err == nil { if !ctx.Runtime.export_manifest { log.Print("Enabling manifest export") ctx.Runtime.export_manifest = true @@ -289,10 +197,9 @@ func (ctx *Instance) UpdateConfig() { // maintenance is the goroutine that runs a periodic maintenance task func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) { for { - if ctx.isEnabled() { + if ctx.State.isEnabled() { ctx.tidy() ctx.readTeams() - ctx.collectPoints() ctx.generatePuzzleList() ctx.generatePointsLog("") } diff --git a/src/mothd.go b/src/mothd.go index abf02cb..0f4edfa 100644 --- a/src/mothd.go +++ b/src/mothd.go @@ -17,6 +17,10 @@ func setup() error { func main() { ctx := &Instance{} + var state_path string + var state_engine_choice string + var state_engine MOTHState + flag.StringVar( &ctx.Base, "base", @@ -30,7 +34,13 @@ func main() { "Path to read mothballs", ) flag.StringVar( - &ctx.StateDir, + &state_engine_choice, + "state-engine", + "legacy", + "State engine to use (default: legacy, alt: sqlite)", + ) + flag.StringVar( + &state_path, "state", "/state", "Path to write state", @@ -59,10 +69,22 @@ func main() { ) flag.Parse() + + if (state_engine_choice == "legacy") { + lm_engine := &LegacyMOTHState{} + lm_engine.StateDir = state_path + lm_engine.maintenanceInterval = *maintenanceInterval + state_engine = lm_engine + } else { + log.Fatal("Unrecognized state engine '", state_engine_choice, "'") + } + if err := setup(); err != nil { log.Fatal(err) } + ctx.State = state_engine + err := ctx.Initialize() if err != nil { log.Fatal(err) diff --git a/src/state.go b/src/state.go new file mode 100644 index 0000000..dfb607d --- /dev/null +++ b/src/state.go @@ -0,0 +1,21 @@ +package main + +import ( + "errors" +) + +type MOTHState interface { + PointsLog(teamId string) []*Award + AwardPoints(teamID string, category string, points int) error + + TeamName(teamId string) (string, error) + isEnabled() bool + getConfig(configName string) (string, error) + getTeams() map[string]struct{} + login(teamId string, token string) (bool, error) + Initialize() (bool, error) +} + +var ErrAlreadyRegistered = errors.New("This team ID has already been registered") +var ErrInvalidTeamID = errors.New("Invalid team ID") +var ErrRegistrationError = errors.New("Unable to register. Perhaps a teammate has already registered")