mirror of https://github.com/dirtbags/moth.git
Breaking out state mechanisms into a more abstract form that shouldbe
easier to extend
This commit is contained in:
parent
0b8a855284
commit
4093797899
|
@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Added
|
### Added
|
||||||
- URL parameter to points.json to allow returning only the JSON for a single
|
- 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).
|
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
|
## [3.4.2] - 2019-11-18
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
|
@ -63,41 +63,34 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
teamName := req.FormValue("name")
|
teamName := req.FormValue("name")
|
||||||
teamId := req.FormValue("id")
|
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(
|
respond(
|
||||||
w, req, JSendFail,
|
w, req, JSendFail,
|
||||||
"Invalid Team ID",
|
"Invalid Team ID",
|
||||||
"I don't have a record of that team ID. Maybe you used capital letters accidentally?",
|
"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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctx.AwardPoints(teamId, category, points); err != nil {
|
if err := ctx.State.AwardPoints(teamId, category, points); err != nil {
|
||||||
respond(
|
respond(
|
||||||
w, req, JSendError,
|
w, req, JSendError,
|
||||||
"Cannot award points",
|
"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) {
|
func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
teamId := req.FormValue("id")
|
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)
|
http.Error(w, "Must provide team ID", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -262,7 +255,7 @@ func (ctx *Instance) manifestHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
teamId := req.FormValue("id")
|
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)
|
http.Error(w, "Must provide a valid team ID", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
127
src/instance.go
127
src/instance.go
|
@ -1,11 +1,8 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
@ -24,6 +21,9 @@ type Instance struct {
|
||||||
MothballDir string
|
MothballDir string
|
||||||
StateDir string
|
StateDir string
|
||||||
ThemeDir string
|
ThemeDir string
|
||||||
|
|
||||||
|
State MOTHState
|
||||||
|
|
||||||
AttemptInterval time.Duration
|
AttemptInterval time.Duration
|
||||||
|
|
||||||
Runtime RuntimeConfig
|
Runtime RuntimeConfig
|
||||||
|
@ -43,7 +43,8 @@ func (ctx *Instance) Initialize() error {
|
||||||
if _, err := os.Stat(ctx.MothballDir); err != nil {
|
if _, err := os.Stat(ctx.MothballDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(ctx.StateDir); err != nil {
|
|
||||||
|
if _, err := ctx.State.Initialize(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,7 +56,6 @@ func (ctx *Instance) Initialize() error {
|
||||||
ctx.mux = http.NewServeMux()
|
ctx.mux = http.NewServeMux()
|
||||||
|
|
||||||
ctx.BindHandlers()
|
ctx.BindHandlers()
|
||||||
ctx.MaybeInitialize()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -72,43 +72,6 @@ func mktoken() string {
|
||||||
return string(a)
|
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 {
|
func pathCleanse(parts []string) string {
|
||||||
clean := make([]string, len(parts))
|
clean := make([]string, len(parts))
|
||||||
for i := range parts {
|
for i := range parts {
|
||||||
|
@ -127,11 +90,6 @@ func (ctx Instance) MothballPath(parts ...string) string {
|
||||||
return path.Join(ctx.MothballDir, tail)
|
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 {
|
func (ctx *Instance) ThemePath(parts ...string) string {
|
||||||
tail := pathCleanse(parts)
|
tail := pathCleanse(parts)
|
||||||
return path.Join(ctx.ThemeDir, tail)
|
return path.Join(ctx.ThemeDir, tail)
|
||||||
|
@ -151,75 +109,6 @@ func (ctx *Instance) TooFast(teamId string) bool {
|
||||||
return now.Before(next)
|
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) {
|
func (ctx *Instance) OpenCategoryFile(category string, parts ...string) (io.ReadCloser, error) {
|
||||||
mb, ok := ctx.categories[category]
|
mb, ok := ctx.categories[category]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
@ -238,9 +127,3 @@ func (ctx *Instance) ValidTeamId(teamId string) bool {
|
||||||
|
|
||||||
return ok
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -28,7 +26,7 @@ func (pm *PuzzleMap) MarshalJSON() ([]byte, error) {
|
||||||
|
|
||||||
func (ctx *Instance) generatePuzzleList() {
|
func (ctx *Instance) generatePuzzleList() {
|
||||||
maxByCategory := map[string]int{}
|
maxByCategory := map[string]int{}
|
||||||
for _, a := range ctx.PointsLog("") {
|
for _, a := range ctx.State.PointsLog("") {
|
||||||
if a.Points > maxByCategory[a.Category] {
|
if a.Points > maxByCategory[a.Category] {
|
||||||
maxByCategory[a.Category] = a.Points
|
maxByCategory[a.Category] = a.Points
|
||||||
}
|
}
|
||||||
|
@ -73,13 +71,13 @@ func (ctx *Instance) generatePointsLog(teamId string) []byte {
|
||||||
Points []*Award `json:"points"`
|
Points []*Award `json:"points"`
|
||||||
}
|
}
|
||||||
ret.Teams = map[string]string{}
|
ret.Teams = map[string]string{}
|
||||||
ret.Points = ctx.PointsLog(teamId)
|
ret.Points = ctx.State.PointsLog(teamId)
|
||||||
|
|
||||||
teamNumbersById := map[string]int{}
|
teamNumbersById := map[string]int{}
|
||||||
for nr, a := range ret.Points {
|
for nr, a := range ret.Points {
|
||||||
teamNumber, ok := teamNumbersById[a.TeamId]
|
teamNumber, ok := teamNumbersById[a.TeamId]
|
||||||
if !ok {
|
if !ok {
|
||||||
teamName, err := ctx.TeamName(a.TeamId)
|
teamName, err := ctx.State.TeamName(a.TeamId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
teamName = "Rodney" // https://en.wikipedia.org/wiki/Rogue_(video_game)#Gameplay
|
teamName = "Rodney" // https://en.wikipedia.org/wiki/Rogue_(video_game)#Gameplay
|
||||||
}
|
}
|
||||||
|
@ -105,7 +103,7 @@ func (ctx *Instance) generatePointsLog(teamId string) []byte {
|
||||||
// maintenance runs
|
// maintenance runs
|
||||||
func (ctx *Instance) tidy() {
|
func (ctx *Instance) tidy() {
|
||||||
// Do they want to reset everything?
|
// Do they want to reset everything?
|
||||||
ctx.MaybeInitialize()
|
ctx.State.Initialize()
|
||||||
|
|
||||||
// Check set config
|
// Check set config
|
||||||
ctx.UpdateConfig()
|
ctx.UpdateConfig()
|
||||||
|
@ -148,37 +146,19 @@ func (ctx *Instance) tidy() {
|
||||||
// readTeams reads in the list of team IDs,
|
// readTeams reads in the list of team IDs,
|
||||||
// so we can quickly validate them.
|
// so we can quickly validate them.
|
||||||
func (ctx *Instance) readTeams() {
|
func (ctx *Instance) readTeams() {
|
||||||
filepath := ctx.StatePath("teamids.txt")
|
teamList := ctx.State.getTeams()
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// For any new team IDs, set their next attempt time to right now
|
// For any new team IDs, set their next attempt time to right now
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
added := 0
|
added := 0
|
||||||
for k, _ := range newList {
|
for teamName, _ := range teamList {
|
||||||
ctx.nextAttemptMutex.RLock()
|
ctx.nextAttemptMutex.RLock()
|
||||||
_, ok := ctx.nextAttempt[k]
|
_, ok := ctx.nextAttempt[teamName]
|
||||||
ctx.nextAttemptMutex.RUnlock()
|
ctx.nextAttemptMutex.RUnlock()
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.nextAttemptMutex.Lock()
|
ctx.nextAttemptMutex.Lock()
|
||||||
ctx.nextAttempt[k] = now
|
ctx.nextAttempt[teamName] = now
|
||||||
ctx.nextAttemptMutex.Unlock()
|
ctx.nextAttemptMutex.Unlock()
|
||||||
|
|
||||||
added += 1
|
added += 1
|
||||||
|
@ -188,9 +168,9 @@ func (ctx *Instance) readTeams() {
|
||||||
// For any removed team IDs, remove them
|
// For any removed team IDs, remove them
|
||||||
removed := 0
|
removed := 0
|
||||||
ctx.nextAttemptMutex.Lock() // XXX: This could be less of a cludgel
|
ctx.nextAttemptMutex.Lock() // XXX: This could be less of a cludgel
|
||||||
for k, _ := range ctx.nextAttempt {
|
for teamName, _ := range ctx.nextAttempt {
|
||||||
if _, ok := newList[k]; !ok {
|
if _, ok := teamList[teamName]; !ok {
|
||||||
delete(ctx.nextAttempt, k)
|
delete(ctx.nextAttempt, teamName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.nextAttemptMutex.Unlock()
|
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() {
|
func (ctx *Instance) UpdateConfig() {
|
||||||
// Handle export manifest
|
// 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 {
|
if !ctx.Runtime.export_manifest {
|
||||||
log.Print("Enabling manifest export")
|
log.Print("Enabling manifest export")
|
||||||
ctx.Runtime.export_manifest = true
|
ctx.Runtime.export_manifest = true
|
||||||
|
@ -289,10 +197,9 @@ func (ctx *Instance) UpdateConfig() {
|
||||||
// maintenance is the goroutine that runs a periodic maintenance task
|
// maintenance is the goroutine that runs a periodic maintenance task
|
||||||
func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) {
|
func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) {
|
||||||
for {
|
for {
|
||||||
if ctx.isEnabled() {
|
if ctx.State.isEnabled() {
|
||||||
ctx.tidy()
|
ctx.tidy()
|
||||||
ctx.readTeams()
|
ctx.readTeams()
|
||||||
ctx.collectPoints()
|
|
||||||
ctx.generatePuzzleList()
|
ctx.generatePuzzleList()
|
||||||
ctx.generatePointsLog("")
|
ctx.generatePointsLog("")
|
||||||
}
|
}
|
||||||
|
|
24
src/mothd.go
24
src/mothd.go
|
@ -17,6 +17,10 @@ func setup() error {
|
||||||
func main() {
|
func main() {
|
||||||
ctx := &Instance{}
|
ctx := &Instance{}
|
||||||
|
|
||||||
|
var state_path string
|
||||||
|
var state_engine_choice string
|
||||||
|
var state_engine MOTHState
|
||||||
|
|
||||||
flag.StringVar(
|
flag.StringVar(
|
||||||
&ctx.Base,
|
&ctx.Base,
|
||||||
"base",
|
"base",
|
||||||
|
@ -30,7 +34,13 @@ func main() {
|
||||||
"Path to read mothballs",
|
"Path to read mothballs",
|
||||||
)
|
)
|
||||||
flag.StringVar(
|
flag.StringVar(
|
||||||
&ctx.StateDir,
|
&state_engine_choice,
|
||||||
|
"state-engine",
|
||||||
|
"legacy",
|
||||||
|
"State engine to use (default: legacy, alt: sqlite)",
|
||||||
|
)
|
||||||
|
flag.StringVar(
|
||||||
|
&state_path,
|
||||||
"state",
|
"state",
|
||||||
"/state",
|
"/state",
|
||||||
"Path to write state",
|
"Path to write state",
|
||||||
|
@ -59,10 +69,22 @@ func main() {
|
||||||
)
|
)
|
||||||
flag.Parse()
|
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 {
|
if err := setup(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.State = state_engine
|
||||||
|
|
||||||
err := ctx.Initialize()
|
err := ctx.Initialize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
|
@ -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")
|
Loading…
Reference in New Issue