Throttle submissions per team (more...)

* Preload team hashes in maintenance goroutine
* Check team hash weirdness at maintenance time
* Check team hash validity before checking answer
* Rate limit answer submissions (adjustable with -attempt flag)
* ... and more!
This commit is contained in:
Neale Pickett 2019-03-07 22:03:48 -05:00
parent a36dbb48be
commit 1614b02b66
7 changed files with 183 additions and 101 deletions

1
TODO.md Normal file
View File

@ -0,0 +1 @@
* Figure out how to log JSend short text in addition to HTTP code

View File

@ -1 +1 @@
3.1-rc1 3.1-rc2

View File

@ -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!

View File

@ -57,36 +57,10 @@ func hasLine(r io.Reader, line string) bool {
} }
func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) { 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")
// Keep foolish operators from shooting themselves in the foot if !ctx.ValidTeamId(teamId) {
// 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) {
respond( respond(
w, req, JSendFail, w, req, JSendFail,
"Invalid Team ID", "Invalid Team ID",
@ -95,38 +69,57 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
return return
} }
f, err := os.OpenFile(ctx.StatePath("teams", teamid), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) f, err := os.OpenFile(ctx.StatePath("teams", teamId), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if os.IsExist(err) { if err != nil {
respond( if os.IsExist(err) {
w, req, JSendFail, respond(
"Already registered", w, req, JSendFail,
"This team ID has already been registered.", "Already registered",
) "This team ID has already been registered.",
return )
} else if err != nil { } else {
log.Print(err) log.Print(err)
respond( respond(
w, req, JSendFail, w, req, JSendFail,
"Registration failed", "Registration failed",
"Unable to register. Perhaps a teammate has already registered?", "Unable to register. Perhaps a teammate has already registered?",
) )
}
return return
} }
defer f.Close() defer f.Close()
fmt.Fprintln(f, teamname)
fmt.Fprintln(f, teamName)
respond( respond(
w, req, JSendSuccess, w, req, JSendSuccess,
"Team registered", "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) { func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
teamid := req.FormValue("id") teamId := req.FormValue("id")
category := req.FormValue("cat") category := req.FormValue("cat")
pointstr := req.FormValue("points") pointstr := req.FormValue("points")
answer := req.FormValue("answer") 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) points, err := strconv.Atoi(pointstr)
if err != nil { if err != nil {
respond( respond(
@ -159,7 +152,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.AwardPoints(teamId, category, points); err != nil {
respond( respond(
w, req, JSendError, w, req, JSendError,
"Cannot award points", "Cannot award points",
@ -170,14 +163,14 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
respond( respond(
w, req, JSendSuccess, w, req, JSendSuccess,
"Points awarded", "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) { 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.TeamName(teamId); err != nil {
http.Error(w, "Unauthorized: must provide team ID", http.StatusUnauthorized) http.Error(w, "Must provide team ID", http.StatusUnauthorized)
return return
} }
@ -210,7 +203,7 @@ func (ctx *Instance) contentHandler(w http.ResponseWriter, req *http.Request) {
puzzleId := parts[len(parts)-2] puzzleId := parts[len(parts)-2]
categoryName := parts[len(parts)-3] categoryName := parts[len(parts)-3]
mb, ok := ctx.Categories[categoryName] mb, ok := ctx.categories[categoryName]
if !ok { if !ok {
http.Error(w, "Not Found", http.StatusNotFound) http.Error(w, "Not Found", http.StatusNotFound)
return return
@ -238,7 +231,7 @@ func (ctx *Instance) staticHandler(w http.ResponseWriter, req *http.Request) {
path = "/index.html" path = "/index.html"
} }
f, err := os.Open(ctx.ResourcePath(path)) f, err := os.Open(ctx.ThemePath(path))
if err != nil { if err != nil {
http.NotFound(w, req) http.NotFound(w, req)
return return

View File

@ -15,40 +15,39 @@ import (
) )
type Instance struct { type Instance struct {
Base string Base string
MothballDir string MothballDir string
StateDir string StateDir string
ResourcesDir string ThemeDir string
Categories map[string]*Mothball AttemptInterval time.Duration
update chan bool
jPuzzleList []byte categories map[string]*Mothball
jPointsLog []byte update chan bool
mux *http.ServeMux jPuzzleList []byte
jPointsLog []byte
nextAttempt map[string]time.Time
mux *http.ServeMux
} }
func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) { func (ctx *Instance) Initialize() error {
ctx := &Instance{ // Roll over and die if directories aren't even set up
Base: strings.TrimRight(base, "/"), if _, err := os.Stat(ctx.MothballDir); err != nil {
MothballDir: mothballDir, return err
StateDir: stateDir, }
ResourcesDir: resourcesDir, if _, err := os.Stat(ctx.StateDir); err != nil {
Categories: map[string]*Mothball{}, return err
update: make(chan bool, 10),
mux: http.NewServeMux(),
} }
// Roll over and die if directories aren't even set up ctx.Base = strings.TrimRight(ctx.Base, "/")
if _, err := os.Stat(mothballDir); err != nil { ctx.categories = map[string]*Mothball{}
return nil, err ctx.update = make(chan bool, 10)
} ctx.nextAttempt = map[string]time.Time{}
if _, err := os.Stat(stateDir); err != nil { ctx.mux = http.NewServeMux()
return nil, err
}
ctx.BindHandlers() ctx.BindHandlers()
ctx.MaybeInitialize() ctx.MaybeInitialize()
return ctx, nil return nil
} }
// Stuff people with mediocre handwriting could write down unambiguously, and can be entered without holding down shift // 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) return path.Join(ctx.StateDir, tail)
} }
func (ctx *Instance) ResourcePath(parts ...string) string { func (ctx *Instance) ThemePath(parts ...string) string {
tail := pathCleanse(parts) 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 { func (ctx *Instance) PointsLog() []*Award {
@ -153,20 +159,20 @@ func (ctx *Instance) PointsLog() []*Award {
return ret 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. // It first checks to make sure these are not duplicate points.
// This is not a perfect check, you can trigger a race condition here. // This is not a perfect check, you can trigger a race condition here.
// It's just a courtesy to the user. // It's just a courtesy to the user.
// The maintenance task makes sure we never have duplicate points in the log. // 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{ a := Award{
When: time.Now(), When: time.Now(),
TeamId: teamid, TeamId: teamId,
Category: category, Category: category,
Points: points, Points: points,
} }
teamName, err := ctx.TeamName(teamid) _, err := ctx.TeamName(teamId)
if err != nil { if err != nil {
return fmt.Errorf("No registered team with this hash") 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) tmpfn := ctx.StatePath("points.tmp", fn)
newfn := ctx.StatePath("points.new", fn) newfn := ctx.StatePath("points.new", fn)
@ -190,12 +196,12 @@ func (ctx *Instance) AwardPoints(teamid, category string, points int) error {
} }
ctx.update <- true ctx.update <- true
log.Printf("Award %s %s %d", teamName, category, points) log.Printf("Award %s %s %d", teamId, category, points)
return nil 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 {
return nil, fmt.Errorf("No such category: %s", category) 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 return f, err
} }
func (ctx *Instance) ValidTeamId(teamId string) bool {
_, ok := ctx.nextAttempt[teamId]
return ok
}
func (ctx *Instance) TeamName(teamId string) (string, error) { func (ctx *Instance) TeamName(teamId string) (string, error) {
teamNameBytes, err := ioutil.ReadFile(ctx.StatePath("teams", teamId)) teamNameBytes, err := ioutil.ReadFile(ctx.StatePath("teams", teamId))
teamName := strings.TrimSpace(string(teamNameBytes)) teamName := strings.TrimSpace(string(teamNameBytes))

View File

@ -40,7 +40,7 @@ func (ctx *Instance) generatePuzzleList() {
} }
ret := map[string][]PuzzleMap{} ret := map[string][]PuzzleMap{}
for catName, mb := range ctx.Categories { for catName, mb := range ctx.categories {
mf, err := mb.Open("map.txt") mf, err := mb.Open("map.txt")
if err != nil { if err != nil {
// File isn't in there // File isn't in there
@ -125,12 +125,12 @@ func (ctx *Instance) tidy() {
ctx.MaybeInitialize() ctx.MaybeInitialize()
// Refresh all current categories // Refresh all current categories
for categoryName, mb := range ctx.Categories { for categoryName, mb := range ctx.categories {
if err := mb.Refresh(); err != nil { if err := mb.Refresh(); err != nil {
// Backing file vanished: remove this category // Backing file vanished: remove this category
log.Printf("Removing category: %s: %s", categoryName, err) log.Printf("Removing category: %s: %s", categoryName, err)
mb.Close() mb.Close()
delete(ctx.Categories, categoryName) delete(ctx.categories, categoryName)
} }
} }
@ -147,18 +147,64 @@ func (ctx *Instance) tidy() {
} }
categoryName := strings.TrimSuffix(filename, ".mb") categoryName := strings.TrimSuffix(filename, ".mb")
if _, ok := ctx.Categories[categoryName]; !ok { if _, ok := ctx.categories[categoryName]; !ok {
mb, err := OpenMothball(filepath) mb, err := OpenMothball(filepath)
if err != nil { if err != nil {
log.Printf("Error opening %s: %s", filepath, err) log.Printf("Error opening %s: %s", filepath, err)
continue continue
} }
log.Printf("New category: %s", filename) 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, // collectPoints gathers up files in points.new/ and appends their contents to points.log,
// removing each points.new/ file as it goes. // removing each points.new/ file as it goes.
func (ctx *Instance) collectPoints() { func (ctx *Instance) collectPoints() {
@ -236,6 +282,7 @@ func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) {
for { for {
if ctx.isEnabled() { if ctx.isEnabled() {
ctx.tidy() ctx.tidy()
ctx.readTeams()
ctx.collectPoints() ctx.collectPoints()
ctx.generatePuzzleList() ctx.generatePuzzleList()
ctx.generatePointsLog() ctx.generatePointsLog()

View File

@ -15,30 +15,42 @@ func setup() error {
} }
func main() { func main() {
base := flag.String( ctx := &Instance{}
flag.StringVar(
&ctx.Base,
"base", "base",
"/", "/",
"Base URL of this instance", "Base URL of this instance",
) )
mothballDir := flag.String( flag.StringVar(
&ctx.MothballDir,
"mothballs", "mothballs",
"/mothballs", "/mothballs",
"Path to read mothballs", "Path to read mothballs",
) )
stateDir := flag.String( flag.StringVar(
&ctx.StateDir,
"state", "state",
"/state", "/state",
"Path to write state", "Path to write state",
) )
themeDir := flag.String( flag.StringVar(
&ctx.ThemeDir,
"theme", "theme",
"/theme", "/theme",
"Path to static theme resources (HTML, images, css, ...)", "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( maintenanceInterval := flag.Duration(
"maint", "maint",
20*time.Second, 20*time.Second,
"Maintenance interval", "Time between maintenance tasks",
) )
listen := flag.String( listen := flag.String(
"listen", "listen",
@ -51,7 +63,7 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
ctx, err := NewInstance(*base, *mothballDir, *stateDir, *themeDir) err := ctx.Initialize()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }