diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..81a2aa1 --- /dev/null +++ b/TODO.md @@ -0,0 +1 @@ +* Figure out how to log JSend short text in addition to HTTP code diff --git a/VERSION b/VERSION index 966f9e8..0ff4b45 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1-rc1 +3.1-rc2 diff --git a/example-puzzles/example/4/puzzle.moth b/example-puzzles/example/4/puzzle.moth new file mode 100644 index 0000000..b021ecc --- /dev/null +++ b/example-puzzles/example/4/puzzle.moth @@ -0,0 +1,18 @@ +Summary: Answer patterns +Answer: command.com +Answer: COMMAND.COM +Author: neale +Pattern: [0-9A-Za-z]{1,8}\.[A-Za-z]{1,3} + +This puzzle features answer input pattern checking. + +Sometimes you need to provide a hint about whether the user has entered the answer in the right format. +By providing a `Pattern` value (a regular expression), +the browser will (hopefully) provide a visual hint when an answer is incorrectly formatted. +It will also (hopefully) prevent the user from submitting, +which will (hopefully) inform the participant that they may have the right solution technique, +but there's a problem with the format of the answer. +This will (hopefully) keep people from getting overly-frustrated with difficult-to-enter answers. + +This answer field will validate only FAT 8+3 filenames. +Try it! diff --git a/src/handlers.go b/src/handlers.go index d4674d7..a699223 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -57,36 +57,10 @@ func hasLine(r io.Reader, line string) bool { } func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) { - teamname := req.FormValue("name") - teamid := req.FormValue("id") + teamName := req.FormValue("name") + teamId := req.FormValue("id") - // Keep foolish operators from shooting themselves in the foot - // You would have to add a pathname to your list of Team IDs to open this vulnerability, - // but I have learned not to overestimate people. - if strings.Contains(teamid, "../") { - teamid = "rodney" - } - - if (teamid == "") || (teamname == "") { - respond( - w, req, JSendFail, - "Invalid Entry", - "Either `id` or `name` was missing from this request.", - ) - return - } - - teamids, err := os.Open(ctx.StatePath("teamids.txt")) - if err != nil { - respond( - w, req, JSendFail, - "Cannot read valid team IDs", - "An error was encountered trying to read valid teams IDs: %v", err, - ) - return - } - defer teamids.Close() - if !hasLine(teamids, teamid) { + if !ctx.ValidTeamId(teamId) { respond( w, req, JSendFail, "Invalid Team ID", @@ -95,38 +69,57 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) { return } - f, err := os.OpenFile(ctx.StatePath("teams", teamid), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) - if os.IsExist(err) { - respond( - w, req, JSendFail, - "Already registered", - "This team ID has already been registered.", - ) - return - } 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) + + fmt.Fprintln(f, teamName) respond( w, req, JSendSuccess, "Team registered", - "Okay, your team has been named and you may begin using your team ID!", + "Your team has been named and you may begin using your team ID!", ) } func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) { - teamid := req.FormValue("id") + teamId := req.FormValue("id") category := req.FormValue("cat") pointstr := req.FormValue("points") answer := req.FormValue("answer") + if ! ctx.ValidTeamId(teamId) { + respond( + w, req, JSendFail, + "Invalid team ID", + "That team ID is not valid for this event.", + ) + return + } + if ctx.TooFast(teamId) { + respond( + w, req, JSendFail, + "Submitting too quickly", + "Your team can only submit one answer every %v", ctx.AttemptInterval, + ) + return + } + points, err := strconv.Atoi(pointstr) if err != nil { respond( @@ -159,7 +152,7 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) { return } - if err := ctx.AwardPoints(teamid, category, points); err != nil { + if err := ctx.AwardPoints(teamId, category, points); err != nil { respond( w, req, JSendError, "Cannot award points", @@ -170,14 +163,14 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) { respond( w, req, JSendSuccess, "Points awarded", - fmt.Sprintf("%d points for %s!", points, teamid), + fmt.Sprintf("%d points for %s!", points, teamId), ) } func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { - teamid := req.FormValue("id") - if _, err := ctx.TeamName(teamid); err != nil { - http.Error(w, "Unauthorized: must provide team ID", http.StatusUnauthorized) + teamId := req.FormValue("id") + if _, err := ctx.TeamName(teamId); err != nil { + http.Error(w, "Must provide team ID", http.StatusUnauthorized) return } @@ -210,7 +203,7 @@ func (ctx *Instance) contentHandler(w http.ResponseWriter, req *http.Request) { puzzleId := parts[len(parts)-2] categoryName := parts[len(parts)-3] - mb, ok := ctx.Categories[categoryName] + mb, ok := ctx.categories[categoryName] if !ok { http.Error(w, "Not Found", http.StatusNotFound) return @@ -238,7 +231,7 @@ func (ctx *Instance) staticHandler(w http.ResponseWriter, req *http.Request) { path = "/index.html" } - f, err := os.Open(ctx.ResourcePath(path)) + f, err := os.Open(ctx.ThemePath(path)) if err != nil { http.NotFound(w, req) return diff --git a/src/instance.go b/src/instance.go index 6f5dd95..968e70a 100644 --- a/src/instance.go +++ b/src/instance.go @@ -15,40 +15,39 @@ import ( ) type Instance struct { - Base string - MothballDir string - StateDir string - ResourcesDir string - Categories map[string]*Mothball - update chan bool - jPuzzleList []byte - jPointsLog []byte - mux *http.ServeMux + Base string + MothballDir string + StateDir string + ThemeDir string + AttemptInterval time.Duration + + categories map[string]*Mothball + update chan bool + jPuzzleList []byte + jPointsLog []byte + nextAttempt map[string]time.Time + mux *http.ServeMux } -func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) { - ctx := &Instance{ - Base: strings.TrimRight(base, "/"), - MothballDir: mothballDir, - StateDir: stateDir, - ResourcesDir: resourcesDir, - Categories: map[string]*Mothball{}, - update: make(chan bool, 10), - mux: http.NewServeMux(), +func (ctx *Instance) Initialize() error { + // Roll over and die if directories aren't even set up + if _, err := os.Stat(ctx.MothballDir); err != nil { + return err + } + if _, err := os.Stat(ctx.StateDir); err != nil { + return err } - // Roll over and die if directories aren't even set up - if _, err := os.Stat(mothballDir); err != nil { - return nil, err - } - if _, err := os.Stat(stateDir); err != nil { - return nil, err - } + ctx.Base = strings.TrimRight(ctx.Base, "/") + ctx.categories = map[string]*Mothball{} + ctx.update = make(chan bool, 10) + ctx.nextAttempt = map[string]time.Time{} + ctx.mux = http.NewServeMux() ctx.BindHandlers() ctx.MaybeInitialize() - return ctx, nil + return nil } // Stuff people with mediocre handwriting could write down unambiguously, and can be entered without holding down shift @@ -123,9 +122,16 @@ func (ctx *Instance) StatePath(parts ...string) string { return path.Join(ctx.StateDir, tail) } -func (ctx *Instance) ResourcePath(parts ...string) string { +func (ctx *Instance) ThemePath(parts ...string) string { tail := pathCleanse(parts) - return path.Join(ctx.ResourcesDir, tail) + return path.Join(ctx.ThemeDir, tail) +} + +func (ctx *Instance) TooFast(teamId string) bool { + now := time.Now() + next, _ := ctx.nextAttempt[teamId] + ctx.nextAttempt[teamId] = now.Add(ctx.AttemptInterval) + return now.Before(next) } func (ctx *Instance) PointsLog() []*Award { @@ -153,20 +159,20 @@ func (ctx *Instance) PointsLog() []*Award { return ret } -// awardPoints gives points to teamid in category. +// 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 { +func (ctx *Instance) AwardPoints(teamId, category string, points int) error { a := Award{ When: time.Now(), - TeamId: teamid, + TeamId: teamId, Category: category, Points: points, } - teamName, err := ctx.TeamName(teamid) + _, err := ctx.TeamName(teamId) if err != nil { return fmt.Errorf("No registered team with this hash") } @@ -177,7 +183,7 @@ func (ctx *Instance) AwardPoints(teamid, category string, points int) error { } } - fn := fmt.Sprintf("%s-%s-%d", teamid, category, points) + fn := fmt.Sprintf("%s-%s-%d", teamId, category, points) tmpfn := ctx.StatePath("points.tmp", fn) newfn := ctx.StatePath("points.new", fn) @@ -190,12 +196,12 @@ func (ctx *Instance) AwardPoints(teamid, category string, points int) error { } ctx.update <- true - log.Printf("Award %s %s %d", teamName, category, points) + 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] + mb, ok := ctx.categories[category] if !ok { return nil, fmt.Errorf("No such category: %s", category) } @@ -205,6 +211,11 @@ func (ctx *Instance) OpenCategoryFile(category string, parts ...string) (io.Read return f, err } +func (ctx *Instance) ValidTeamId(teamId string) bool { + _, ok := ctx.nextAttempt[teamId] + return ok +} + func (ctx *Instance) TeamName(teamId string) (string, error) { teamNameBytes, err := ioutil.ReadFile(ctx.StatePath("teams", teamId)) teamName := strings.TrimSpace(string(teamNameBytes)) diff --git a/src/maintenance.go b/src/maintenance.go index 136ba88..3152c28 100644 --- a/src/maintenance.go +++ b/src/maintenance.go @@ -40,7 +40,7 @@ func (ctx *Instance) generatePuzzleList() { } ret := map[string][]PuzzleMap{} - for catName, mb := range ctx.Categories { + for catName, mb := range ctx.categories { mf, err := mb.Open("map.txt") if err != nil { // File isn't in there @@ -125,12 +125,12 @@ func (ctx *Instance) tidy() { ctx.MaybeInitialize() // Refresh all current categories - for categoryName, mb := range ctx.Categories { + for categoryName, mb := range ctx.categories { if err := mb.Refresh(); err != nil { // Backing file vanished: remove this category log.Printf("Removing category: %s: %s", categoryName, err) mb.Close() - delete(ctx.Categories, categoryName) + delete(ctx.categories, categoryName) } } @@ -147,18 +147,64 @@ func (ctx *Instance) tidy() { } categoryName := strings.TrimSuffix(filename, ".mb") - if _, ok := ctx.Categories[categoryName]; !ok { + if _, ok := ctx.categories[categoryName]; !ok { mb, err := OpenMothball(filepath) if err != nil { log.Printf("Error opening %s: %s", filepath, err) continue } log.Printf("New category: %s", filename) - ctx.Categories[categoryName] = mb + ctx.categories[categoryName] = mb } } } +// 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 + } + + // For any new team IDs, set their next attempt time to right now + now := time.Now() + added := 0 + for k, _ := range newList { + if _, ok := ctx.nextAttempt[k]; !ok { + ctx.nextAttempt[k] = now + added += 1 + } + } + + // For any removed team IDs, remove them + removed := 0 + for k, _ := range ctx.nextAttempt { + if _, ok := newList[k]; !ok { + delete(ctx.nextAttempt, k) + } + } + + if (added > 0) || (removed > 0) { + log.Printf("Team IDs updated: %d added, %d removed", added, removed) + } +} + // 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() { @@ -236,6 +282,7 @@ func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) { for { if ctx.isEnabled() { ctx.tidy() + ctx.readTeams() ctx.collectPoints() ctx.generatePuzzleList() ctx.generatePointsLog() diff --git a/src/mothd.go b/src/mothd.go index d02e9c1..abf02cb 100644 --- a/src/mothd.go +++ b/src/mothd.go @@ -15,30 +15,42 @@ func setup() error { } func main() { - base := flag.String( + ctx := &Instance{} + + flag.StringVar( + &ctx.Base, "base", "/", "Base URL of this instance", ) - mothballDir := flag.String( + flag.StringVar( + &ctx.MothballDir, "mothballs", "/mothballs", "Path to read mothballs", ) - stateDir := flag.String( + flag.StringVar( + &ctx.StateDir, "state", "/state", "Path to write state", ) - themeDir := flag.String( + flag.StringVar( + &ctx.ThemeDir, "theme", "/theme", "Path to static theme resources (HTML, images, css, ...)", ) + flag.DurationVar( + &ctx.AttemptInterval, + "attempt", + 500*time.Millisecond, + "Per-team time required between answer attempts", + ) maintenanceInterval := flag.Duration( "maint", 20*time.Second, - "Maintenance interval", + "Time between maintenance tasks", ) listen := flag.String( "listen", @@ -51,7 +63,7 @@ func main() { log.Fatal(err) } - ctx, err := NewInstance(*base, *mothballDir, *stateDir, *themeDir) + err := ctx.Initialize() if err != nil { log.Fatal(err) }