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) {
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

View File

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

View File

@ -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()

View File

@ -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)
}