From 61c1129e0f89fee52909628bcaa369a0abe31d01 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sat, 15 Sep 2018 00:24:48 +0000 Subject: [PATCH 01/13] A passable start at a go-based mothd --- maintenance.go | 66 ----------- mothd.go | 167 ---------------------------- points.go | 129 --------------------- src/award.go | 46 ++++++++ src/award_test.go | 34 ++++++ handlers.go => src/handlers.go | 86 ++++++++++---- src/instance.go | 114 +++++++++++++++++++ src/instance_test.go | 1 + src/maintenance.go | 105 +++++++++++++++++ src/moth-init | 9 -- src/{mothball => }/mothball.go | 18 ++- src/{mothball => }/mothball_test.go | 4 +- src/mothd | 14 --- src/mothd.go | 86 ++++++++++++++ 14 files changed, 470 insertions(+), 409 deletions(-) delete mode 100644 maintenance.go delete mode 100644 mothd.go delete mode 100644 points.go create mode 100644 src/award.go create mode 100644 src/award_test.go rename handlers.go => src/handlers.go (53%) create mode 100644 src/instance.go create mode 100644 src/instance_test.go create mode 100644 src/maintenance.go delete mode 100755 src/moth-init rename src/{mothball => }/mothball.go (73%) rename src/{mothball => }/mothball_test.go (94%) delete mode 100755 src/mothd create mode 100644 src/mothd.go diff --git a/maintenance.go b/maintenance.go deleted file mode 100644 index f352c33..0000000 --- a/maintenance.go +++ /dev/null @@ -1,66 +0,0 @@ -package main - -import ( - "log" - "io/ioutil" - "time" - "os" - "strings" -) - -func cacheMothball(filepath string, categoryName string) { - log.Printf("I'm exploding a mothball %s %s", filepath, categoryName) -} - -// maintenance runs -func tidy() { - // Skip if we've been disabled - if _, err := os.Stat(statePath("disabled")); err == nil { - log.Print("disabled file found, suspending maintenance") - return - } - - // Skip if we've expired - untilspec, err := ioutil.ReadFile(statePath("until")) - if err == nil { - until, err := time.Parse(time.RFC3339, string(untilspec)) - if err != nil { - log.Print("Unparseable date in until file: %s", until) - } else { - if until.Before(time.Now()) { - log.Print("until file time reached, suspending maintenance") - return - } - } - } - - // Get current list of categories - newCategories := []string{} - files, err := ioutil.ReadDir(modulesPath()) - if err != nil { - log.Printf("Error reading packages: %s", err) - } - for _, f := range files { - filename := f.Name() - filepath := modulesPath(filename) - if ! strings.HasSuffix(filename, ".mb") { - continue - } - - categoryName := strings.TrimSuffix(filename, ".mb") - newCategories = append(newCategories, categoryName) - - // Uncompress into cache directory - cacheMothball(filepath, categoryName) - } - categories = newCategories - - collectPoints() -} - -// maintenance is the goroutine that runs a periodic maintenance task -func maintenance(maintenanceInterval time.Duration) { - for ;; time.Sleep(maintenanceInterval) { - tidy() - } -} diff --git a/mothd.go b/mothd.go deleted file mode 100644 index 18f42a6..0000000 --- a/mothd.go +++ /dev/null @@ -1,167 +0,0 @@ -package main - -import ( - "bufio" - "github.com/namsral/flag" - "fmt" - "log" - "net/http" - "os" - "path" - "strings" - "time" -) - -var moduleDir string -var stateDir string -var cacheDir string -var categories = []string{} - -// anchoredSearch looks for needle in filename, -// skipping the first skip space-delimited words -func anchoredSearch(filename string, needle string, skip int) bool { - f, err := os.Open(filename) - if err != nil { - log.Print("Can't open %s: %s", filename, err) - return false - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - parts := strings.SplitN(" ", line, skip+1) - if parts[skip+1] == needle { - return true - } - } - - return false -} - -func showPage(w http.ResponseWriter, title string, body string) { - w.WriteHeader(http.StatusOK) - - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "%s", title) - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "

%s

", title) - fmt.Fprintf(w, "
%s
", body) - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") -} - -func modulesPath(parts ...string) string { - tail := path.Join(parts...) - return path.Join(moduleDir, tail) -} - -func statePath(parts ...string) string { - tail := path.Join(parts...) - return path.Join(stateDir, tail) -} - -func cachePath(parts ...string) string { - tail := path.Join(parts...) - return path.Join(cacheDir, tail) -} - -func logRequest(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Printf("HTTP %s %s %s\n", r.RemoteAddr, r.Method, r.URL) - handler.ServeHTTP(w, r) - }) -} - -func setup() error { - // Roll over and die if directories aren't even set up - if _, err := os.Stat(modulesPath()); os.IsNotExist(err) { - return err - } - if _, err := os.Stat(statePath()); os.IsNotExist(err) { - return err - } - if _, err := os.Stat(cachePath()); os.IsNotExist(err) { - return err - } - - // Make sure points directories exist - os.Mkdir(statePath("points.tmp"), 0755) - os.Mkdir(statePath("points.new"), 0755) - - // Preseed available team ids if file doesn't exist - if f, err := os.OpenFile(statePath("teamids.txt"), os.O_WRONLY | os.O_CREATE | os.O_EXCL, 0644); err == nil { - defer f.Close() - for i := 0; i <= 9999; i += 1 { - fmt.Fprintf(f, "%04d\n", i) - } - } - - return nil -} - -func main() { - var maintenanceInterval time.Duration - var listen string - - fs := flag.NewFlagSetWithEnvPrefix(os.Args[0], "MOTH", flag.ExitOnError) - fs.StringVar( - &moduleDir, - "modules", - "/moth/modules", - "Path where your moth modules live", - ) - fs.StringVar( - &stateDir, - "state", - "/moth/state", - "Path where state should be written", - ) - fs.StringVar( - &cacheDir, - "cache", - "/moth/cache", - "Path for ephemeral cache", - ) - fs.DurationVar( - &maintenanceInterval, - "maint", - 20 * time.Second, - "Maintenance interval", - ) - fs.StringVar( - &listen, - "listen", - ":8080", - "[host]:port to bind and listen", - ) - fs.Parse(os.Args[1:]) - - if err := setup(); err != nil { - log.Fatal(err) - } - go maintenance(maintenanceInterval) - - fileserver := http.FileServer(http.Dir(cacheDir)) - http.HandleFunc("/", rootHandler) - http.Handle("/static/", http.StripPrefix("/static", fileserver)) - - http.HandleFunc("/register", registerHandler) - http.HandleFunc("/token", tokenHandler) - http.HandleFunc("/answer", answerHandler) - - http.HandleFunc("/puzzles.json", puzzlesHandler) - http.HandleFunc("/points.json", pointsHandler) - - log.Printf("Listening on %s", listen) - log.Fatal(http.ListenAndServe(listen, logRequest(http.DefaultServeMux))) -} diff --git a/points.go b/points.go deleted file mode 100644 index f6f16c0..0000000 --- a/points.go +++ /dev/null @@ -1,129 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "io/ioutil" - "log" - "os" - "strconv" - "strings" - "time" -) - -type Award struct { - when time.Time - teamid string - category string - points int -} - -func ParseAward(s string) (*Award, error) { - ret := Award{} - - parts := strings.SplitN(s, " ", 5) - if len(parts) < 4 { - return nil, fmt.Errorf("Malformed award string") - } - - whenEpoch, err := strconv.ParseInt(parts[0], 10, 64) - if (err != nil) { - return nil, fmt.Errorf("Malformed timestamp: %s", parts[0]) - } - ret.when = time.Unix(whenEpoch, 0) - - ret.teamid = parts[1] - ret.category = parts[2] - - points, err := strconv.Atoi(parts[3]) - if (err != nil) { - return nil, fmt.Errorf("Malformed points: %s", parts[3]) - } - ret.points = points - - return &ret, nil -} - -func (a *Award) String() string { - return fmt.Sprintf("%d %s %s %d", a.when.Unix(), a.teamid, a.category, a.points) -} - -func pointsLog() []Award { - var ret []Award - - fn := 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 - } - ret = append(ret, *cur) - } - - return ret -} - -// awardPoints gives points points to team teamid in category category -func awardPoints(teamid string, category string, points int) error { - fn := fmt.Sprintf("%s-%s-%d", teamid, category, points) - tmpfn := statePath("points.tmp", fn) - newfn := statePath("points.new", fn) - - contents := fmt.Sprintf("%d %s %s %d\n", time.Now().Unix(), teamid, points) - - if err := ioutil.WriteFile(tmpfn, []byte(contents), 0644); err != nil { - return err - } - - if err := os.Rename(tmpfn, newfn); err != nil { - return err - } - - log.Printf("Award %s %s %d", teamid, category, points) - return nil -} - -// collectPoints gathers up files in points.new/ and appends their contents to points.log, -// removing each points.new/ file as it goes. -func collectPoints() { - logf, err := os.OpenFile(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(statePath("points.new")) - if err != nil { - log.Printf("Error reading packages: %s", err) - } - for _, f := range files { - filename := 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 - } - fmt.Fprintf(logf, "%s\n", award.String()) - log.Print(award.String()) - logf.Sync() - if err := os.Remove(filename); err != nil { - log.Printf("Unable to remove %s: %s", filename, err) - } - } -} \ No newline at end of file diff --git a/src/award.go b/src/award.go new file mode 100644 index 0000000..e975599 --- /dev/null +++ b/src/award.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +type Award struct { + When time.Time + TeamId string + Category string + Points int +} + +func (a *Award) String() string { + return fmt.Sprintf("%d %s %s %d", a.When.Unix(), a.TeamId, a.Category, a.Points) +} + +func ParseAward(s string) (*Award, error) { + ret := Award{} + + parts := strings.SplitN(s, " ", 5) + if len(parts) < 4 { + return nil, fmt.Errorf("Malformed award string") + } + + whenEpoch, err := strconv.ParseInt(parts[0], 10, 64) + if (err != nil) { + return nil, fmt.Errorf("Malformed timestamp: %s", parts[0]) + } + ret.When = time.Unix(whenEpoch, 0) + + ret.TeamId = parts[1] + ret.Category = parts[2] + + points, err := strconv.Atoi(parts[3]) + if (err != nil) { + return nil, fmt.Errorf("Malformed Points: %s", parts[3]) + } + ret.Points = points + + return &ret, nil +} + diff --git a/src/award_test.go b/src/award_test.go new file mode 100644 index 0000000..ba3ff06 --- /dev/null +++ b/src/award_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "testing" +) + +func TestAward(t *testing.T) { + entry := "1536958399 1a2b3c4d counting 1" + a, err := ParseAward(entry) + if err != nil { + t.Error(err) + return + } + if a.TeamId != "1a2b3c4d" { + t.Error("TeamID parsed wrong") + } + if a.Category != "counting" { + t.Error("Category parsed wrong") + } + if a.Points != 1 { + t.Error("Points parsed wrong") + } + + if a.String() != entry { + t.Error("String conversion wonky") + } + + if _, err := ParseAward("bad bad bad 1"); err == nil { + t.Error("Not throwing error on bad timestamp") + } + if _, err := ParseAward("1 bad bad bad"); err == nil { + t.Error("Not throwing error on bad points") + } +} diff --git a/handlers.go b/src/handlers.go similarity index 53% rename from handlers.go rename to src/handlers.go index a1edddd..f3b60ea 100644 --- a/handlers.go +++ b/src/handlers.go @@ -7,9 +7,38 @@ import ( "regexp" "strings" "strconv" + "io" + "log" + "bufio" ) -func registerHandler(w http.ResponseWriter, req *http.Request) { +// anchoredSearch looks for needle in r, +// skipping the first skip space-delimited words +func anchoredSearch(r io.Reader, needle string, skip int) bool { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, " ", skip+1) + if (len(parts) > skip) && (parts[skip] == needle) { + return true + } + } + + return false +} + +func anchoredSearchFile(filename string, needle string, skip int) bool { + r, err := os.Open(filename) + if err != nil { + return false + } + defer r.Close() + + return anchoredSearch(r, needle, skip) +} + + +func (ctx Instance) registerHandler(w http.ResponseWriter, req *http.Request) { teamname := req.FormValue("n") teamid := req.FormValue("h") @@ -22,12 +51,12 @@ func registerHandler(w http.ResponseWriter, req *http.Request) { return } - if ! anchoredSearch(statePath("teamids.txt"), teamid, 0) { + if ! anchoredSearchFile(ctx.StatePath("teamids.txt"), teamid, 0) { showPage(w, "Invalid Team ID", "I don't have a record of that team ID. Maybe you used capital letters accidentally?") return } - f, err := os.OpenFile(statePath("state", teamid), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + f, err := os.OpenFile(ctx.StatePath(teamid), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) if err != nil { showPage( w, @@ -41,12 +70,12 @@ func registerHandler(w http.ResponseWriter, req *http.Request) { showPage(w, "Success", "Okay, your team has been named and you may begin using your team ID!") } -func tokenHandler(w http.ResponseWriter, req *http.Request) { +func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) { teamid := req.FormValue("t") token := req.FormValue("k") // Check answer - if ! anchoredSearch(token, statePath("tokens.txt"), 0) { + if ! anchoredSearchFile(ctx.StatePath("tokens.txt"), token, 0) { showPage(w, "Unrecognized token", "I don't recognize that token. Did you type in the whole thing?") return } @@ -72,14 +101,14 @@ func tokenHandler(w http.ResponseWriter, req *http.Request) { return } - if err := awardPoints(teamid, category, points); err != nil { + if err := ctx.AwardPoints(teamid, category, points); err != nil { showPage(w, "Error awarding points", err.Error()) return } showPage(w, "Points awarded", fmt.Sprintf("%d points for %s!", points, teamid)) } -func answerHandler(w http.ResponseWriter, req *http.Request) { +func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) { teamid := req.FormValue("t") category := req.FormValue("c") pointstr := req.FormValue("p") @@ -90,41 +119,51 @@ func answerHandler(w http.ResponseWriter, req *http.Request) { points = 0 } - // Defang category name; prevent directory traversal - if matched, _ := regexp.MatchString("^[A-Za-z0-9_-]", category); matched { - category = "" + catmb, ok := ctx.Categories[category] + if ! ok { + showPage(w, "Category does not exist", "The specified category does not exist. Sorry!") + return } - // Check answer - needle := fmt.Sprintf("%s %s", points, answer) - haystack := cachePath(category, "answers.txt") + // Get the answers + haystack, err := catmb.Open("answers.txt") + if err != nil { + showPage(w, "Answers do not exist", + "Please tell the contest people that the mothball for this category has no answers.txt in it!") + return + } + defer haystack.Close() + + // Look for the answer + needle := fmt.Sprintf("%d %s", points, answer) if ! anchoredSearch(haystack, needle, 0) { showPage(w, "Wrong answer", err.Error()) + return } - if err := awardPoints(teamid, category, points); err != nil { + if err := ctx.AwardPoints(teamid, category, points); err != nil { showPage(w, "Error awarding points", err.Error()) return } showPage(w, "Points awarded", fmt.Sprintf("%d points for %s!", points, teamid)) } -func puzzlesHandler(w http.ResponseWriter, req *http.Request) { +func (ctx Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { puzzles := map[string][]interface{}{} // v := map[string][]interface{}{"Moo": {1, "0177f85ae895a33e2e7c5030c3dc484e8173e55c"}} // j, _ := json.Marshal(v) - for _, category := range categories { - + for _, category := range ctx.Categories { + log.Print(puzzles, category) } } -func pointsHandler(w http.ResponseWriter, req *http.Request) { +func (ctx Instance) pointsHandler(w http.ResponseWriter, req *http.Request) { } // staticHandler serves up static files. -func rootHandler(w http.ResponseWriter, req *http.Request) { +func (ctx Instance) rootHandler(w http.ResponseWriter, req *http.Request) { if req.URL.Path == "/" { showPage( w, @@ -150,3 +189,12 @@ func rootHandler(w http.ResponseWriter, req *http.Request) { http.NotFound(w, req) } + +func (ctx Instance) BindHandlers(mux *http.ServeMux) { + mux.HandleFunc(ctx.Base + "/", ctx.rootHandler) + mux.HandleFunc(ctx.Base + "/register", ctx.registerHandler) + mux.HandleFunc(ctx.Base + "/token", ctx.tokenHandler) + mux.HandleFunc(ctx.Base + "/answer", ctx.answerHandler) + mux.HandleFunc(ctx.Base + "/puzzles.json", ctx.puzzlesHandler) + mux.HandleFunc(ctx.Base + "/points.json", ctx.pointsHandler) +} diff --git a/src/instance.go b/src/instance.go new file mode 100644 index 0000000..01011bc5 --- /dev/null +++ b/src/instance.go @@ -0,0 +1,114 @@ +package main + +import ( + "os" + "log" + "bufio" + "fmt" + "time" + "io/ioutil" + "path" + "strings" +) + +type Instance struct { + Base string + MothballDir string + StateDir string + Categories map[string]*Mothball +} + +func NewInstance(base, mothballDir, stateDir string) (*Instance, error) { + ctx := &Instance{ + Base: strings.TrimRight(base, "/"), + MothballDir: mothballDir, + StateDir: stateDir, + } + + // 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.Initialize() + + return ctx, nil +} + +func (ctx *Instance) Initialize () { + // Make sure points directories exist + os.Mkdir(ctx.StatePath("points.tmp"), 0755) + os.Mkdir(ctx.StatePath("points.new"), 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 <= 9999; i += 1 { + fmt.Fprintf(f, "%04d\n", i) + } + } + + if f, err := os.OpenFile(ctx.StatePath("initialized"), os.O_WRONLY | os.O_CREATE | os.O_EXCL, 0644); err == nil { + defer f.Close() + fmt.Println("Remove this file to reinitialize the contest") + } +} + +func (ctx Instance) MothballPath(parts ...string) string { + tail := path.Join(parts...) + return path.Join(ctx.MothballDir, tail) +} + +func (ctx *Instance) StatePath(parts ...string) string { + tail := path.Join(parts...) + return path.Join(ctx.StateDir, tail) +} + + +func (ctx *Instance) PointsLog() []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 + } + ret = append(ret, *cur) + } + + return ret +} + +// awardPoints gives points points to team teamid in category category +func (ctx *Instance) AwardPoints(teamid string, category string, points int) error { + fn := fmt.Sprintf("%s-%s-%d", teamid, category, points) + tmpfn := ctx.StatePath("points.tmp", fn) + newfn := ctx.StatePath("points.new", fn) + + contents := fmt.Sprintf("%d %s %s %d\n", time.Now().Unix(), teamid, category, points) + + if err := ioutil.WriteFile(tmpfn, []byte(contents), 0644); err != nil { + return err + } + + if err := os.Rename(tmpfn, newfn); err != nil { + return err + } + + log.Printf("Award %s %s %d", teamid, category, points) + return nil +} diff --git a/src/instance_test.go b/src/instance_test.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/src/instance_test.go @@ -0,0 +1 @@ +package main diff --git a/src/maintenance.go b/src/maintenance.go new file mode 100644 index 0000000..b359e63 --- /dev/null +++ b/src/maintenance.go @@ -0,0 +1,105 @@ +package main + +import ( + "log" + "io/ioutil" + "time" + "os" + "strings" + "fmt" +) + +// maintenance runs +func (ctx *Instance) Tidy() { + // Skip if we've been disabled + if _, err := os.Stat(ctx.StatePath("disabled")); err == nil { + log.Print("disabled file found, suspending maintenance") + return + } + + // Skip if we've expired + untilspec, err := ioutil.ReadFile(ctx.StatePath("until")) + if err == nil { + until, err := time.Parse(time.RFC3339, string(untilspec)) + if err != nil { + log.Printf("Unparseable date in until file: %v", until) + } else { + if until.Before(time.Now()) { + log.Print("until file time reached, suspending maintenance") + return + } + } + } + + // Any new categories? + files, err := ioutil.ReadDir(ctx.MothballPath()) + if err != nil { + log.Printf("Error listing mothballs: %s", err) + } + for _, f := range files { + filename := f.Name() + filepath := ctx.MothballPath(filename) + if ! strings.HasSuffix(filename, ".mb") { + continue + } + categoryName := strings.TrimSuffix(filename, ".mb") + + if _, ok := ctx.Categories[categoryName]; !ok { + mb, err := OpenMothball(filepath) + if err != nil { + log.Printf("Error opening %s: %s", filepath, err) + continue + } + ctx.Categories[categoryName] = mb + } + } + + // Any old categories? + log.Print("XXX: Check for and reap old categories") + + ctx.CollectPoints() +} + +// 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 + } + fmt.Fprintf(logf, "%s\n", award.String()) + log.Print("XXX: check for duplicates", award.String()) + logf.Sync() + if err := os.Remove(filename); err != nil { + log.Printf("Unable to remove %s: %s", filename, err) + } + } +} + + + +// maintenance is the goroutine that runs a periodic maintenance task +func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) { + for ;; time.Sleep(maintenanceInterval) { + ctx.Tidy() + } +} diff --git a/src/moth-init b/src/moth-init deleted file mode 100755 index e0cd4d1..0000000 --- a/src/moth-init +++ /dev/null @@ -1,9 +0,0 @@ -#! /bin/sh - -while true; do - /moth/bin/once - sleep 20 -done & - -cd /moth/www -s6-tcpserver -u $(id -u www) -g $(id -g www) 0.0.0.0 80 /usr/bin/eris -c -d -. diff --git a/src/mothball/mothball.go b/src/mothball.go similarity index 73% rename from src/mothball/mothball.go rename to src/mothball.go index 1f63776..1a5dfcd 100644 --- a/src/mothball/mothball.go +++ b/src/mothball.go @@ -1,9 +1,10 @@ -package mothball +package main import ( "archive/zip" "fmt" "io" + "io/ioutil" "os" "time" ) @@ -14,7 +15,7 @@ type Mothball struct { mtime time.Time } -func Open(filename string) (*Mothball, error) { +func OpenMothball(filename string) (*Mothball, error) { var m Mothball m.filename = filename @@ -38,7 +39,7 @@ func (m *Mothball) Refresh() (error) { } mtime := info.ModTime() - if mtime == m.mtime { + if ! mtime.After(m.mtime) { return nil } @@ -65,3 +66,14 @@ func (m *Mothball) Open(filename string) (io.ReadCloser, error) { } return nil, fmt.Errorf("File not found: %s in %s", filename, m.filename) } + +func (m *Mothball) ReadFile(filename string) ([]byte, error) { + f, err := m.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + + bytes, err := ioutil.ReadAll(f) + return bytes, err +} diff --git a/src/mothball/mothball_test.go b/src/mothball_test.go similarity index 94% rename from src/mothball/mothball_test.go rename to src/mothball_test.go index f740e27..d1b46bb 100644 --- a/src/mothball/mothball_test.go +++ b/src/mothball_test.go @@ -1,4 +1,4 @@ -package mothball +package main import ( "archive/zip" @@ -35,7 +35,7 @@ func TestMothball(t *testing.T) { tf.Close() // Now read it in - mb, err := Open(tf.Name()) + mb, err := OpenMothball(tf.Name()) if err != nil { t.Error(err) return diff --git a/src/mothd b/src/mothd deleted file mode 100755 index ffad247..0000000 --- a/src/mothd +++ /dev/null @@ -1,14 +0,0 @@ -#! /bin/sh - -cd ${1:-$(dirname $0)} -KOTH_BASE=$(pwd) - -echo "Running koth instances in $KOTH_BASE" - -while true; do - for i in $KOTH_BASE/*/assigned.txt; do - dir=${i%/*} - $dir/bin/once - done - sleep 5 -done diff --git a/src/mothd.go b/src/mothd.go new file mode 100644 index 0000000..93c27b8 --- /dev/null +++ b/src/mothd.go @@ -0,0 +1,86 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "time" +) + +func showPage(w http.ResponseWriter, title string, body string) { + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "%s", title) + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "

%s

", title) + fmt.Fprintf(w, "
%s
", body) + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") +} + + +func logRequest(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("HTTP %s %s %s\n", r.RemoteAddr, r.Method, r.URL) + handler.ServeHTTP(w, r) + }) +} + +func setup() error { + return nil +} + +func main() { + base := flag.String( + "base", + "/", + "Base URL of this instance", + ) + mothballDir := flag.String( + "mothballs", + "/moth/mothballs", + "Path to read mothballs", + ) + stateDir := flag.String( + "state", + "/moth/state", + "Path to write state", + ) + maintenanceInterval := flag.Duration( + "maint", + 20 * time.Second, + "Maintenance interval", + ) + listen := flag.String( + "listen", + ":80", + "[host]:port to bind and listen", + ) + flag.Parse() + + if err := setup(); err != nil { + log.Fatal(err) + } + + ctx, err := NewInstance(*base, *mothballDir, *stateDir) + if err != nil { + log.Fatal(err) + } + ctx.BindHandlers(http.DefaultServeMux) + + go ctx.Maintenance(*maintenanceInterval) + + log.Printf("Listening on %s", *listen) + log.Fatal(http.ListenAndServe(*listen, logRequest(http.DefaultServeMux))) +} From 5b018eab4286a2e748075c1677ba1275b85f3092 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Mon, 17 Sep 2018 23:00:08 +0000 Subject: [PATCH 02/13] Moving toward a working server --- README.md | 161 ++++++++++++++++---------- src/award.go | 46 ++++++-- src/award_test.go | 4 +- src/handlers.go | 266 ++++++++++++++++++++++++++----------------- src/instance.go | 74 +++++++----- src/maintenance.go | 26 +++-- src/mothball.go | 22 ++-- src/mothball_test.go | 8 +- src/mothd.go | 44 +++---- src/static.go | 237 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 622 insertions(+), 266 deletions(-) create mode 100644 src/static.go diff --git a/README.md b/README.md index 3c73a6d..9b62c5a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ which in the past has been called "HACK", "Queen Of The Hill", "Cyber Spark", -and "Cyber Fire". +"Cyber Fire", +"Cyber Fire Puzzles", +and "Cyber Fire Foundry". Information about these events is at http://dirtbags.net/contest/ @@ -48,75 +50,110 @@ More on how the devel sever works in Running A Production Server ==================== -XXX: Update this +Run `dirtbags/moth` (Docker) or `mothd` (native). -How to install it --------------------- +`mothd` assumes you're running a contest out of `/moth`. +For Docker, you'll need to bind-mount your actual directories +(`state`, `mothballs`, and optionally `resources`) into +`/moth/`. -It's made to be virtualized, -so you can run multiple contests at once if you want. -If you were to want to run it out of `/srv/moth`, -do the following: - - $ mothinst=/srv/moth/mycontest - $ mkdir -p $mothinst - $ install.sh $mothinst - - Yay, you've got it installed. - -How to run a contest ------------------------- - -`mothd` runs through every contest on your server every few seconds, -and does housekeeping tasks that make the contest "run". -If you stop `mothd`, people can still play the contest, -but their points won't show up on the scoreboard. - -A handy side-effect here is that if you need to meddle with the points log, -you can just kill `mothd`, -do you work, -then bring `mothd` back up. - - $ cp src/mothd /srv/moth - $ /srv/moth/mothd - -You're also going to need a web server if you want people to be able to play. +You can override any path with an option, +run `mothd -help` for usage. -How to run a web server ------------------------------ - -Your web server needs to serve up files for you contest out of -`$mothinst/www`. - -If you don't want to fuss around with setting up a full-featured web server, -you can use `tcpserver` and `eris`, -which is what we use to run our contests. - -`tcpserver` is part of the `uscpi-tcp` package in Ubuntu. -You can also use busybox's `tcpsvd` (my preference, but a PITA on Ubuntu). - -`eris` can be obtained at https://woozle.org/neale/g.cgi/net/eris/about/ - - $ mothinst=/srv/moth/mycontest - $ $mothinst/bin/httpd +State Directory +=============== -Installing Puzzle Categories ------------------------------------- +Pausing scoring +------------------- -Puzzle categories are distributed in a different way than the server. -After setting up (see above), just run +Create the file `state/disabled` +to pause scoring, +and remove it to resume. +You can use the Unix `touch` command to create the file: - $ /srv/koth/mycontest/bin/install-category /path/to/my/category - + touch state/disabled -Permissions ----------------- +When scoring is paused, +participants can still submit answers, +and the system will tell them whether the answer is correct. +As soon as you unpause, +all correctly-submitted answers will be scored. -It's up to you not to be a bonehead about permissions. -Install sets it so the web user on your system can write to the files it needs to, -but if you're using Apache, -it plays games with user IDs when running CGI. -You're going to have to figure out how to configure your preferred web server. +Resetting an instance +------------------- + +Remove the file `state/initialized`, +and the server will zap everything. + + +Setting up custom team IDs +------------------- + +The file `state/teamids.txt` has all the team IDs, +one per line. +This defaults to all 4-digit natural numbers. +You can edit it to be whatever strings you like. + +We sometimes to set `teamids.txt` to a bunch of random 8-digit hex values: + + for i in $(seq 50); do od -x /dev/urandom | awk '{print $2 $3; exit;}'; done + +Remember that team IDs are essentially passwords. + + +Mothball Directory +================== + +Installing puzzle categories +------------------- + +The development server will provide you with a `.mb` (mothball) file, +when you click the `[mb]` link next to a category. + +Just drop that file into the `mothballs` directory, +and the server will pick it up. + +If you remove a mothball, +the category will vanish, +but points scored in that category won't! + + + +Resources Directory +=================== + + +Making it look better +------------------- + +`mothd` provides some built-in HTML for rendering a complete contest, +but it's rather bland. +You can override everything by dropping a new file into the `resources` directory: + +* `basic.css` is used by the default HTML to pretty things up +* `index.html` is the landing page, which asks to register a team +* `puzzle.html` and `puzzle.js` render a puzzle from JSON +* `puzzle-list.html` and `puzzle-list.js` render the list of active puzzles from JSON +* `scoreboard.html` and `scoreboard.js` render the current scoreboard from JSON +* Any other file in the `resources` directory will be served up, too. + +If you don't want to read through the source code, I don't blame you. +Run a `mothd` server and pull the various static resources into your `resources` directory, +and then you can start hacking away at them. + + +Changing scoring +-------------- + +Believe it or not, +scoring is determined client-side in the scoreboard, +from the points log. +You can hack in whatever algorithm you like. + +If you do hack in a new algorithm, +please be a dear and email it to us. +We'd love to see it! + diff --git a/src/award.go b/src/award.go index e975599..f8cf653 100644 --- a/src/award.go +++ b/src/award.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "strconv" "strings" @@ -8,39 +9,62 @@ import ( ) type Award struct { - When time.Time - TeamId string + When time.Time + TeamId string Category string - Points int + Points int } func (a *Award) String() string { return fmt.Sprintf("%d %s %s %d", a.When.Unix(), a.TeamId, a.Category, a.Points) } +func (a *Award) MarshalJSON() ([]byte, error) { + if a == nil { + return []byte("null"), nil + } + jTeamId, err := json.Marshal(a.TeamId) + if err != nil { + return nil, err + } + jCategory, err := json.Marshal(a.Category) + if err != nil { + return nil, err + } + ret := fmt.Sprintf( + "[%d,%s,%s,%d]", + a.When.Unix(), + jTeamId, + jCategory, + a.Points, + ) + return []byte(ret), nil +} + func ParseAward(s string) (*Award, error) { ret := Award{} - + + s = strings.Trim(s, " \t\n") + parts := strings.SplitN(s, " ", 5) if len(parts) < 4 { return nil, fmt.Errorf("Malformed award string") } - + whenEpoch, err := strconv.ParseInt(parts[0], 10, 64) - if (err != nil) { + if err != nil { return nil, fmt.Errorf("Malformed timestamp: %s", parts[0]) } ret.When = time.Unix(whenEpoch, 0) - + ret.TeamId = parts[1] ret.Category = parts[2] - + points, err := strconv.Atoi(parts[3]) - if (err != nil) { - return nil, fmt.Errorf("Malformed Points: %s", parts[3]) + if err != nil { + return nil, fmt.Errorf("Malformed Points: %s: %v", parts[3], err) } ret.Points = points return &ret, nil } - diff --git a/src/award_test.go b/src/award_test.go index ba3ff06..2875557 100644 --- a/src/award_test.go +++ b/src/award_test.go @@ -20,11 +20,11 @@ func TestAward(t *testing.T) { if a.Points != 1 { t.Error("Points parsed wrong") } - + if a.String() != entry { t.Error("String conversion wonky") } - + if _, err := ParseAward("bad bad bad 1"); err == nil { t.Error("Not throwing error on bad timestamp") } diff --git a/src/handlers.go b/src/handlers.go index f3b60ea..fa508c2 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -1,65 +1,61 @@ package main import ( + "bufio" + "encoding/json" "fmt" + "log" "net/http" "os" "regexp" - "strings" "strconv" - "io" - "log" - "bufio" + "strings" ) -// anchoredSearch looks for needle in r, -// skipping the first skip space-delimited words -func anchoredSearch(r io.Reader, needle string, skip int) bool { - scanner := bufio.NewScanner(r) - for scanner.Scan() { - line := scanner.Text() - parts := strings.SplitN(line, " ", skip+1) - if (len(parts) > skip) && (parts[skip] == needle) { - return true - } +func respond(w http.ResponseWriter, req *http.Request, status Status, short string, description string) { + // This is a kludge. Do proper parsing when this causes problems. + accept := req.Header.Get("Accept") + if strings.Contains(accept, "application/json") { + ShowJSend(w, status, short, description) + } else { + ShowHtml(w, status, short, description) } - - return false } -func anchoredSearchFile(filename string, needle string, skip int) bool { - r, err := os.Open(filename) - if err != nil { - return false - } - defer r.Close() - - return anchoredSearch(r, needle, skip) -} - - func (ctx Instance) registerHandler(w http.ResponseWriter, req *http.Request) { - teamname := req.FormValue("n") - teamid := req.FormValue("h") - - if matched, _ := regexp.MatchString("[^0-9a-z]", teamid); matched { - teamid = "" + 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 == "") { - showPage(w, "Invalid Entry", "Oops! Are you sure you got that right?") + respond( + w, req, Fail, + "Invalid Entry", + "Either `id` or `name` was missing from this request.", + ) return } - - if ! anchoredSearchFile(ctx.StatePath("teamids.txt"), teamid, 0) { - showPage(w, "Invalid Team ID", "I don't have a record of that team ID. Maybe you used capital letters accidentally?") + + if !anchoredSearchFile(ctx.StatePath("teamids.txt"), teamid, 0) { + respond( + w, req, Fail, + "Invalid Team ID", + "I don't have a record of that team ID. Maybe you used capital letters accidentally?", + ) return } - - f, err := os.OpenFile(ctx.StatePath(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 err != nil { - showPage( - w, + log.Print(err) + respond( + w, req, Fail, "Registration failed", "Unable to register. Perhaps a teammate has already registered?", ) @@ -67,7 +63,11 @@ func (ctx Instance) registerHandler(w http.ResponseWriter, req *http.Request) { } defer f.Close() fmt.Fprintln(f, teamname) - showPage(w, "Success", "Okay, your team has been named and you may begin using your team ID!") + respond( + w, req, Success, + "Team registered", + "Okay, your team has been named and you may begin using your team ID!", + ) } func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) { @@ -75,8 +75,12 @@ func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) { token := req.FormValue("k") // Check answer - if ! anchoredSearchFile(ctx.StatePath("tokens.txt"), token, 0) { - showPage(w, "Unrecognized token", "I don't recognize that token. Did you type in the whole thing?") + if !anchoredSearchFile(ctx.StatePath("tokens.txt"), token, 0) { + respond( + w, req, Fail, + "Unrecognized token", + "I don't recognize that token. Did you type in the whole thing?", + ) return } @@ -95,17 +99,29 @@ func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) { if matched, _ := regexp.MatchString("^[A-Za-z0-9_-]", category); matched { category = "" } - + if (category == "") || (points == 0) { - showPage(w, "Unrecognized token", "Something doesn't look right about that token") + respond( + w, req, Fail, + "Unrecognized token", + "Something doesn't look right about that token", + ) return } - + if err := ctx.AwardPoints(teamid, category, points); err != nil { - showPage(w, "Error awarding points", err.Error()) + respond( + w, req, Fail, + "Error awarding points", + err.Error(), + ) return } - showPage(w, "Points awarded", fmt.Sprintf("%d points for %s!", points, teamid)) + respond( + w, req, Success, + "Points awarded", + fmt.Sprintf("%d points for %s!", points, teamid), + ) } func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) { @@ -118,83 +134,121 @@ func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) { if err != nil { points = 0 } - + catmb, ok := ctx.Categories[category] - if ! ok { - showPage(w, "Category does not exist", "The specified category does not exist. Sorry!") + if !ok { + respond( + w, req, Fail, + "Category does not exist", + "The requested category does not exist. Sorry!", + ) return } // Get the answers haystack, err := catmb.Open("answers.txt") if err != nil { - showPage(w, "Answers do not exist", - "Please tell the contest people that the mothball for this category has no answers.txt in it!") + respond( + w, req, Error, + "Answers do not exist", + "Please tell the contest people that the mothball for this category has no answers.txt in it!", + ) return } defer haystack.Close() - + // Look for the answer needle := fmt.Sprintf("%d %s", points, answer) - if ! anchoredSearch(haystack, needle, 0) { - showPage(w, "Wrong answer", err.Error()) + if !anchoredSearch(haystack, needle, 0) { + respond( + w, req, Fail, + "Wrong answer", + err.Error(), + ) return } if err := ctx.AwardPoints(teamid, category, points); err != nil { - showPage(w, "Error awarding points", err.Error()) - return - } - showPage(w, "Points awarded", fmt.Sprintf("%d points for %s!", points, teamid)) -} - -func (ctx Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { - puzzles := map[string][]interface{}{} - // v := map[string][]interface{}{"Moo": {1, "0177f85ae895a33e2e7c5030c3dc484e8173e55c"}} - // j, _ := json.Marshal(v) - - for _, category := range ctx.Categories { - log.Print(puzzles, category) - } -} - -func (ctx Instance) pointsHandler(w http.ResponseWriter, req *http.Request) { - -} - -// staticHandler serves up static files. -func (ctx Instance) rootHandler(w http.ResponseWriter, req *http.Request) { - if req.URL.Path == "/" { - showPage( - w, - "Welcome", - ` -

Register your team

- -
- Team ID:
- Team name: - -
- -
- If someone on your team has already registered, - proceed to the - puzzles overview. -
- `, + respond( + w, req, Error, + "Error awarding points", + err.Error(), ) return } - - http.NotFound(w, req) + respond( + w, req, Success, + "Points awarded", + fmt.Sprintf("%d points for %s!", points, teamid), + ) +} + +type PuzzleMap struct { + Points int `json:"points"` + Path string `json:"path"` +} + +func (ctx Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + res := map[string][]PuzzleMap{} + for catName, mb := range ctx.Categories { + mf, err := mb.Open("map.txt") + if err != nil { + log.Print(err) + } + defer mf.Close() + + pm := make([]PuzzleMap, 0, 30) + scanner := bufio.NewScanner(mf) + for scanner.Scan() { + line := scanner.Text() + parts := strings.Split(line, " ") + if len(parts) != 2 { + continue + } + pointval, err := strconv.Atoi(parts[0]) + if err != nil { + log.Print(err) + continue + } + dir := parts[1] + + pm = append(pm, PuzzleMap{pointval, dir}) + log.Print(pm) + } + + res[catName] = pm + log.Print(res) + } + jres, _ := json.Marshal(res) + w.Write(jres) +} + +func (ctx Instance) pointsHandler(w http.ResponseWriter, req *http.Request) { + log := ctx.PointsLog() + jlog, err := json.Marshal(log) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + w.Write(jlog) +} + +func (ctx Instance) staticHandler(w http.ResponseWriter, req *http.Request) { + ServeStatic(w, req, ctx.ResourcesDir) } func (ctx Instance) BindHandlers(mux *http.ServeMux) { - mux.HandleFunc(ctx.Base + "/", ctx.rootHandler) - mux.HandleFunc(ctx.Base + "/register", ctx.registerHandler) - mux.HandleFunc(ctx.Base + "/token", ctx.tokenHandler) - mux.HandleFunc(ctx.Base + "/answer", ctx.answerHandler) - mux.HandleFunc(ctx.Base + "/puzzles.json", ctx.puzzlesHandler) - mux.HandleFunc(ctx.Base + "/points.json", ctx.pointsHandler) + mux.HandleFunc(ctx.Base+"/", ctx.staticHandler) + mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler) + mux.HandleFunc(ctx.Base+"/token", ctx.tokenHandler) + mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler) + mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler) + mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler) } diff --git a/src/instance.go b/src/instance.go index 01011bc5..893e487 100644 --- a/src/instance.go +++ b/src/instance.go @@ -1,28 +1,31 @@ package main import ( - "os" - "log" "bufio" "fmt" - "time" "io/ioutil" + "log" + "os" "path" "strings" + "time" ) type Instance struct { - Base string - MothballDir string - StateDir string - Categories map[string]*Mothball + Base string + MothballDir string + StateDir string + ResourcesDir string + Categories map[string]*Mothball } -func NewInstance(base, mothballDir, stateDir string) (*Instance, error) { +func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) { ctx := &Instance{ - Base: strings.TrimRight(base, "/"), - MothballDir: mothballDir, - StateDir: stateDir, + Base: strings.TrimRight(base, "/"), + MothballDir: mothballDir, + StateDir: stateDir, + ResourcesDir: resourcesDir, + Categories: map[string]*Mothball{}, } // Roll over and die if directories aren't even set up @@ -33,28 +36,46 @@ func NewInstance(base, mothballDir, stateDir string) (*Instance, error) { return nil, err } - ctx.Initialize() - + ctx.MaybeInitialize() + return ctx, nil } -func (ctx *Instance) Initialize () { - // Make sure points directories exist +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 { + 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 <= 9999; i += 1 { fmt.Fprintf(f, "%04d\n", i) } } - - if f, err := os.OpenFile(ctx.StatePath("initialized"), os.O_WRONLY | os.O_CREATE | os.O_EXCL, 0644); err == nil { - defer f.Close() - fmt.Println("Remove this file to reinitialize the contest") + + // 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 (ctx Instance) MothballPath(parts ...string) string { @@ -67,7 +88,6 @@ func (ctx *Instance) StatePath(parts ...string) string { return path.Join(ctx.StateDir, tail) } - func (ctx *Instance) PointsLog() []Award { var ret []Award @@ -78,7 +98,7 @@ func (ctx *Instance) PointsLog() []Award { return ret } defer f.Close() - + scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() @@ -89,7 +109,7 @@ func (ctx *Instance) PointsLog() []Award { } ret = append(ret, *cur) } - + return ret } @@ -98,17 +118,17 @@ func (ctx *Instance) AwardPoints(teamid string, category string, points int) err fn := fmt.Sprintf("%s-%s-%d", teamid, category, points) tmpfn := ctx.StatePath("points.tmp", fn) newfn := ctx.StatePath("points.new", fn) - + contents := fmt.Sprintf("%d %s %s %d\n", time.Now().Unix(), teamid, category, points) - + if err := ioutil.WriteFile(tmpfn, []byte(contents), 0644); err != nil { return err } - + if err := os.Rename(tmpfn, newfn); err != nil { return err } - + log.Printf("Award %s %s %d", teamid, category, points) return nil } diff --git a/src/maintenance.go b/src/maintenance.go index b359e63..dd231c7 100644 --- a/src/maintenance.go +++ b/src/maintenance.go @@ -1,22 +1,25 @@ package main import ( - "log" + "fmt" "io/ioutil" - "time" + "log" "os" "strings" - "fmt" + "time" ) // maintenance runs func (ctx *Instance) Tidy() { + // Do they want to reset everything? + ctx.MaybeInitialize() + // Skip if we've been disabled if _, err := os.Stat(ctx.StatePath("disabled")); err == nil { log.Print("disabled file found, suspending maintenance") return } - + // Skip if we've expired untilspec, err := ioutil.ReadFile(ctx.StatePath("until")) if err == nil { @@ -30,7 +33,7 @@ func (ctx *Instance) Tidy() { } } } - + // Any new categories? files, err := ioutil.ReadDir(ctx.MothballPath()) if err != nil { @@ -39,24 +42,25 @@ func (ctx *Instance) Tidy() { for _, f := range files { filename := f.Name() filepath := ctx.MothballPath(filename) - if ! strings.HasSuffix(filename, ".mb") { + if !strings.HasSuffix(filename, ".mb") { continue } categoryName := strings.TrimSuffix(filename, ".mb") - + 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 } } // Any old categories? log.Print("XXX: Check for and reap old categories") - + ctx.CollectPoints() } @@ -69,7 +73,7 @@ func (ctx *Instance) CollectPoints() { return } defer logf.Close() - + files, err := ioutil.ReadDir(ctx.StatePath("points.new")) if err != nil { log.Printf("Error reading packages: %s", err) @@ -95,11 +99,9 @@ func (ctx *Instance) CollectPoints() { } } - - // maintenance is the goroutine that runs a periodic maintenance task func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) { - for ;; time.Sleep(maintenanceInterval) { + for ; ; time.Sleep(maintenanceInterval) { ctx.Tidy() } } diff --git a/src/mothball.go b/src/mothball.go index 1a5dfcd..4f18257 100644 --- a/src/mothball.go +++ b/src/mothball.go @@ -10,16 +10,16 @@ import ( ) type Mothball struct { - zf *zip.ReadCloser + zf *zip.ReadCloser filename string - mtime time.Time + mtime time.Time } func OpenMothball(filename string) (*Mothball, error) { var m Mothball - + m.filename = filename - + err := m.Refresh() if err != nil { return nil, err @@ -28,21 +28,21 @@ func OpenMothball(filename string) (*Mothball, error) { return &m, nil } -func (m *Mothball) Close() (error) { +func (m *Mothball) Close() error { return m.zf.Close() } -func (m *Mothball) Refresh() (error) { +func (m *Mothball) Refresh() error { info, err := os.Stat(m.filename) if err != nil { return err } mtime := info.ModTime() - - if ! mtime.After(m.mtime) { + + if !mtime.After(m.mtime) { return nil } - + zf, err := zip.OpenReader(m.filename) if err != nil { return err @@ -53,7 +53,7 @@ func (m *Mothball) Refresh() (error) { } m.zf = zf m.mtime = mtime - + return nil } @@ -73,7 +73,7 @@ func (m *Mothball) ReadFile(filename string) ([]byte, error) { return nil, err } defer f.Close() - + bytes, err := ioutil.ReadAll(f) return bytes, err } diff --git a/src/mothball_test.go b/src/mothball_test.go index d1b46bb..8115809 100644 --- a/src/mothball_test.go +++ b/src/mothball_test.go @@ -16,7 +16,7 @@ func TestMothball(t *testing.T) { return } defer os.Remove(tf.Name()) - + w := zip.NewWriter(tf) f, err := w.Create("moo.txt") if err != nil { @@ -33,7 +33,7 @@ func TestMothball(t *testing.T) { } w.Close() tf.Close() - + // Now read it in mb, err := OpenMothball(tf.Name()) if err != nil { @@ -46,7 +46,7 @@ func TestMothball(t *testing.T) { t.Error(err) return } - + line := make([]byte, 200) n, err := cow.Read(line) if (err != nil) && (err != io.EOF) { @@ -59,5 +59,5 @@ func TestMothball(t *testing.T) { t.Error("Contents didn't match") return } - + } diff --git a/src/mothd.go b/src/mothd.go index 93c27b8..9a7bcc4 100644 --- a/src/mothd.go +++ b/src/mothd.go @@ -2,34 +2,11 @@ package main import ( "flag" - "fmt" "log" "net/http" "time" ) -func showPage(w http.ResponseWriter, title string, body string) { - w.WriteHeader(http.StatusOK) - - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "%s", title) - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "

%s

", title) - fmt.Fprintf(w, "
%s
", body) - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") -} - - func logRequest(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Printf("HTTP %s %s %s\n", r.RemoteAddr, r.Method, r.URL) @@ -42,11 +19,11 @@ func setup() error { } func main() { - base := flag.String( - "base", - "/", - "Base URL of this instance", - ) + base := flag.String( + "base", + "/", + "Base URL of this instance", + ) mothballDir := flag.String( "mothballs", "/moth/mothballs", @@ -57,9 +34,14 @@ func main() { "/moth/state", "Path to write state", ) + resourcesDir := flag.String( + "resources", + "/moth/resources", + "Path to static resources (HTML, images, css, ...)", + ) maintenanceInterval := flag.Duration( "maint", - 20 * time.Second, + 20*time.Second, "Maintenance interval", ) listen := flag.String( @@ -68,12 +50,12 @@ func main() { "[host]:port to bind and listen", ) flag.Parse() - + if err := setup(); err != nil { log.Fatal(err) } - ctx, err := NewInstance(*base, *mothballDir, *stateDir) + ctx, err := NewInstance(*base, *mothballDir, *stateDir, *resourcesDir) if err != nil { log.Fatal(err) } diff --git a/src/static.go b/src/static.go new file mode 100644 index 0000000..44ca41e --- /dev/null +++ b/src/static.go @@ -0,0 +1,237 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// anchoredSearch looks for needle in r, +// skipping the first skip space-delimited words +func anchoredSearch(r io.Reader, needle string, skip int) bool { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, " ", skip+1) + if (len(parts) > skip) && (parts[skip] == needle) { + return true + } + } + + return false +} + +// anchoredSearchFile performs an anchoredSearch on a given filename +func anchoredSearchFile(filename string, needle string, skip int) bool { + r, err := os.Open(filename) + if err != nil { + return false + } + defer r.Close() + + return anchoredSearch(r, needle, skip) +} + +type Status int + +const ( + Success = iota + Fail + Error +) + +// ShowJSend renders a JSend response to w +func ShowJSend(w http.ResponseWriter, status Status, short string, description string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) // RFC2616 makes it pretty clear that 4xx codes are for the user-agent + + statusStr := "" + switch status { + case Success: + statusStr = "success" + case Fail: + statusStr = "fail" + default: + statusStr = "error" + } + + jshort, _ := json.Marshal(short) + jdesc, _ := json.Marshal(description) + fmt.Fprintf( + w, + `{"status":"%s","data":{"short":%s,"description":%s}}"`, + statusStr, jshort, jdesc, + ) +} + +// ShowHtml delevers an HTML response to w +func ShowHtml(w http.ResponseWriter, status Status, title string, body string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + statusStr := "" + switch status { + case Success: + statusStr = "Success" + case Fail: + statusStr = "Fail" + default: + statusStr = "Error" + } + + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "%s", title) + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "

%s

", statusStr, title) + fmt.Fprintf(w, "
%s
", body) + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") +} + +// staticStylesheet serves up a basic stylesheet. +// This is designed to be usable on small touchscreens (like mobile phones) +func staticStylesheet(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/css") + w.WriteHeader(http.StatusOK) + + fmt.Fprint( + w, + ` +/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */ +body { + font-family: sans-serif; + max-width: 40em; + background: #282a33; + color: #f6efdc; +} +a:any-link { + color: #8b969a; +} +h1 { + background: #5e576b; + color: #9e98a8; +} +h1.Fail, h1.Error { + background: #3a3119; + color: #ffcc98; +} +h1.Fail:before { + content: "Fail: "; +} +h1.Error:before { + content: "Error: "; +} +p { + margin: 1em 0em; +} +form, pre { + margin: 1em; +} +input { + padding: 0.6em; + margin: 0.2em; +} +li { + margin: 0.5em 0em; +} + `, + ) +} + +// staticIndex serves up a basic landing page +func staticIndex(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Welcome", + ` +

Register your team

+ +
+ Team ID:
+ Team name: + +
+ +

+ If someone on your team has already registered, + proceed to the + puzzles overview. +

+ `, + ) +} + +func staticScoreboard(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Scoreboard", + "XXX: This would be the scoreboard", + ) +} + +func staticPuzzles(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Puzzles", + "XXX: This would be the puzzles overview", + ) +} + +func tryServeFile(w http.ResponseWriter, req *http.Request, path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + d, err := f.Stat() + if err != nil { + return false + } + + http.ServeContent(w, req, path, d.ModTime(), f) + return true +} + +func ServeStatic(w http.ResponseWriter, req *http.Request, resourcesDir string) { + path := req.URL.Path + if strings.Contains(path, "..") { + http.Error(w, "Invalid URL path", http.StatusBadRequest) + return + } + if path == "/" { + path = "/index.html" + } + + fpath := filepath.Join(resourcesDir, path) + if tryServeFile(w, req, fpath) { + return + } + + switch path { + case "/basic.css": + staticStylesheet(w) + case "/index.html": + staticIndex(w) + case "/scoreboard.html": + staticScoreboard(w) + case "/puzzles.html": + staticPuzzles(w) + default: + http.NotFound(w, req) + } +} From 78f2e2a79c72ae8d8b14d396960b0c4a8865592f Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Mon, 17 Sep 2018 23:40:05 +0000 Subject: [PATCH 03/13] Flesh out remaining handlers I think at this point we just need to drop in text for puzzle.html, puzzle-list.html, and scoreboard.html, and we'll have a working server. --- src/award.go | 24 ++++------- src/handlers.go | 108 +++++++++++++++++++++++++----------------------- src/instance.go | 12 ++++++ 3 files changed, 76 insertions(+), 68 deletions(-) diff --git a/src/award.go b/src/award.go index f8cf653..5885ad5 100644 --- a/src/award.go +++ b/src/award.go @@ -3,7 +3,6 @@ package main import ( "encoding/json" "fmt" - "strconv" "strings" "time" ) @@ -46,25 +45,16 @@ func ParseAward(s string) (*Award, error) { s = strings.Trim(s, " \t\n") - parts := strings.SplitN(s, " ", 5) - if len(parts) < 4 { - return nil, fmt.Errorf("Malformed award string") + var whenEpoch int64 + + n, err := fmt.Sscanf(s, "%d %s %s %d", &whenEpoch, &ret.TeamId, &ret.Category, &ret.Points) + if err != nil { + return nil, err + } else if n != 4 { + return nil, fmt.Errorf("Malformed award string: only parsed %d fields", n) } - whenEpoch, err := strconv.ParseInt(parts[0], 10, 64) - if err != nil { - return nil, fmt.Errorf("Malformed timestamp: %s", parts[0]) - } ret.When = time.Unix(whenEpoch, 0) - ret.TeamId = parts[1] - ret.Category = parts[2] - - points, err := strconv.Atoi(parts[3]) - if err != nil { - return nil, fmt.Errorf("Malformed Points: %s: %v", parts[3], err) - } - ret.Points = points - return &ret, nil } diff --git a/src/handlers.go b/src/handlers.go index fa508c2..41150f7 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -7,18 +7,18 @@ import ( "log" "net/http" "os" - "regexp" "strconv" "strings" ) -func respond(w http.ResponseWriter, req *http.Request, status Status, short string, description string) { +func respond(w http.ResponseWriter, req *http.Request, status Status, short string, format string, a ...interface{}) { + long := fmt.Sprintf(format, a...) // This is a kludge. Do proper parsing when this causes problems. accept := req.Header.Get("Accept") if strings.Contains(accept, "application/json") { - ShowJSend(w, status, short, description) + ShowJSend(w, status, short, long) } else { - ShowHtml(w, status, short, description) + ShowHtml(w, status, short, long) } } @@ -71,40 +71,50 @@ func (ctx Instance) registerHandler(w http.ResponseWriter, req *http.Request) { } func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) { - teamid := req.FormValue("t") - token := req.FormValue("k") + teamid := req.FormValue("id") + token := req.FormValue("token") - // Check answer - if !anchoredSearchFile(ctx.StatePath("tokens.txt"), token, 0) { + var category string + var points int + var fluff string + + stoken := strings.Replace(token, ":", " ", 2) + n, err := fmt.Sscanf(stoken, "%s %d %s", &category, &points, &fluff) + if err != nil || n != 3 { respond( w, req, Fail, - "Unrecognized token", - "I don't recognize that token. Did you type in the whole thing?", + "Malformed token", + "That doesn't look like a token: %v.", err, ) return } - parts := strings.Split(token, ":") - category := "" - pointstr := "" - if len(parts) >= 2 { - category = parts[0] - pointstr = parts[1] - } - points, err := strconv.Atoi(pointstr) - if err != nil { - points = 0 - } - // Defang category name; prevent directory traversal - if matched, _ := regexp.MatchString("^[A-Za-z0-9_-]", category); matched { - category = "" + if (category == "") || (points <= 0) { + respond( + w, req, Fail, + "Weird token", + "That token doesn't make any sense.", + ) + return } - if (category == "") || (points == 0) { + f, err := ctx.OpenCategoryFile(category, "tokens.txt") + if err != nil { + respond( + w, req, Fail, + "Cannot list valid tokens", + err.Error(), + ) + return + } + defer f.Close() + + // Make sure the token is in the list + if !anchoredSearch(f, token, 0) { respond( w, req, Fail, "Unrecognized token", - "Something doesn't look right about that token", + "I don't recognize that token. Did you type in the whole thing?", ) return } @@ -120,38 +130,32 @@ func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) { respond( w, req, Success, "Points awarded", - fmt.Sprintf("%d points for %s!", points, teamid), + "%d points for %s!", points, teamid, ) } func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) { - teamid := req.FormValue("t") - category := req.FormValue("c") - pointstr := req.FormValue("p") - answer := req.FormValue("a") + teamid := req.FormValue("id") + category := req.FormValue("cat") + pointstr := req.FormValue("points") + answer := req.FormValue("answer") points, err := strconv.Atoi(pointstr) if err != nil { - points = 0 - } - - catmb, ok := ctx.Categories[category] - if !ok { respond( w, req, Fail, - "Category does not exist", - "The requested category does not exist. Sorry!", + "Cannot parse point value", + "This doesn't look like an integer: %s", pointstr, ) return } - // Get the answers - haystack, err := catmb.Open("answers.txt") + haystack, err := ctx.OpenCategoryFile(category, "answers.txt") if err != nil { respond( - w, req, Error, - "Answers do not exist", - "Please tell the contest people that the mothball for this category has no answers.txt in it!", + w, req, Fail, + "Cannot list answers", + "Unable to read the list of answers for this category.", ) return } @@ -163,7 +167,7 @@ func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) { respond( w, req, Fail, "Wrong answer", - err.Error(), + "That is not the correct answer for %s %d.", category, points, ) return } @@ -204,16 +208,18 @@ func (ctx Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { scanner := bufio.NewScanner(mf) for scanner.Scan() { line := scanner.Text() - parts := strings.Split(line, " ") - if len(parts) != 2 { - continue - } - pointval, err := strconv.Atoi(parts[0]) + + var pointval int + var dir string + + n, err := fmt.Sscanf(line, "%d %s", &pointval, &dir) if err != nil { - log.Print(err) + log.Printf("Parsing map for %s: %v", catName, err) + continue + } else if n != 2 { + log.Printf("Parsing map for %s: short read", catName) continue } - dir := parts[1] pm = append(pm, PuzzleMap{pointval, dir}) log.Print(pm) diff --git a/src/instance.go b/src/instance.go index 893e487..771fefd 100644 --- a/src/instance.go +++ b/src/instance.go @@ -3,6 +3,7 @@ package main import ( "bufio" "fmt" + "io" "io/ioutil" "log" "os" @@ -132,3 +133,14 @@ func (ctx *Instance) AwardPoints(teamid string, category string, points int) err 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 { + return nil, fmt.Errorf("No such category: %s", category) + } + + filename := path.Join(parts...) + f, err := mb.Open(filename) + return f, err +} From 4c3fe349360848a5fa00730ab00c088a5047e0a0 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 18 Sep 2018 00:02:44 +0000 Subject: [PATCH 04/13] Check for duplicate points --- src/award.go | 46 +++++++++++++++++++++++++++++----------------- src/handlers.go | 29 ++++++++++++++++++++++++++++- src/instance.go | 25 +++++++++++++++++++++---- src/maintenance.go | 17 +++++++++++++++-- src/static.go | 28 ---------------------------- 5 files changed, 93 insertions(+), 52 deletions(-) diff --git a/src/award.go b/src/award.go index 5885ad5..acea0cd 100644 --- a/src/award.go +++ b/src/award.go @@ -14,6 +14,25 @@ type Award struct { Points int } +func ParseAward(s string) (*Award, error) { + ret := Award{} + + s = strings.Trim(s, " \t\n") + + var whenEpoch int64 + + n, err := fmt.Sscanf(s, "%d %s %s %d", &whenEpoch, &ret.TeamId, &ret.Category, &ret.Points) + if err != nil { + return nil, err + } else if n != 4 { + return nil, fmt.Errorf("Malformed award string: only parsed %d fields", n) + } + + ret.When = time.Unix(whenEpoch, 0) + + return &ret, nil +} + func (a *Award) String() string { return fmt.Sprintf("%d %s %s %d", a.When.Unix(), a.TeamId, a.Category, a.Points) } @@ -40,21 +59,14 @@ func (a *Award) MarshalJSON() ([]byte, error) { return []byte(ret), nil } -func ParseAward(s string) (*Award, error) { - ret := Award{} - - s = strings.Trim(s, " \t\n") - - var whenEpoch int64 - - n, err := fmt.Sscanf(s, "%d %s %s %d", &whenEpoch, &ret.TeamId, &ret.Category, &ret.Points) - if err != nil { - return nil, err - } else if n != 4 { - return nil, fmt.Errorf("Malformed award string: only parsed %d fields", n) +func (a *Award) Same(o *Award) bool { + switch { + case a.TeamId != o.TeamId: + return false + case a.Category != o.Category: + return false + case a.Points != o.Points: + return false } - - ret.When = time.Unix(whenEpoch, 0) - - return &ret, nil -} + return true +} \ No newline at end of file diff --git a/src/handlers.go b/src/handlers.go index 41150f7..2f5f223 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -4,6 +4,7 @@ import ( "bufio" "encoding/json" "fmt" + "io" "log" "net/http" "os" @@ -22,6 +23,32 @@ func respond(w http.ResponseWriter, req *http.Request, status Status, short stri } } +// anchoredSearch looks for needle in r, +// skipping the first skip space-delimited words +func anchoredSearch(r io.Reader, needle string, skip int) bool { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, " ", skip+1) + if (len(parts) > skip) && (parts[skip] == needle) { + return true + } + } + + return false +} + +// anchoredSearchFile performs an anchoredSearch on a given filename +func anchoredSearchFile(filename string, needle string, skip int) bool { + r, err := os.Open(filename) + if err != nil { + return false + } + defer r.Close() + + return anchoredSearch(r, needle, skip) +} + func (ctx Instance) registerHandler(w http.ResponseWriter, req *http.Request) { teamname := req.FormValue("name") teamid := req.FormValue("id") @@ -172,7 +199,7 @@ func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) { return } - if err := ctx.AwardPoints(teamid, category, points); err != nil { + if err := ctx.AwardPointsUniquely(teamid, category, points); err != nil { respond( w, req, Error, "Error awarding points", diff --git a/src/instance.go b/src/instance.go index 771fefd..dfb4168 100644 --- a/src/instance.go +++ b/src/instance.go @@ -89,8 +89,8 @@ func (ctx *Instance) StatePath(parts ...string) string { return path.Join(ctx.StateDir, tail) } -func (ctx *Instance) PointsLog() []Award { - var ret []Award +func (ctx *Instance) PointsLog() []*Award { + var ret []*Award fn := ctx.StatePath("points.log") f, err := os.Open(fn) @@ -108,14 +108,14 @@ func (ctx *Instance) PointsLog() []Award { log.Printf("Skipping malformed award line %s: %s", line, err) continue } - ret = append(ret, *cur) + ret = append(ret, cur) } return ret } // awardPoints gives points points to team teamid in category category -func (ctx *Instance) AwardPoints(teamid string, category string, points int) error { +func (ctx *Instance) AwardPoints(teamid, category string, points int) error { fn := fmt.Sprintf("%s-%s-%d", teamid, category, points) tmpfn := ctx.StatePath("points.tmp", fn) newfn := ctx.StatePath("points.new", fn) @@ -134,6 +134,23 @@ func (ctx *Instance) AwardPoints(teamid string, category string, points int) err return nil } +func (ctx *Instance) AwardPointsUniquely(teamid, category string, points int) error { + a := Award{ + When: time.Now(), + TeamId: teamid, + Category: category, + Points: points, + } + + for _, e := range ctx.PointsLog() { + if a.Same(e) { + return fmt.Errorf("Points already awarded to this team in this category") + } + } + + return ctx.AwardPoints(teamid, category, points) +} + func (ctx *Instance) OpenCategoryFile(category string, parts ...string) (io.ReadCloser, error) { mb, ok := ctx.Categories[category] if !ok { diff --git a/src/maintenance.go b/src/maintenance.go index dd231c7..75d839b 100644 --- a/src/maintenance.go +++ b/src/maintenance.go @@ -90,8 +90,21 @@ func (ctx *Instance) CollectPoints() { log.Printf("Can't parse award file %s: %s", filename, err) continue } - fmt.Fprintf(logf, "%s\n", award.String()) - log.Print("XXX: check for duplicates", award.String()) + + 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) diff --git a/src/static.go b/src/static.go index 44ca41e..ef209d3 100644 --- a/src/static.go +++ b/src/static.go @@ -1,42 +1,14 @@ package main import ( - "bufio" "encoding/json" "fmt" - "io" "net/http" "os" "path/filepath" "strings" ) -// anchoredSearch looks for needle in r, -// skipping the first skip space-delimited words -func anchoredSearch(r io.Reader, needle string, skip int) bool { - scanner := bufio.NewScanner(r) - for scanner.Scan() { - line := scanner.Text() - parts := strings.SplitN(line, " ", skip+1) - if (len(parts) > skip) && (parts[skip] == needle) { - return true - } - } - - return false -} - -// anchoredSearchFile performs an anchoredSearch on a given filename -func anchoredSearchFile(filename string, needle string, skip int) bool { - r, err := os.Open(filename) - if err != nil { - return false - } - defer r.Close() - - return anchoredSearch(r, needle, skip) -} - type Status int const ( From 2dc0b0690f1261ee5696b888f154bc6e5168c0e3 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 18 Sep 2018 00:06:29 +0000 Subject: [PATCH 05/13] New rule: (category, points) must be unique across the contest. No more having 2 tokens in a category with the same point value. --- src/handlers.go | 2 +- src/instance.go | 40 +++++++++++++++++++--------------------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/handlers.go b/src/handlers.go index 2f5f223..b522c33 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -199,7 +199,7 @@ func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) { return } - if err := ctx.AwardPointsUniquely(teamid, category, points); err != nil { + if err := ctx.AwardPoints(teamid, category, points); err != nil { respond( w, req, Error, "Error awarding points", diff --git a/src/instance.go b/src/instance.go index dfb4168..f35a14b 100644 --- a/src/instance.go +++ b/src/instance.go @@ -114,27 +114,12 @@ func (ctx *Instance) PointsLog() []*Award { return ret } -// awardPoints gives points points to team teamid in category 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 { - fn := fmt.Sprintf("%s-%s-%d", teamid, category, points) - tmpfn := ctx.StatePath("points.tmp", fn) - newfn := ctx.StatePath("points.new", fn) - - contents := fmt.Sprintf("%d %s %s %d\n", time.Now().Unix(), teamid, category, points) - - if err := ioutil.WriteFile(tmpfn, []byte(contents), 0644); err != nil { - return err - } - - if err := os.Rename(tmpfn, newfn); err != nil { - return err - } - - log.Printf("Award %s %s %d", teamid, category, points) - return nil -} - -func (ctx *Instance) AwardPointsUniquely(teamid, category string, points int) error { a := Award{ When: time.Now(), TeamId: teamid, @@ -147,8 +132,21 @@ func (ctx *Instance) AwardPointsUniquely(teamid, category string, points int) er 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) - return ctx.AwardPoints(teamid, category, points) + if err := ioutil.WriteFile(tmpfn, []byte(a.String()), 0644); err != nil { + return err + } + + if err := os.Rename(tmpfn, newfn); err != nil { + return err + } + + log.Printf("Award %s %s %d", teamid, category, points) + return nil } func (ctx *Instance) OpenCategoryFile(category string, parts ...string) (io.ReadCloser, error) { From 1fdba38ad31ffa0f3a7fccea8c94eaf774bfcc49 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 18 Sep 2018 03:32:24 +0000 Subject: [PATCH 06/13] Basic rendering of open puzzles --- src/award.go | 4 +-- src/handlers.go | 75 ++++++++++++++++++++++++++++++---------------- src/instance.go | 8 ++--- src/maintenance.go | 4 +-- src/static.go | 71 +++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 127 insertions(+), 35 deletions(-) diff --git a/src/award.go b/src/award.go index acea0cd..759393b 100644 --- a/src/award.go +++ b/src/award.go @@ -64,9 +64,9 @@ func (a *Award) Same(o *Award) bool { case a.TeamId != o.TeamId: return false case a.Category != o.Category: - return false + return false case a.Points != o.Points: return false } return true -} \ No newline at end of file +} diff --git a/src/handlers.go b/src/handlers.go index b522c33..198c27a 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -23,32 +23,18 @@ func respond(w http.ResponseWriter, req *http.Request, status Status, short stri } } -// anchoredSearch looks for needle in r, -// skipping the first skip space-delimited words -func anchoredSearch(r io.Reader, needle string, skip int) bool { +// hasLine returns true if line appears in r. +// The entire line must match. +func hasLine(r io.Reader, line string) bool { scanner := bufio.NewScanner(r) for scanner.Scan() { - line := scanner.Text() - parts := strings.SplitN(line, " ", skip+1) - if (len(parts) > skip) && (parts[skip] == needle) { + if scanner.Text() == line { return true } } - return false } -// anchoredSearchFile performs an anchoredSearch on a given filename -func anchoredSearchFile(filename string, needle string, skip int) bool { - r, err := os.Open(filename) - if err != nil { - return false - } - defer r.Close() - - return anchoredSearch(r, needle, skip) -} - func (ctx Instance) registerHandler(w http.ResponseWriter, req *http.Request) { teamname := req.FormValue("name") teamid := req.FormValue("id") @@ -69,7 +55,17 @@ func (ctx Instance) registerHandler(w http.ResponseWriter, req *http.Request) { return } - if !anchoredSearchFile(ctx.StatePath("teamids.txt"), teamid, 0) { + teamids, err := os.Open(ctx.StatePath("teamids.txt")) + if err != nil { + respond( + w, req, Fail, + "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( w, req, Fail, "Invalid Team ID", @@ -137,7 +133,7 @@ func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) { defer f.Close() // Make sure the token is in the list - if !anchoredSearch(f, token, 0) { + if !hasLine(f, token) { respond( w, req, Fail, "Unrecognized token", @@ -190,7 +186,7 @@ func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) { // Look for the answer needle := fmt.Sprintf("%d %s", points, answer) - if !anchoredSearch(haystack, needle, 0) { + if !hasLine(haystack, needle) { respond( w, req, Fail, "Wrong answer", @@ -215,23 +211,46 @@ func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) { } type PuzzleMap struct { - Points int `json:"points"` - Path string `json:"path"` + Points int + Path string +} + +func (pm *PuzzleMap) MarshalJSON() ([]byte, error) { + if pm == nil { + return []byte("null"), nil + } + + jPath, err := json.Marshal(pm.Path) + if err != nil { + return nil, err + } + + ret := fmt.Sprintf("[%d,%s]", pm.Points, string(jPath)) + return []byte(ret), nil } func (ctx Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) + maxByCategory := map[string]int{} + for _, a := range ctx.PointsLog() { + if a.Points > maxByCategory[a.Category] { + maxByCategory[a.Category] = a.Points + } + } + res := map[string][]PuzzleMap{} for catName, mb := range ctx.Categories { mf, err := mb.Open("map.txt") if err != nil { log.Print(err) + continue } defer mf.Close() pm := make([]PuzzleMap, 0, 30) + completed := true scanner := bufio.NewScanner(mf) for scanner.Scan() { line := scanner.Text() @@ -249,11 +268,17 @@ func (ctx Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { } pm = append(pm, PuzzleMap{pointval, dir}) - log.Print(pm) + + if pointval > maxByCategory[catName] { + completed = false + break + } + } + if completed { + pm = append(pm, PuzzleMap{0, ""}) } res[catName] = pm - log.Print(res) } jres, _ := json.Marshal(res) w.Write(jres) diff --git a/src/instance.go b/src/instance.go index f35a14b..0ecdbcf 100644 --- a/src/instance.go +++ b/src/instance.go @@ -121,10 +121,10 @@ func (ctx *Instance) PointsLog() []*Award { // 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, + When: time.Now(), + TeamId: teamid, Category: category, - Points: points, + Points: points, } for _, e := range ctx.PointsLog() { @@ -132,7 +132,7 @@ func (ctx *Instance) AwardPoints(teamid, category string, points int) error { 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) diff --git a/src/maintenance.go b/src/maintenance.go index 75d839b..518c92d 100644 --- a/src/maintenance.go +++ b/src/maintenance.go @@ -90,7 +90,7 @@ func (ctx *Instance) CollectPoints() { log.Printf("Can't parse award file %s: %s", filename, err) continue } - + duplicate := false for _, e := range ctx.PointsLog() { if award.Same(e) { @@ -98,7 +98,7 @@ func (ctx *Instance) CollectPoints() { break } } - + if duplicate { log.Printf("Skipping duplicate points: %s", award.String()) } else { diff --git a/src/static.go b/src/static.go index ef209d3..ee788ca 100644 --- a/src/static.go +++ b/src/static.go @@ -119,6 +119,13 @@ input { } li { margin: 0.5em 0em; +} +nav { + border: solid black 2px; +} +nav li { + display: inline; + margin: 2em; } `, ) @@ -158,8 +165,68 @@ func staticScoreboard(w http.ResponseWriter) { func staticPuzzles(w http.ResponseWriter) { ShowHtml( w, Success, - "Puzzles", - "XXX: This would be the puzzles overview", + "Open Puzzles", + ` +
+ + `, ) } From 7942f4fc5a538829fa09dbda8ef24d2f68f1fcb1 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 19 Sep 2018 00:22:03 +0000 Subject: [PATCH 07/13] Add scoreboard plus formatting tweaks --- res/basic.css | 44 ++++++++++++ res/puzzles.html | 81 ++++++++++++++++++++++ res/scoreboard.html | 147 ++++++++++++++++++++++++++++++++++++++++ src/handlers.go | 8 ++- src/static.go | 159 ++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 423 insertions(+), 16 deletions(-) create mode 100644 res/basic.css create mode 100644 res/puzzles.html create mode 100644 res/scoreboard.html diff --git a/res/basic.css b/res/basic.css new file mode 100644 index 0000000..e133165 --- /dev/null +++ b/res/basic.css @@ -0,0 +1,44 @@ +/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */ +body { + font-family: sans-serif; + max-width: 40em; + background: #282a33; + color: #f6efdc; +} +a:any-link { + color: #8b969a; +} +h1 { + background: #5e576b; + color: #9e98a8; +} +.Fail, .Error { + background: #3a3119; + color: #ffcc98; +} +.Fail:before { + content: "Fail: "; +} +.Error:before { + content: "Error: "; +} +p { + margin: 1em 0em; +} +form, pre { + margin: 1em; +} +input { + padding: 0.6em; + margin: 0.2em; +} +nav { + border: solid black 2px; +} +nav ul, .category ul { + padding: 1em; +} +nav li, .category li { + display: inline; + margin: 1em; +} diff --git a/res/puzzles.html b/res/puzzles.html new file mode 100644 index 0000000..4d1942e --- /dev/null +++ b/res/puzzles.html @@ -0,0 +1,81 @@ + + + + Open Puzzles + + + + + + + +

Open Puzzles

+
+
+
+ + + diff --git a/res/scoreboard.html b/res/scoreboard.html new file mode 100644 index 0000000..9d2d513 --- /dev/null +++ b/res/scoreboard.html @@ -0,0 +1,147 @@ + + + + Open Puzzles + + + + + + + +

Scoreboard

+
+
+
+ + + diff --git a/src/handlers.go b/src/handlers.go index 198c27a..527d4d7 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -5,9 +5,11 @@ import ( "encoding/json" "fmt" "io" + "io/ioutil" "log" "net/http" "os" + "rand" "strconv" "strings" ) @@ -285,13 +287,15 @@ func (ctx Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { } func (ctx Instance) pointsHandler(w http.ResponseWriter, req *http.Request) { - log := ctx.PointsLog() - jlog, err := json.Marshal(log) + plog := ctx.PointsLog() + jlog, err := json.Marshal(plog) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + // XXX: go through plog, building an array of teams, so we can anonymize team IDs + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/src/static.go b/src/static.go index ee788ca..4176ef4 100644 --- a/src/static.go +++ b/src/static.go @@ -97,14 +97,14 @@ h1 { background: #5e576b; color: #9e98a8; } -h1.Fail, h1.Error { +.Fail, .Error { background: #3a3119; color: #ffcc98; } -h1.Fail:before { +.Fail:before { content: "Fail: "; } -h1.Error:before { +.Error:before { content: "Error: "; } p { @@ -117,15 +117,15 @@ input { padding: 0.6em; margin: 0.2em; } -li { - margin: 0.5em 0em; -} nav { border: solid black 2px; } -nav li { +nav ul, .category ul { + padding: 1em; +} +nav li, .category li { display: inline; - margin: 2em; + margin: 1em; } `, ) @@ -158,7 +158,136 @@ func staticScoreboard(w http.ResponseWriter) { ShowHtml( w, Success, "Scoreboard", - "XXX: This would be the scoreboard", + ` +
+
+
+ + `, ) } @@ -167,11 +296,12 @@ func staticPuzzles(w http.ResponseWriter) { w, Success, "Open Puzzles", ` -
+
+
+
`, ) From 5075bb601d49ac3f9ae3006906b2d785eb6eba4c Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 19 Sep 2018 03:29:05 +0000 Subject: [PATCH 08/13] Everything but puzzle content serving --- res/basic.css | 4 ++++ res/scoreboard.html | 3 ++- src/award.go | 2 +- src/handlers.go | 32 ++++++++++++++++++++++++-------- src/instance.go | 6 ++++++ src/maintenance.go | 13 ++++++++++--- src/static.go | 8 +++----- 7 files changed, 50 insertions(+), 18 deletions(-) diff --git a/res/basic.css b/res/basic.css index e133165..a8a856e 100644 --- a/res/basic.css +++ b/res/basic.css @@ -32,6 +32,10 @@ input { padding: 0.6em; margin: 0.2em; } +#scoreboard .category { + border: solid white 1px; + display: inline-block; +} nav { border: solid black 2px; } diff --git a/res/scoreboard.html b/res/scoreboard.html index 9d2d513..e16abf6 100644 --- a/res/scoreboard.html +++ b/res/scoreboard.html @@ -1,7 +1,7 @@ - Open Puzzles + Scoreboard @@ -97,6 +97,7 @@ function scoreboard(element, continuous) { var width = maxWidth * catPct; var bar = document.createElement("span"); + bar.classList.add("category"); bar.classList.add("cat" + ncat); bar.style.width = width + "%"; bar.textContent = category + ": " + catTeam; diff --git a/src/award.go b/src/award.go index 759393b..4a8ba75 100644 --- a/src/award.go +++ b/src/award.go @@ -17,7 +17,7 @@ type Award struct { func ParseAward(s string) (*Award, error) { ret := Award{} - s = strings.Trim(s, " \t\n") + s = strings.TrimSpace(s) var whenEpoch int64 diff --git a/src/handlers.go b/src/handlers.go index 527d4d7..cdd6254 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -5,11 +5,9 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "log" "net/http" "os" - "rand" "strconv" "strings" ) @@ -287,19 +285,37 @@ func (ctx Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { } func (ctx Instance) pointsHandler(w http.ResponseWriter, req *http.Request) { - plog := ctx.PointsLog() - jlog, err := json.Marshal(plog) + var ret struct { + Teams map[string]string `json:"teams"` + Points []*Award `json:"points"` + } + ret.Teams = map[string]string{} + ret.Points = ctx.PointsLog() + + teamNumbersById := map[string]int{} + for nr, a := range ret.Points { + teamNumber, ok := teamNumbersById[a.TeamId] + if !ok { + teamName, err := ctx.TeamName(a.TeamId) + if err != nil { + teamName = "[unregistered]" + } + teamNumber = nr + teamNumbersById[a.TeamId] = teamNumber + ret.Teams[strconv.FormatInt(int64(teamNumber), 16)] = teamName + } + a.TeamId = strconv.FormatInt(int64(teamNumber), 16) + } + + jret, err := json.Marshal(ret) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - // XXX: go through plog, building an array of teams, so we can anonymize team IDs - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - - w.Write(jlog) + w.Write(jret) } func (ctx Instance) staticHandler(w http.ResponseWriter, req *http.Request) { diff --git a/src/instance.go b/src/instance.go index 0ecdbcf..bad4112 100644 --- a/src/instance.go +++ b/src/instance.go @@ -159,3 +159,9 @@ func (ctx *Instance) OpenCategoryFile(category string, parts ...string) (io.Read f, err := mb.Open(filename) return f, err } + +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 518c92d..501e140 100644 --- a/src/maintenance.go +++ b/src/maintenance.go @@ -34,6 +34,16 @@ func (ctx *Instance) Tidy() { } } + // Refresh all current 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) + } + } + // Any new categories? files, err := ioutil.ReadDir(ctx.MothballPath()) if err != nil { @@ -58,9 +68,6 @@ func (ctx *Instance) Tidy() { } } - // Any old categories? - log.Print("XXX: Check for and reap old categories") - ctx.CollectPoints() } diff --git a/src/static.go b/src/static.go index 4176ef4..64d4031 100644 --- a/src/static.go +++ b/src/static.go @@ -60,10 +60,10 @@ func ShowHtml(w http.ResponseWriter, status Status, title string, body string) { fmt.Fprintf(w, "") fmt.Fprintf(w, "%s", title) fmt.Fprintf(w, "") - fmt.Fprintf(w, "") + fmt.Fprintf(w, "") fmt.Fprintf(w, "") fmt.Fprintf(w, "") - fmt.Fprintf(w, "

%s

", statusStr, title) + fmt.Fprintf(w, "

%s

", statusStr, title) fmt.Fprintf(w, "
%s
", body) fmt.Fprintf(w, "