From e9295d67bf6d509425f25cfa8ef3570fda4a9f65 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 2 May 2018 21:45:45 +0000 Subject: [PATCH 01/50] Checking in go work --- handlers.go | 7 +++++ maintenance.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ mothd.go | 54 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 handlers.go create mode 100644 maintenance.go create mode 100644 mothd.go diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..9051185 --- /dev/null +++ b/handlers.go @@ -0,0 +1,7 @@ +package main + +import ( + "fmt" + "net/http" +) + diff --git a/maintenance.go b/maintenance.go new file mode 100644 index 0000000..1406f38 --- /dev/null +++ b/maintenance.go @@ -0,0 +1,71 @@ +package main + +import ( + "log" + "io/ioutil" + "time" + "strings" +) + +func allfiles(dirpath) []string { + files, err := ioutil.ReadDir(dirpath) + if (err != nil) { + log.Printf("Error reading directory %s: %s", dirpath, err) + return [] + } + return files +} + +// maintenance runs +func tidy() { + // Skip if we've been disabled + if exists(statePath("disabled")) { + log.Print("disabled file found, suspending maintenance") + return + } + + // Skip if we've expired + untilspec, _ := ioutil.ReadFile(statePath("until")) + until, err := time.Parse(time.RFC3339, string(untilspec)) + if err == nil { + if until.Before(time.Now()) { + log.Print("until file time reached, suspending maintenance") + return + } + } + + log.Print("Hello, I'm maintaining!") + + // + // Get current list of categories + // + newCategories := []string{} + for f := range(allfiles(mothPath("packages"))) { + filename := f.Name() + filepath := mothPath(path.Join("packages", 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 + + // + // Collect new points + // + for f := range allfiles(statePath("points.new")) { + + } +} + +// maintenance is the goroutine that runs a periodic maintenance task +func maintenance() { + for ;; time.Sleep(maintenanceInterval) { + tidy() + } +} diff --git a/mothd.go b/mothd.go new file mode 100644 index 0000000..8d6c08c --- /dev/null +++ b/mothd.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "html" + "io/ioutil" + "log" + "net/http" + "os" + "path" + "strings" + "time" +) + +var basePath = "." +var maintenanceInterval = 20 * time.Second +var categories = []string{} + +func mooHandler(w http.ResponseWriter, req *http.Request) { + moo := req.FormValue("moo") + fmt.Fprintf(w, "Hello, %q. %s", html.EscapeString(req.URL.Path), html.EscapeString(moo)) +} + +func rootHandler(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/" { + http.NotFound(w, req) + return + } +} + +func mothPath(filename string) string { + return path.Join(basePath, filename) +} + +func statePath(filename string) string { + return path.Join(basePath, "state", filename) +} + +func exists(filename string) bool { + if _, err := os.Stat(filename); os.IsNotExist(err) { + return false; + } + return true; +} + +func main() { + log.Print("Sup") + go maintenance(); + http.HandleFunc("/", rootHandler) + http.HandleFunc("/moo/", mooHandler) + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +// docker run --rm -it -p 5880:8080 -v $HOME:$HOME:ro -w $(pwd) golang go run mothd.go From 75eef3d19f0ec9345970f5033b69c419477c4a6a Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 2 May 2018 23:00:53 +0000 Subject: [PATCH 02/50] still working on maintenance functions --- maintenance.go | 16 ++++++++++++++-- mothd.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/maintenance.go b/maintenance.go index 1406f38..8afe8af 100644 --- a/maintenance.go +++ b/maintenance.go @@ -42,7 +42,7 @@ func tidy() { newCategories := []string{} for f := range(allfiles(mothPath("packages"))) { filename := f.Name() - filepath := mothPath(path.Join("packages", filename)) + filepath := mothPath("packages", filename) if ! strings.HasSuffix(filename, ".mb") { continue } @@ -58,9 +58,21 @@ func tidy() { // // Collect new points // + pointsLog = os.OpenFile(statePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) for f := range allfiles(statePath("points.new")) { - + filename := statePath("points.new", f.Name()) + s := ioutil.ReadFile(filename) + award, err := ParseAward(s) + if (err != nil) { + log.Printf("Can't parse award file %s: %s", filename, err) + continue + } + fmt.Fprintf(pointsLog, "%s\n", award.String()) + log.Print(award.String()) + pointsLog.Sync() + os.Remove(filename) } + pointsLog.Close() } // maintenance is the goroutine that runs a periodic maintenance task diff --git a/mothd.go b/mothd.go index 8d6c08c..437214d 100644 --- a/mothd.go +++ b/mothd.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path" + "strconv" "strings" "time" ) @@ -16,6 +17,47 @@ var basePath = "." var maintenanceInterval = 20 * time.Second var categories = []string{} +type Award struct { + when time.Time, + team string, + category string, + points int, + comment string +} + +func ParseAward(s string) (*Award, error) { + ret := Award{} + + parts := strings.SplitN(s, " ", 5) + if len(parts) < 4 { + return nil, Error("Malformed award string") + } + + whenEpoch, err = strconv.Atoi(parts[0]) + if (err != nil) { + return nil, Errorf("Malformed timestamp: %s", parts[0]) + } + ret.when = time.Unix(whenEpoch, 0) + + ret.team = parts[1] + ret.category = parts[2] + + points, err = strconv.Atoi(parts[3]) + if (err != nil) { + return nil, Errorf("Malformed points: %s", parts[3]) + } + + if len(parts) == 5 { + ret.comment = parts[4] + } + + return &ret +} + +func (a *Award) String() string { + return fmt.Sprintf("%d %s %s %d %s", a.when.Unix(), a.team, a.category, a.points, a.comment) +} + func mooHandler(w http.ResponseWriter, req *http.Request) { moo := req.FormValue("moo") fmt.Fprintf(w, "Hello, %q. %s", html.EscapeString(req.URL.Path), html.EscapeString(moo)) @@ -28,12 +70,12 @@ func rootHandler(w http.ResponseWriter, req *http.Request) { } } -func mothPath(filename string) string { - return path.Join(basePath, filename) +func mothPath(parts ...string) string { + return path.Join(basePath, parts...) } -func statePath(filename string) string { - return path.Join(basePath, "state", filename) +func statePath(parts ...string) string { + return path.Join(basePath, "state", parts...) } func exists(filename string) bool { From f0497f35d9fe23f78fff3a78a29c92deee7477f5 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 3 May 2018 15:58:03 +0000 Subject: [PATCH 03/50] more work --- maintenance.go | 20 ++------------ mothd.go | 40 ---------------------------- points.go | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 58 deletions(-) create mode 100644 points.go diff --git a/maintenance.go b/maintenance.go index 8afe8af..827d49e 100644 --- a/maintenance.go +++ b/maintenance.go @@ -16,6 +16,7 @@ func allfiles(dirpath) []string { return files } + // maintenance runs func tidy() { // Skip if we've been disabled @@ -55,24 +56,7 @@ func tidy() { } categories = newCategories - // - // Collect new points - // - pointsLog = os.OpenFile(statePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - for f := range allfiles(statePath("points.new")) { - filename := statePath("points.new", f.Name()) - s := ioutil.ReadFile(filename) - award, err := ParseAward(s) - if (err != nil) { - log.Printf("Can't parse award file %s: %s", filename, err) - continue - } - fmt.Fprintf(pointsLog, "%s\n", award.String()) - log.Print(award.String()) - pointsLog.Sync() - os.Remove(filename) - } - pointsLog.Close() + collectPoints() } // maintenance is the goroutine that runs a periodic maintenance task diff --git a/mothd.go b/mothd.go index 437214d..3ae0c26 100644 --- a/mothd.go +++ b/mothd.go @@ -17,46 +17,6 @@ var basePath = "." var maintenanceInterval = 20 * time.Second var categories = []string{} -type Award struct { - when time.Time, - team string, - category string, - points int, - comment string -} - -func ParseAward(s string) (*Award, error) { - ret := Award{} - - parts := strings.SplitN(s, " ", 5) - if len(parts) < 4 { - return nil, Error("Malformed award string") - } - - whenEpoch, err = strconv.Atoi(parts[0]) - if (err != nil) { - return nil, Errorf("Malformed timestamp: %s", parts[0]) - } - ret.when = time.Unix(whenEpoch, 0) - - ret.team = parts[1] - ret.category = parts[2] - - points, err = strconv.Atoi(parts[3]) - if (err != nil) { - return nil, Errorf("Malformed points: %s", parts[3]) - } - - if len(parts) == 5 { - ret.comment = parts[4] - } - - return &ret -} - -func (a *Award) String() string { - return fmt.Sprintf("%d %s %s %d %s", a.when.Unix(), a.team, a.category, a.points, a.comment) -} func mooHandler(w http.ResponseWriter, req *http.Request) { moo := req.FormValue("moo") diff --git a/points.go b/points.go new file mode 100644 index 0000000..4314d36 --- /dev/null +++ b/points.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "log" + "os" +) + +type Award struct { + when time.Time, + team string, + category string, + points int, + comment string +} + +func ParseAward(s string) (*Award, error) { + ret := Award{} + + parts := strings.SplitN(s, " ", 5) + if len(parts) < 4 { + return nil, Error("Malformed award string") + } + + whenEpoch, err = strconv.Atoi(parts[0]) + if (err != nil) { + return nil, Errorf("Malformed timestamp: %s", parts[0]) + } + ret.when = time.Unix(whenEpoch, 0) + + ret.team = parts[1] + ret.category = parts[2] + + points, err = strconv.Atoi(parts[3]) + if (err != nil) { + return nil, Errorf("Malformed points: %s", parts[3]) + } + + if len(parts) == 5 { + ret.comment = parts[4] + } + + return &ret +} + +func (a *Award) String() string { + return fmt.Sprintf("%d %s %s %d %s", a.when.Unix(), a.team, a.category, a.points, a.comment) +} + +// collectPoints gathers up files in points.new/ and appends their contents to points.log, +// removing each points.new/ file as it goes. +func collectPoints() { + pointsLog = os.OpenFile(statePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + defer pointsLog.Close() + + for f := range allfiles(statePath("points.new")) { + filename := statePath("points.new", f.Name()) + s := ioutil.ReadFile(filename) + award, err := ParseAward(s) + if (err != nil) { + log.Printf("Can't parse award file %s: %s", filename, err) + continue + } + fmt.Fprintf(pointsLog, "%s\n", award.String()) + log.Print(award.String()) + pointsLog.Sync() + err := os.Remove(filename) + if (err != nil) { + log.Printf("Unable to remove %s: %s", filename, err) + } + } +} \ No newline at end of file From f84b23ace423703aa239af8f3fb635042c30ec8b Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 4 May 2018 23:20:51 +0000 Subject: [PATCH 04/50] It compiles! --- handlers.go | 104 +++++++++++++++++++++++++++++++++++++++++++++++++ maintenance.go | 23 +++++------ mothd.go | 80 ++++++++++++++++++++++++++++++------- points.go | 91 ++++++++++++++++++++++++++++++------------- 4 files changed, 245 insertions(+), 53 deletions(-) diff --git a/handlers.go b/handlers.go index 9051185..e9f430f 100644 --- a/handlers.go +++ b/handlers.go @@ -3,5 +3,109 @@ package main import ( "fmt" "net/http" + "os" + "regexp" + "strings" + "strconv" ) +func 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 = "" + } + + if (teamid == "") || (teamname == "") { + showPage(w, "Invalid Entry", "Oops! Are you sure you got that right?") + return + } + + if ! anchoredSearch(statePath("assigned.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) + if err != nil { + showPage( + w, + "Registration failed", + "Unable to register. Perhaps a teammate has already registered?", + ) + return + } + defer f.Close() + fmt.Fprintln(f, teamname) + 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) { + teamid := req.FormValue("t") + token := req.FormValue("k") + + // Check answer + if ! anchoredSearch(token, statePath("tokens.txt"), 0) { + showPage(w, "Unrecognized token", "I don't recognize that token. Did you type in the whole thing?") + 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) { + showPage(w, "Unrecognized token", "Something doesn't look right about that token") + return + } + + if err := 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) { + teamid := req.FormValue("t") + category := req.FormValue("c") + pointstr := req.FormValue("p") + answer := req.FormValue("a") + + 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 = "" + } + + // Check answer + needle := fmt.Sprintf("%s %s", points, answer) + haystack := mothPath("packages", category, "answers.txt") + if ! anchoredSearch(haystack, needle, 0) { + showPage(w, "Wrong answer", err.Error()) + } + + if err := 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)) +} + diff --git a/maintenance.go b/maintenance.go index 827d49e..93fca5a 100644 --- a/maintenance.go +++ b/maintenance.go @@ -4,19 +4,14 @@ import ( "log" "io/ioutil" "time" + "os" "strings" ) -func allfiles(dirpath) []string { - files, err := ioutil.ReadDir(dirpath) - if (err != nil) { - log.Printf("Error reading directory %s: %s", dirpath, err) - return [] - } - return files +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 @@ -37,11 +32,17 @@ func tidy() { log.Print("Hello, I'm maintaining!") - // + // Make sure points directories exist + os.Mkdir(statePath("points.tmp"), 0755) + os.Mkdir(statePath("points.new"), 0755) + // Get current list of categories - // newCategories := []string{} - for f := range(allfiles(mothPath("packages"))) { + files, err := ioutil.ReadDir(mothPath("packages")) + if err != nil { + log.Printf("Error reading packages: %s", err) + } + for _, f := range files { filename := f.Name() filepath := mothPath("packages", filename) if ! strings.HasSuffix(filename, ".mb") { diff --git a/mothd.go b/mothd.go index 3ae0c26..08e0e99 100644 --- a/mothd.go +++ b/mothd.go @@ -1,41 +1,90 @@ package main import ( + "bufio" "fmt" - "html" - "io/ioutil" "log" "net/http" + "io/ioutil" "os" "path" - "strconv" "strings" "time" ) -var basePath = "." +var basePath = "/home/neale/src/moth" var maintenanceInterval = 20 * time.Second 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 + } + } -func mooHandler(w http.ResponseWriter, req *http.Request) { - moo := req.FormValue("moo") - fmt.Fprintf(w, "Hello, %q. %s", html.EscapeString(req.URL.Path), html.EscapeString(moo)) + return false } -func rootHandler(w http.ResponseWriter, req *http.Request) { - if req.URL.Path != "/" { - http.NotFound(w, req) - return + +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 + } + + return nil +} + +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, "

%s

", title) + fmt.Fprintf(w, "
%s
", body) + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") } func mothPath(parts ...string) string { - return path.Join(basePath, parts...) + tail := path.Join(parts...) + return path.Join(basePath, tail) } func statePath(parts ...string) string { - return path.Join(basePath, "state", parts...) + tail := path.Join(parts...) + return path.Join(basePath, "state", tail) } func exists(filename string) bool { @@ -48,8 +97,9 @@ func exists(filename string) bool { func main() { log.Print("Sup") go maintenance(); - http.HandleFunc("/", rootHandler) - http.HandleFunc("/moo/", mooHandler) + http.HandleFunc("/register", registerHandler) + http.HandleFunc("/token", tokenHandler) + http.HandleFunc("/answer", answerHandler) log.Fatal(http.ListenAndServe(":8080", nil)) } diff --git a/points.go b/points.go index 4314d36..9c3e2e2 100644 --- a/points.go +++ b/points.go @@ -1,17 +1,21 @@ package main import ( + "bufio" "fmt" + "io/ioutil" "log" "os" + "strconv" + "strings" + "time" ) type Award struct { - when time.Time, - team string, - category string, - points int, - comment string + when time.Time + team string + category string + points int } func ParseAward(s string) (*Award, error) { @@ -19,53 +23,86 @@ func ParseAward(s string) (*Award, error) { parts := strings.SplitN(s, " ", 5) if len(parts) < 4 { - return nil, Error("Malformed award string") + return nil, fmt.Errorf("Malformed award string") } - whenEpoch, err = strconv.Atoi(parts[0]) + whenEpoch, err := strconv.ParseInt(parts[0], 10, 64) if (err != nil) { - return nil, Errorf("Malformed timestamp: %s", parts[0]) + return nil, fmt.Errorf("Malformed timestamp: %s", parts[0]) } ret.when = time.Unix(whenEpoch, 0) ret.team = parts[1] ret.category = parts[2] - points, err = strconv.Atoi(parts[3]) + points, err := strconv.Atoi(parts[3]) if (err != nil) { - return nil, Errorf("Malformed points: %s", parts[3]) + return nil, fmt.Errorf("Malformed points: %s", parts[3]) } - - if len(parts) == 5 { - ret.comment = parts[4] - } - - return &ret + ret.points = points + + return &ret, nil } func (a *Award) String() string { - return fmt.Sprintf("%d %s %s %d %s", a.when.Unix(), a.team, a.category, a.points, a.comment) + return fmt.Sprintf("%d %s %s %d", a.when.Unix(), a.team, 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 } // collectPoints gathers up files in points.new/ and appends their contents to points.log, // removing each points.new/ file as it goes. func collectPoints() { - pointsLog = os.OpenFile(statePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - defer pointsLog.Close() + 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() - for f := range allfiles(statePath("points.new")) { + 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 := ioutil.ReadFile(filename) - award, err := ParseAward(s) - if (err != nil) { + 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(pointsLog, "%s\n", award.String()) + fmt.Fprintf(logf, "%s\n", award.String()) log.Print(award.String()) - pointsLog.Sync() - err := os.Remove(filename) - if (err != nil) { + logf.Sync() + if err := os.Remove(filename); err != nil { log.Printf("Unable to remove %s: %s", filename, err) } } From 0b8db097520d42c76f0c8c50a3428dcceada3b68 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Mon, 7 May 2018 03:37:52 +0000 Subject: [PATCH 05/50] runs now --- handlers.go | 31 ++++++++++++- maintenance.go | 26 +++++------ mothd.go | 117 +++++++++++++++++++++++++++++++++++-------------- points.go | 19 ++++++++ 4 files changed, 144 insertions(+), 49 deletions(-) diff --git a/handlers.go b/handlers.go index e9f430f..132a821 100644 --- a/handlers.go +++ b/handlers.go @@ -22,7 +22,7 @@ func registerHandler(w http.ResponseWriter, req *http.Request) { return } - if ! anchoredSearch(statePath("assigned.txt"), teamid, 0) { + if ! anchoredSearch(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 } @@ -97,7 +97,7 @@ func answerHandler(w http.ResponseWriter, req *http.Request) { // Check answer needle := fmt.Sprintf("%s %s", points, answer) - haystack := mothPath("packages", category, "answers.txt") + haystack := cachePath(category, "answers.txt") if ! anchoredSearch(haystack, needle, 0) { showPage(w, "Wrong answer", err.Error()) } @@ -109,3 +109,30 @@ func answerHandler(w http.ResponseWriter, req *http.Request) { showPage(w, "Points awarded", fmt.Sprintf("%d points for %s!", points, teamid)) } +// staticHandler serves up static files. +func 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. +
+ `, + ) + return + } + + http.NotFound(w, req) +} diff --git a/maintenance.go b/maintenance.go index 93fca5a..c787318 100644 --- a/maintenance.go +++ b/maintenance.go @@ -15,36 +15,36 @@ func cacheMothball(filepath string, categoryName string) { // maintenance runs func tidy() { // Skip if we've been disabled - if exists(statePath("disabled")) { + if _, err := os.Stat(statePath("disabled")); err == nil { log.Print("disabled file found, suspending maintenance") return } // Skip if we've expired - untilspec, _ := ioutil.ReadFile(statePath("until")) - until, err := time.Parse(time.RFC3339, string(untilspec)) + untilspec, err := ioutil.ReadFile(statePath("until")) if err == nil { - if until.Before(time.Now()) { - log.Print("until file time reached, suspending maintenance") - return + 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 + } } } log.Print("Hello, I'm maintaining!") - // Make sure points directories exist - os.Mkdir(statePath("points.tmp"), 0755) - os.Mkdir(statePath("points.new"), 0755) - // Get current list of categories newCategories := []string{} - files, err := ioutil.ReadDir(mothPath("packages")) + files, err := ioutil.ReadDir(modulesPath()) if err != nil { log.Printf("Error reading packages: %s", err) } for _, f := range files { filename := f.Name() - filepath := mothPath("packages", filename) + filepath := modulesPath(filename) if ! strings.HasSuffix(filename, ".mb") { continue } @@ -61,7 +61,7 @@ func tidy() { } // maintenance is the goroutine that runs a periodic maintenance task -func maintenance() { +func maintenance(maintenanceInterval time.Duration) { for ;; time.Sleep(maintenanceInterval) { tidy() } diff --git a/mothd.go b/mothd.go index 08e0e99..b7d2909 100644 --- a/mothd.go +++ b/mothd.go @@ -2,18 +2,19 @@ package main import ( "bufio" + "flag" "fmt" "log" "net/http" - "io/ioutil" "os" "path" "strings" "time" ) -var basePath = "/home/neale/src/moth" -var maintenanceInterval = 20 * time.Second +var moduleDir string +var stateDir string +var cacheDir string var categories = []string{} // anchoredSearch looks for needle in filename, @@ -38,25 +39,6 @@ func anchoredSearch(filename string, needle string, skip int) bool { return false } - -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 - } - - return nil -} - func showPage(w http.ResponseWriter, title string, body string) { w.WriteHeader(http.StatusOK) @@ -77,30 +59,97 @@ func showPage(w http.ResponseWriter, title string, body string) { fmt.Fprintf(w, "") } -func mothPath(parts ...string) string { +func modulesPath(parts ...string) string { tail := path.Join(parts...) - return path.Join(basePath, tail) + return path.Join(moduleDir, tail) } func statePath(parts ...string) string { tail := path.Join(parts...) - return path.Join(basePath, "state", tail) + return path.Join(stateDir, tail) } -func exists(filename string) bool { - if _, err := os.Stat(filename); os.IsNotExist(err) { - return false; +func cachePath(parts ...string) string { + tail := path.Join(parts...) + return path.Join(cacheDir, tail) +} + +func setup() error { + // Roll over and die if directories aren't even set up + if _, err := os.Stat(modulesPath()); os.IsNotExist(err) { + return err } - return true; + 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 logRequest(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, r.URL) + handler.ServeHTTP(w, r) + }) } func main() { - log.Print("Sup") - go maintenance(); + flag.StringVar( + &moduleDir, + "modules", + "/modules", + "Path where your moth modules live", + ) + flag.StringVar( + &stateDir, + "state", + "/state", + "Path where state should be written", + ) + flag.StringVar( + &cacheDir, + "cache", + "/cache", + "Path for ephemeral cache", + ) + maintenanceInterval := flag.Duration( + "maint", + 20 * time.Second, + "Maintenance interval", + ) + listen := flag.String( + "listen", + ":8080", + "[host]:port to bind and listen", + ) + + if err := setup(); err != nil { + log.Fatal(err) + } + go maintenance(*maintenanceInterval) + + http.HandleFunc("/", rootHandler) + http.Handle("/static/", http.FileServer(http.Dir(cacheDir))) + http.HandleFunc("/register", registerHandler) http.HandleFunc("/token", tokenHandler) http.HandleFunc("/answer", answerHandler) - log.Fatal(http.ListenAndServe(":8080", nil)) -} -// docker run --rm -it -p 5880:8080 -v $HOME:$HOME:ro -w $(pwd) golang go run mothd.go + log.Printf("Listening on %s", *listen) + log.Fatal(http.ListenAndServe(*listen, logRequest(http.DefaultServeMux))) +} diff --git a/points.go b/points.go index 9c3e2e2..67817e3 100644 --- a/points.go +++ b/points.go @@ -73,6 +73,25 @@ func pointsLog() []Award { 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 + } + + 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() { From 6a9fcb7b9c1e94a8da13c28b484b3b1effbee9d5 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 8 May 2018 18:45:50 +0000 Subject: [PATCH 06/50] Add flags and other stuff --- handlers.go | 14 +++++++++++ maintenance.go | 2 -- mothd.go | 60 ++++++++++++++++++++++++++++------------------- points.go | 7 +++--- www/res/style.css | 2 +- 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/handlers.go b/handlers.go index 132a821..a1edddd 100644 --- a/handlers.go +++ b/handlers.go @@ -109,6 +109,20 @@ func answerHandler(w http.ResponseWriter, req *http.Request) { showPage(w, "Points awarded", fmt.Sprintf("%d points for %s!", points, teamid)) } +func 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 { + + } +} + +func pointsHandler(w http.ResponseWriter, req *http.Request) { + +} + // staticHandler serves up static files. func rootHandler(w http.ResponseWriter, req *http.Request) { if req.URL.Path == "/" { diff --git a/maintenance.go b/maintenance.go index c787318..f352c33 100644 --- a/maintenance.go +++ b/maintenance.go @@ -34,8 +34,6 @@ func tidy() { } } - log.Print("Hello, I'm maintaining!") - // Get current list of categories newCategories := []string{} files, err := ioutil.ReadDir(modulesPath()) diff --git a/mothd.go b/mothd.go index b7d2909..18f42a6 100644 --- a/mothd.go +++ b/mothd.go @@ -2,7 +2,7 @@ package main import ( "bufio" - "flag" + "github.com/namsral/flag" "fmt" "log" "net/http" @@ -45,15 +45,16 @@ func showPage(w http.ResponseWriter, title string, body string) { 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, "") fmt.Fprintf(w, "

%s

", title) fmt.Fprintf(w, "
%s
", body) fmt.Fprintf(w, "") fmt.Fprintf(w, "") @@ -74,6 +75,13 @@ func cachePath(parts ...string) string { 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) { @@ -101,55 +109,59 @@ func setup() error { return nil } -func logRequest(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, r.URL) - handler.ServeHTTP(w, r) - }) -} - func main() { - flag.StringVar( + var maintenanceInterval time.Duration + var listen string + + fs := flag.NewFlagSetWithEnvPrefix(os.Args[0], "MOTH", flag.ExitOnError) + fs.StringVar( &moduleDir, "modules", - "/modules", + "/moth/modules", "Path where your moth modules live", ) - flag.StringVar( + fs.StringVar( &stateDir, "state", - "/state", + "/moth/state", "Path where state should be written", ) - flag.StringVar( + fs.StringVar( &cacheDir, "cache", - "/cache", + "/moth/cache", "Path for ephemeral cache", ) - maintenanceInterval := flag.Duration( + fs.DurationVar( + &maintenanceInterval, "maint", 20 * time.Second, "Maintenance interval", ) - listen := flag.String( + 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) + go maintenance(maintenanceInterval) + fileserver := http.FileServer(http.Dir(cacheDir)) http.HandleFunc("/", rootHandler) - http.Handle("/static/", http.FileServer(http.Dir(cacheDir))) + 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))) + log.Printf("Listening on %s", listen) + log.Fatal(http.ListenAndServe(listen, logRequest(http.DefaultServeMux))) } diff --git a/points.go b/points.go index 67817e3..f6f16c0 100644 --- a/points.go +++ b/points.go @@ -13,7 +13,7 @@ import ( type Award struct { when time.Time - team string + teamid string category string points int } @@ -32,7 +32,7 @@ func ParseAward(s string) (*Award, error) { } ret.when = time.Unix(whenEpoch, 0) - ret.team = parts[1] + ret.teamid = parts[1] ret.category = parts[2] points, err := strconv.Atoi(parts[3]) @@ -45,7 +45,7 @@ func ParseAward(s string) (*Award, error) { } func (a *Award) String() string { - return fmt.Sprintf("%d %s %s %d", a.when.Unix(), a.team, a.category, a.points) + return fmt.Sprintf("%d %s %s %d", a.when.Unix(), a.teamid, a.category, a.points) } func pointsLog() []Award { @@ -89,6 +89,7 @@ func awardPoints(teamid string, category string, points int) error { return err } + log.Printf("Award %s %s %d", teamid, category, points) return nil } diff --git a/www/res/style.css b/www/res/style.css index c8ab4de..d18dcd1 100644 --- a/www/res/style.css +++ b/www/res/style.css @@ -124,6 +124,6 @@ a:visited { } ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.2); border-radius: 1em; } From cb399d9d7e0c3588f5c5b404c7c1b68113df1986 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 10 May 2018 02:01:09 +0000 Subject: [PATCH 07/50] Begin work on reading mothballs --- mothball.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 mothball.go diff --git a/mothball.go b/mothball.go new file mode 100644 index 0000000..845d861 --- /dev/null +++ b/mothball.go @@ -0,0 +1,52 @@ +package mothball + +import ( + "archive/zip" + "os" + "time" +) + +type Mothball struct { + zf *zipfile.File, + filename string, + mtime time.Time, +} + +func Open(filename string) (*Mothball, error) { + var m Mothball + + m.filename = filename + + err := m.Refresh() + if err != nil { + return err + } + + return &m +} + +func (m Mothball) Close() (error) { + return m.zf.Close() +} + +func (m Mothball) Refresh() (error) { + mtime, err := os.Stat(m.filename) + if err != nil { + return err + } + + if mtime == m.mtime { + return nil + } + + zf, err := zip.OpenReader(m.filename) + if err != nil { + return err + } + + m.zf.Close() + m.zf = zf + m.mtime = mtime +} + +func (m Mothball) \ No newline at end of file From ec2483f4c945707980935cb3d270e3c644d1f74c Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 10 May 2018 03:52:52 +0000 Subject: [PATCH 08/50] Mothball work --- mothball.go | 52 --------------------------- src/mothball/mothball.go | 67 +++++++++++++++++++++++++++++++++++ src/mothball/mothball_test.go | 63 ++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 52 deletions(-) delete mode 100644 mothball.go create mode 100644 src/mothball/mothball.go create mode 100644 src/mothball/mothball_test.go diff --git a/mothball.go b/mothball.go deleted file mode 100644 index 845d861..0000000 --- a/mothball.go +++ /dev/null @@ -1,52 +0,0 @@ -package mothball - -import ( - "archive/zip" - "os" - "time" -) - -type Mothball struct { - zf *zipfile.File, - filename string, - mtime time.Time, -} - -func Open(filename string) (*Mothball, error) { - var m Mothball - - m.filename = filename - - err := m.Refresh() - if err != nil { - return err - } - - return &m -} - -func (m Mothball) Close() (error) { - return m.zf.Close() -} - -func (m Mothball) Refresh() (error) { - mtime, err := os.Stat(m.filename) - if err != nil { - return err - } - - if mtime == m.mtime { - return nil - } - - zf, err := zip.OpenReader(m.filename) - if err != nil { - return err - } - - m.zf.Close() - m.zf = zf - m.mtime = mtime -} - -func (m Mothball) \ No newline at end of file diff --git a/src/mothball/mothball.go b/src/mothball/mothball.go new file mode 100644 index 0000000..1f63776 --- /dev/null +++ b/src/mothball/mothball.go @@ -0,0 +1,67 @@ +package mothball + +import ( + "archive/zip" + "fmt" + "io" + "os" + "time" +) + +type Mothball struct { + zf *zip.ReadCloser + filename string + mtime time.Time +} + +func Open(filename string) (*Mothball, error) { + var m Mothball + + m.filename = filename + + err := m.Refresh() + if err != nil { + return nil, err + } + + return &m, nil +} + +func (m *Mothball) Close() (error) { + return m.zf.Close() +} + +func (m *Mothball) Refresh() (error) { + info, err := os.Stat(m.filename) + if err != nil { + return err + } + mtime := info.ModTime() + + if mtime == m.mtime { + return nil + } + + zf, err := zip.OpenReader(m.filename) + if err != nil { + return err + } + + if m.zf != nil { + m.zf.Close() + } + m.zf = zf + m.mtime = mtime + + return nil +} + +func (m *Mothball) Open(filename string) (io.ReadCloser, error) { + for _, f := range m.zf.File { + if filename == f.Name { + ret, err := f.Open() + return ret, err + } + } + return nil, fmt.Errorf("File not found: %s in %s", filename, m.filename) +} diff --git a/src/mothball/mothball_test.go b/src/mothball/mothball_test.go new file mode 100644 index 0000000..f740e27 --- /dev/null +++ b/src/mothball/mothball_test.go @@ -0,0 +1,63 @@ +package mothball + +import ( + "archive/zip" + "fmt" + "io" + "io/ioutil" + "os" + "testing" +) + +func TestMothball(t *testing.T) { + tf, err := ioutil.TempFile("", "mothball") + if err != nil { + t.Error(err) + return + } + defer os.Remove(tf.Name()) + + w := zip.NewWriter(tf) + f, err := w.Create("moo.txt") + if err != nil { + t.Error(err) + return + } + // no Close method + + _, err = fmt.Fprintln(f, "The cow goes moo") + //.Write([]byte("The cow goes moo")) + if err != nil { + t.Error(err) + return + } + w.Close() + tf.Close() + + // Now read it in + mb, err := Open(tf.Name()) + if err != nil { + t.Error(err) + return + } + + cow, err := mb.Open("moo.txt") + if err != nil { + t.Error(err) + return + } + + line := make([]byte, 200) + n, err := cow.Read(line) + if (err != nil) && (err != io.EOF) { + t.Error(err) + return + } + + if string(line[:n]) != "The cow goes moo\n" { + t.Log(line) + t.Error("Contents didn't match") + return + } + +} From be67430e1b614d6370919956890d41971f51a964 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 10 May 2018 16:54:36 +0000 Subject: [PATCH 09/50] Add a fifth logo --- Dockerfile.moth | 2 +- www/credits.html | 1 + www/images/logo4.png | Bin 0 -> 144 bytes www/index.html | 1 + www/register.html | 1 + www/scoring.html | 1 + 6 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 www/images/logo4.png diff --git a/Dockerfile.moth b/Dockerfile.moth index ac5b9d9..0e8b7e9 100644 --- a/Dockerfile.moth +++ b/Dockerfile.moth @@ -1,6 +1,6 @@ FROM neale/eris -RUN apk --no-cache add lua5.2 lua5.3 +RUN apk --no-cache add lua5.1 lua5.2 lua5.3 RUN ln -s lua5.2 /usr/bin/lua # Install MOTH. This could be less obtuse. diff --git a/www/credits.html b/www/credits.html index b6be5ec..ec3367f 100644 --- a/www/credits.html +++ b/www/credits.html @@ -92,6 +92,7 @@ window.addEventListener("load", init); + diff --git a/www/images/logo4.png b/www/images/logo4.png new file mode 100644 index 0000000000000000000000000000000000000000..0c4add8021ea0fa0c6b3e38221bdfde6b796af15 GIT binary patch literal 144 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blmSQK*5Dp-y;YjHK@;M7UB8!1E zD+n_lzh4Z}C0XJcQ4*Y=R#Ki=l*&+EUaps!mtCBkSdglhUz9%kosASw5wE9bP0l+XkK|0N-8 literal 0 HcmV?d00001 diff --git a/www/index.html b/www/index.html index 7662935..ad9f3db 100644 --- a/www/index.html +++ b/www/index.html @@ -71,6 +71,7 @@ + diff --git a/www/register.html b/www/register.html index b4e07b6..07fe93a 100644 --- a/www/register.html +++ b/www/register.html @@ -46,6 +46,7 @@ + diff --git a/www/scoring.html b/www/scoring.html index 8c0dc60..e85dea4 100644 --- a/www/scoring.html +++ b/www/scoring.html @@ -118,6 +118,7 @@ + From 6bd96c2eda3fec8b3bdd113fc7ee71ac6074457d Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 11 May 2018 14:27:31 +0000 Subject: [PATCH 10/50] Add 4th image to cgi --- www/cgi-bin/koth.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/www/cgi-bin/koth.lua b/www/cgi-bin/koth.lua index df23523..2781e98 100644 --- a/www/cgi-bin/koth.lua +++ b/www/cgi-bin/koth.lua @@ -54,6 +54,7 @@ function koth.page(title, body) print('') print('') print('') + print('') print('') print("") os.exit(0) From 789fd00e47f17711f5fb298e10101c56e7c6ba44 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 11 May 2018 21:45:40 +0000 Subject: [PATCH 11/50] Rewrite devel server to be more reliable, accept seed in URL --- Dockerfile.moth-compile | 3 - Dockerfile.moth-devel | 14 +- tools/devel-server.py | 402 +++++++++++++++------------------------- 3 files changed, 158 insertions(+), 261 deletions(-) diff --git a/Dockerfile.moth-compile b/Dockerfile.moth-compile index e00c96b..5680e4f 100644 --- a/Dockerfile.moth-compile +++ b/Dockerfile.moth-compile @@ -1,8 +1,5 @@ FROM alpine -ARG http_proxy -ENV http_proxy=${http_proxy} - RUN apk --no-cache add python3 py3-pillow COPY tools/package-puzzles.py tools/moth.py tools/mistune.py tools/answer_words.txt /moth/ diff --git a/Dockerfile.moth-devel b/Dockerfile.moth-devel index 4e94dfa..8058a5b 100644 --- a/Dockerfile.moth-devel +++ b/Dockerfile.moth-devel @@ -1,14 +1,10 @@ FROM alpine -ARG http_proxy -ENV http_proxy=${http_proxy} +RUN apk --no-cache add python3 py3-pillow && \ + pip3 install aiohttp -RUN apk --no-cache add python3 py3-pillow - -COPY tools/devel-server.py tools/moth.py tools/mistune.py tools/answer_words.txt /moth/ -COPY www /moth/src/www -COPY example-puzzles /moth/puzzles -COPY docs /moth/docs +COPY . /moth/ +COPY example-puzzles /puzzles/ WORKDIR /moth/ -ENTRYPOINT ["python3", "/moth/devel-server.py"] +ENTRYPOINT ["python3", "/moth/tools/devel-server.py", "--bind", ":8080", "--puzzles", "/puzzles"] diff --git a/tools/devel-server.py b/tools/devel-server.py index 48d2f82..354f899 100755 --- a/tools/devel-server.py +++ b/tools/devel-server.py @@ -1,268 +1,163 @@ #!/usr/bin/python3 -# To pick up any changes to this file without restarting anything: -# while true; do ./tools/devel-server.py --once; done -# It's kludgy, but it gets the job done. -# Feel free to make it suck less, for example using the `tcpserver` program. - +import asyncio import glob import html -import http.server +from aiohttp import web import io -import mistune +import mimetypes import moth +import logging import os import pathlib +import random import shutil import socketserver import sys import traceback -try: - from http.server import HTTPStatus -except ImportError: - class HTTPStatus: - OK = 200 - NOT_FOUND = 404 - INTERNAL_SERVER_ERROR = 500 +sys.dont_write_bytecode = True # Don't write .pyc files -sys.dont_write_bytecode = True +def mkseed(): + return bytes(random.choice(b'abcdef0123456789') for i in range(40)) -# XXX: This will eventually cause a problem. Do something more clever here. -seed = 1 - -def page(title, body, baseurl, scripts=[]): - return """ - - - {title} - - {scripts} - - -

{title}

-
- {body} -
- -""".format( - title=title, - body=body, - baseurl=baseurl, - scripts="\n".join(''.format(s) for s in scripts), - ) - - - - -# XXX: What horrors did we unleash with our chdir shenanigans that -# makes this serve 404 and 500 when we mix in ThreadingMixIn? -class ThreadingServer(socketserver.ForkingMixIn, http.server.HTTPServer): - pass - - -class MothHandler(http.server.SimpleHTTPRequestHandler): - puzzles_dir = "puzzles" - base_url = "" - - def mdpage(self, body, scripts=[]): - try: - title, _ = body.split('\n', 1) - except ValueError: - title = "Result" - title = title.lstrip("#") - title = title.strip() - return page(title, mistune.markdown(body, escape=False), self.base_url, scripts=scripts) - - - def handle_one_request(self): - try: - super().handle_one_request() - except: - tbtype, value, tb = sys.exc_info() - tblist = traceback.format_tb(tb, None) + traceback.format_exception_only(tbtype, value) - payload = ("Traceback (most recent call last)\n" + - "".join(tblist[:-1]) + - tblist[-1]).encode('utf-8') - self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", payload) - self.end_headers() - self.wfile.write(payload) - - def do_GET(self): - if self.path == "/": - self.serve_front() - elif self.path.startswith("/puzzles/"): - self.serve_puzzles(self.path) - elif self.path.startswith("/files/"): - self.serve_file(self.translate_path(self.path)) +class Page: + def __init__(self, title, depth=0): + self.title = title + if depth: + self.base = "/".join([".."] * depth) else: - self.send_error(HTTPStatus.NOT_FOUND, "File not found") + self.base = "." + self.body = io.StringIO() + self.scripts = [] + + def add_script(self, path): + self.scripts.append(path) + + def write(self, s): + self.body.write(s) + + def text(self): + ret = io.StringIO() + ret.write("\n") + ret.write("\n") + ret.write(" \n") + ret.write(" {}\n".format(self.title)) + ret.write(" \n".format(self.base)) + for s in self.scripts: + ret.write(" {}\n".format(s)) + ret.write(" \n") + ret.write(" \n") + ret.write("

{}

\n".format(self.title)) + ret.write("
\n") + ret.write(self.body.getvalue()) + ret.write("
\n") + ret.write(" \n") + ret.write("\n") + return ret.getvalue() + + def response(self, request): + return web.Response(text=self.text(), content_type="text/html") - def translate_path(self, path): - if path.startswith('/files'): - path = path[7:] - return super().translate_path(path) +async def handle_front(request): + p = Page("Devel Server", 0) + p.write("

Yo, it's the front page!

") + p.write("") + p.write("

If you use this development server to run a contest, you are a fool.

") + return p.response(request) - def serve_front(self): - body = """ -MOTH Development Server Front Page -==================== +async def handle_puzzlelist(request): + p = Page("Puzzle Categories", 1) + p.write("
") + return p.response(request) -Yo, it's the front page. -There's stuff you can do here: +async def handle_category(request): + seed = request.query.get("seed", mkseed()) + category = request.match_info.get("category") + cat = moth.Category(os.path.join(request.app["puzzles_dir"], category), seed) + p = Page("Puzzles in category {}".format(category), 2) + p.write("") + return p.response(request) -* [Available puzzles](puzzles/) -* [Raw filesystem view](files/) -* [Documentation](files/docs/) -* [Instructions](files/docs/devel-server.md) for using this server +async def handle_puzzle(request): + seed = request.query.get("seed", mkseed()) + category = request.match_info.get("category") + points = int(request.match_info.get("points")) + cat = moth.Category(os.path.join(request.app["puzzles_dir"], category), seed) + puzzle = cat.puzzle(points) -If you use this development server to run a contest, -you are a fool. -""" - payload = self.mdpage(body).encode('utf-8') - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", len(payload)) - self.end_headers() - self.wfile.write(payload) - - def serve_puzzles(self, path): - body = io.StringIO() - path = path.rstrip('/') - parts = path.split("/") - scripts = [] - title = None - fpath = None - points = None - cat = None - puzzle = None - - try: - fpath = os.path.join(self.puzzles_dir, parts[2]) - points = int(parts[3]) - except: - pass - - if fpath: - cat = moth.Category(fpath, seed) - if points: - puzzle = cat.puzzle(points) - - if not cat: - title = "Puzzle Categories" - body.write("
    ") - for i in sorted(glob.glob(os.path.join(self.puzzles_dir, "*", ""))): - bn = os.path.basename(i.strip('/\\')) - body.write('
  • puzzles/{}/
  • '.format(bn, bn)) - body.write("
") - elif not puzzle: - # List all point values in a category - title = "Puzzles in category `{}`".format(parts[2]) - body.write("
    ") - for points in cat.pointvals(): - body.write('
  • puzzles/{cat}/{points}/
  • '.format(cat=parts[2], points=points)) - body.write("
") - elif len(parts) == 4: - # Serve up a puzzle - scripts = puzzle.scripts - title = "{} puzzle {}".format(parts[2], parts[3]) - body.write("

Body

") - body.write("
") - body.write(puzzle.html_body()) - body.write("
") - body.write("

Files

") - body.write("
    ") - for name,puzzlefile in sorted(puzzle.files.items()): - if puzzlefile.visible: - visibility = '' - else: - visibility = '(unlisted)' - body.write('
  • {filename} {visibility}
  • ' - .format(cat=parts[2], - points=puzzle.points, - filename=name, - visibility=visibility)) - body.write("
") - body.write("

Answers

") - body.write("

Input box (for scripts): ") - body.write("

    ") - assert puzzle.answers, 'No answers defined' - for a in puzzle.answers: - body.write("
  • {}
  • ".format(html.escape(a))) - body.write("
") - body.write("

Authors

{}

".format(', '.join(puzzle.get_authors()))) - body.write("

Summary

{}

".format(puzzle.summary)) - if puzzle.logs: - body.write("

Debug Log

") - body.write('
    ') - for l in puzzle.logs: - body.write("
  • {}
  • ".format(html.escape(l))) - body.write("
") - elif len(parts) == 5: - # Serve up a puzzle file - try: - pfile = puzzle.files[parts[4]] - except KeyError: - self.send_error(HTTPStatus.NOT_FOUND, "File not found. Did you add it to the Files: header or puzzle.add_stream?") - return - ctype = self.guess_type(pfile.name) - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", ctype) - self.end_headers() - shutil.copyfileobj(pfile.stream, self.wfile) - return - - payload = page(title, body.getvalue(), self.base_url, scripts=scripts).encode('utf-8') - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", len(payload)) - self.end_headers() - self.wfile.write(payload) - - def serve_file(self, path): - lastmod = None - fspath = pathlib.Path(path) - - if fspath.is_dir(): - ctype = "text/html; charset=utf-8" - payload = self.list_directory(path) - # it sends headers but not body - shutil.copyfileobj(payload, self.wfile) + p = Page("{} puzzle {}".format(category, points), 3) + for s in puzzle.scripts: + p.add_script(s) + p.write("

Body

") + p.write("
") + p.write(puzzle.html_body()) + p.write("
") + p.write("

Files

") + p.write("
    ") + for name,puzzlefile in sorted(puzzle.files.items()): + if puzzlefile.visible: + visibility = '' else: - ctype = self.guess_type(path) - try: - payload = fspath.read_bytes() - except OSError: - self.send_error(HTTPStatus.NOT_FOUND, "File not found") - return - if path.endswith(".md"): - ctype = "text/html; charset=utf-8" - content = self.mdpage(payload.decode('utf-8')) - payload = content.encode('utf-8') - try: - fs = fspath.stat() - lastmod = self.date_time_string(fs.st_mtime) - except: - pass + visibility = '(unlisted)' + p.write('
  • {filename} {visibility}
  • ' + .format(cat=category, + points=puzzle.points, + filename=name, + visibility=visibility)) + p.write("
") + p.write("

Answers

") + p.write("

Input box (for scripts): ") + p.write("

    ") + assert puzzle.answers, 'No answers defined' + for a in puzzle.answers: + p.write("
  • {}
  • ".format(html.escape(a))) + p.write("
") + p.write("

Authors

{}

".format(', '.join(puzzle.get_authors()))) + p.write("

Summary

{}

".format(puzzle.summary)) + if puzzle.logs: + p.write("

Debug Log

") + p.write('
    ') + for l in puzzle.logs: + p.write("
  • {}
  • ".format(html.escape(l))) + p.write("
") + + return p.response(request) - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", len(payload)) - if lastmod: - self.send_header("Last-Modified", lastmod) - self.end_headers() - self.wfile.write(payload) +async def handle_puzzlefile(request): + seed = request.query.get("seed", mkseed()) + category = request.match_info.get("category") + points = int(request.match_info.get("points")) + filename = request.match_info.get("filename") + cat = moth.Category(os.path.join(request.app["puzzles_dir"], category), seed) + puzzle = cat.puzzle(points) + try: + file = puzzle.files[filename] + except KeyError: + return web.Response(status=404) + + resp = web.Response() + resp.content_type, _ = mimetypes.guess_type(file.name) + # This is the line where I decided Go was better than Python at multiprocessing + # You should be able to chain the puzzle file's output to the async output, + # without having to block. But if there's a way to do that, it certainly + # isn't documented anywhere. + resp.body = file.stream.read() + return resp -def run(address=('127.0.0.1', 8080), once=False): - httpd = ThreadingServer(address, MothHandler) - print("=== Listening on http://{}:{}/".format(address[0], address[1])) - if once: - httpd.handle_request() - else: - httpd.serve_forever() if __name__ == '__main__': import argparse @@ -272,10 +167,6 @@ if __name__ == '__main__': '--puzzles', default='puzzles', help="Directory containing your puzzles" ) - parser.add_argument( - '--once', default=False, action='store_true', - help="Serve one page, then exit. For debugging the server." - ) parser.add_argument( '--bind', default="127.0.0.1:8080", help="Bind to ip:port" @@ -285,8 +176,21 @@ if __name__ == '__main__': help="Base URL to this server, for reverse proxy setup" ) args = parser.parse_args() - addr, port = args.bind.split(":") - port = int(port) - MothHandler.puzzles_dir = args.puzzles - MothHandler.base_url = args.base - run(address=(addr, port), once=args.once) + parts = args.bind.split(":") + addr = parts[0] or "0.0.0.0" + port = int(parts[1]) + + logging.basicConfig(level=logging.INFO) + + mydir = os.path.dirname(os.path.dirname(os.path.realpath(sys.argv[0]))) + + app = web.Application() + app["puzzles_dir"] = args.puzzles + app["base_url"] = args.base + app.router.add_route("GET", "/", handle_front) + app.router.add_route("GET", "/puzzles/", handle_puzzlelist) + app.router.add_route("GET", "/puzzles/{category}/", handle_category) + app.router.add_route("GET", "/puzzles/{category}/{points}/", handle_puzzle) + app.router.add_route("GET", "/puzzles/{category}/{points}/{filename}", handle_puzzlefile) + app.router.add_static("/files/", mydir, show_index=True) + web.run_app(app, host=addr, port=port) From 4cf8c211c55295eb2b2618750d5464c33d31976e Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 17:29:32 +0000 Subject: [PATCH 12/50] Add API document --- docs/api.md | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/api.md diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..e8b35c4 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,187 @@ +MOTHv3 API +========== + +MOTH, by design, uses a small number of API endpoints. + +Whenever possible, +we decided to push complexity into the client, +keeping the server as simple as we could make it. +After all, +this is a hacking contest. +If a participant finds a vulnerability in code running on their own machine, +the people running the server don't care. + +Specification +============= + +You make requests as HTTP GET query arguments: + + https://server/path/to/endpoint?var1=val1&var2=val2 + +The server returns a +[JSend](https://labs.omniti.com/labs/jsend) response: + + { + status: "success", + data: "Any JS data type here" + } + + +Client State +============ + +The client (or user interacting with the client) needs to remember only one thing: + +* teamId: the team ID used to register + +A naive client, +like the one we used from 2009-2018, +can ask the user to type in the team ID for every submission. +This is fine. + + +Endpoints +========= + +RegisterTeam(teamId, teamName) +------------------------------- + +Register a team name with a team hash. + +Parameters: + +* teamId: Team's unique identifier (usually a hex value) +* teamName: Team's human-readable name + +On success, no data is returned. +On failure, message contains an English explanation of why. + +Example: + + https://server/RegisterTeam?teamId=8b1292ca + + { + status: "success", + data: nil + } + + +GetPuzzleList() +--------------- + +Return all currently-open puzzles. + +Return data: + +* puzzles: dictionary mapping from category to a list of point values. + + +Example: + + https://server/GetPuzzleList + + { + status: "success", + data: { + "puzzles": { + "sequence": [1, 2], + "codebreaking": [10], + } + } + } + + +### GetPuzzle(category, points) + +Return a puzzle. + +Return data: + +* authors: List of puzzle authors +* hashes: list of djbhash values of acceptable answers +* files: dictionary of puzzle-associated filenames and their URLs +* body: HTML body of the puzzle + + +Example: + + https://server/GetPuzzle?category=sequence&points=1 + + { + status: "success", + data: { + "authors": ["neale"], + "hashes": [177627], + "files": { + "happy.png": "https://cdn/assets/0904cf3a437a348bea2c49d56a3087c26a01a63c.png" + }, + "body": "
1 2 3 4 5 _\n
\n" + } + + +### GetPointsLog() + +Return the entire points log, and team names. + +Return data: + +* teams: mapping from team number (int) to team name +* log: list of (timestamp, team number, category, points) + +Note: team number may change between calls. + + +Example: + + https://server/GetEventsLog + + { + status: "success", + data: { + teams: { + 0: "Zelda", + 1: "Defender" + }, + log: [ + [1526478368, 0, "sequence", 1], + [1526478524, 1, "sequence", 1], + [1526478536, 0, "nocode", 1] + ] + } + } + + +### SubmitAnswer(teamId, category, points, answer) + +Submit an answer to a puzzle. + +Example: + + https://server/SubmitAnswer?teamId=8b1292ca&category=sequence&points=1&answer=6 + + { + status: "success", + data: null + } + +### SubmitToken(teamId, token) + +Submit a token for points + +Return data: + +* category: category for which this token awarded points +* points: number of points awarded + + +Example: + + https://server/SubmitToken?teamId=8b1292ca&token=wat:30:xylep-radar-nanox + + { + status: "success", + data: { + category: "wat", + points: 30 + } + } From ceef8a74376533781912559bdf445fec0326d87e Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 17:31:29 +0000 Subject: [PATCH 13/50] Update headings to be at the same level --- docs/api.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/api.md b/docs/api.md index e8b35c4..3584d6b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -91,7 +91,8 @@ Example: } -### GetPuzzle(category, points) +GetPuzzle(category, points) +-------------------- Return a puzzle. @@ -119,7 +120,8 @@ Example: } -### GetPointsLog() +GetPointsLog() +--------------- Return the entire points log, and team names. @@ -151,7 +153,8 @@ Example: } -### SubmitAnswer(teamId, category, points, answer) +SubmitAnswer(teamId, category, points, answer) +---------------------- Submit an answer to a puzzle. @@ -164,7 +167,8 @@ Example: data: null } -### SubmitToken(teamId, token) +SubmitToken(teamId, token) +--------------------- Submit a token for points From 96171ad051f077c811c6144abfff1ae4137b0774 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 17:37:29 +0000 Subject: [PATCH 14/50] Document all parameters --- docs/api.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/api.md b/docs/api.md index 3584d6b..25a6536 100644 --- a/docs/api.md +++ b/docs/api.md @@ -96,6 +96,11 @@ GetPuzzle(category, points) Return a puzzle. +Parameters: + +* category: name of category to fetch from +* points: point value of the puzzle to fetch + Return data: * authors: List of puzzle authors @@ -158,6 +163,13 @@ SubmitAnswer(teamId, category, points, answer) Submit an answer to a puzzle. +Parameters: + +* teamId: Team ID (optional: if ommitted, answer is verified but no points are awarded) +* category: category name of puzzle +* points: point value of puzzle +* answer: attempted answer + Example: https://server/SubmitAnswer?teamId=8b1292ca&category=sequence&points=1&answer=6 @@ -172,6 +184,11 @@ SubmitToken(teamId, token) Submit a token for points +Parameters: + +* teamId: Team ID +* token: Token being submitted + Return data: * category: category for which this token awarded points From 11482d5a4df0e3ecaa52975a1a06cf061e5b1996 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 18:02:19 +0000 Subject: [PATCH 15/50] Add epilogue to SubmitAnswer, formatting change --- docs/api.md | 39 +++++++----- handlers.go | 152 -------------------------------------------- maintenance.go | 66 ------------------- mothd.go | 167 ------------------------------------------------- points.go | 129 -------------------------------------- 5 files changed, 23 insertions(+), 530 deletions(-) delete mode 100644 handlers.go delete mode 100644 maintenance.go delete mode 100644 mothd.go delete mode 100644 points.go diff --git a/docs/api.md b/docs/api.md index 25a6536..dbb34b1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -48,7 +48,7 @@ RegisterTeam(teamId, teamName) Register a team name with a team hash. -Parameters: +### Parameters * teamId: Team's unique identifier (usually a hex value) * teamName: Team's human-readable name @@ -56,13 +56,13 @@ Parameters: On success, no data is returned. On failure, message contains an English explanation of why. -Example: +### Example https://server/RegisterTeam?teamId=8b1292ca { status: "success", - data: nil + data: null } @@ -71,12 +71,12 @@ GetPuzzleList() Return all currently-open puzzles. -Return data: +### Return data * puzzles: dictionary mapping from category to a list of point values. -Example: +### Example https://server/GetPuzzleList @@ -96,12 +96,12 @@ GetPuzzle(category, points) Return a puzzle. -Parameters: +### Parameters * category: name of category to fetch from * points: point value of the puzzle to fetch -Return data: +### Return data * authors: List of puzzle authors * hashes: list of djbhash values of acceptable answers @@ -109,7 +109,7 @@ Return data: * body: HTML body of the puzzle -Example: +### Example https://server/GetPuzzle?category=sequence&points=1 @@ -130,7 +130,7 @@ GetPointsLog() Return the entire points log, and team names. -Return data: +### Return data * teams: mapping from team number (int) to team name * log: list of (timestamp, team number, category, points) @@ -138,7 +138,7 @@ Return data: Note: team number may change between calls. -Example: +### Example https://server/GetEventsLog @@ -163,20 +163,27 @@ SubmitAnswer(teamId, category, points, answer) Submit an answer to a puzzle. -Parameters: +### Parameters * teamId: Team ID (optional: if ommitted, answer is verified but no points are awarded) * category: category name of puzzle * points: point value of puzzle * answer: attempted answer -Example: + +### Return Data + +* epilogue: HTML to display as an "epilogue" to the puzzle + +### Example https://server/SubmitAnswer?teamId=8b1292ca&category=sequence&points=1&answer=6 { status: "success", - data: null + data: { + epilogue: "That's right: in base 10, 5 + 1 = 6." + } } SubmitToken(teamId, token) @@ -184,18 +191,18 @@ SubmitToken(teamId, token) Submit a token for points -Parameters: +### Parameters * teamId: Team ID * token: Token being submitted -Return data: +### Return data * category: category for which this token awarded points * points: number of points awarded -Example: +### Example https://server/SubmitToken?teamId=8b1292ca&token=wat:30:xylep-radar-nanox diff --git a/handlers.go b/handlers.go deleted file mode 100644 index a1edddd..0000000 --- a/handlers.go +++ /dev/null @@ -1,152 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "os" - "regexp" - "strings" - "strconv" -) - -func 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 = "" - } - - if (teamid == "") || (teamname == "") { - showPage(w, "Invalid Entry", "Oops! Are you sure you got that right?") - return - } - - if ! anchoredSearch(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) - if err != nil { - showPage( - w, - "Registration failed", - "Unable to register. Perhaps a teammate has already registered?", - ) - return - } - defer f.Close() - fmt.Fprintln(f, teamname) - 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) { - teamid := req.FormValue("t") - token := req.FormValue("k") - - // Check answer - if ! anchoredSearch(token, statePath("tokens.txt"), 0) { - showPage(w, "Unrecognized token", "I don't recognize that token. Did you type in the whole thing?") - 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) { - showPage(w, "Unrecognized token", "Something doesn't look right about that token") - return - } - - if err := 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) { - teamid := req.FormValue("t") - category := req.FormValue("c") - pointstr := req.FormValue("p") - answer := req.FormValue("a") - - 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 = "" - } - - // Check answer - needle := fmt.Sprintf("%s %s", points, answer) - haystack := cachePath(category, "answers.txt") - if ! anchoredSearch(haystack, needle, 0) { - showPage(w, "Wrong answer", err.Error()) - } - - if err := 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) { - puzzles := map[string][]interface{}{} - // v := map[string][]interface{}{"Moo": {1, "0177f85ae895a33e2e7c5030c3dc484e8173e55c"}} - // j, _ := json.Marshal(v) - - for _, category := range categories { - - } -} - -func pointsHandler(w http.ResponseWriter, req *http.Request) { - -} - -// staticHandler serves up static files. -func 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. -
- `, - ) - return - } - - http.NotFound(w, req) -} 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 From b73df73b94173a783b7aaab6a066b842377a6296 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 18:06:47 +0000 Subject: [PATCH 16/50] json, not yaml --- docs/api.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/api.md b/docs/api.md index dbb34b1..1cea57b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -22,8 +22,8 @@ The server returns a [JSend](https://labs.omniti.com/labs/jsend) response: { - status: "success", - data: "Any JS data type here" + "status": "success", + "data": "Any JS data type here" } @@ -61,8 +61,8 @@ On failure, message contains an English explanation of why. https://server/RegisterTeam?teamId=8b1292ca { - status: "success", - data: null + "status": "success", + "data": null } @@ -81,8 +81,8 @@ Return all currently-open puzzles. https://server/GetPuzzleList { - status: "success", - data: { + "status": "success", + "data": { "puzzles": { "sequence": [1, 2], "codebreaking": [10], @@ -114,8 +114,8 @@ Return a puzzle. https://server/GetPuzzle?category=sequence&points=1 { - status: "success", - data: { + "status": "success", + "data": { "authors": ["neale"], "hashes": [177627], "files": { @@ -143,13 +143,13 @@ Note: team number may change between calls. https://server/GetEventsLog { - status: "success", - data: { - teams: { + "status": "success", + "data": { + "teams": { 0: "Zelda", 1: "Defender" }, - log: [ + "log": [ [1526478368, 0, "sequence", 1], [1526478524, 1, "sequence", 1], [1526478536, 0, "nocode", 1] @@ -180,9 +180,9 @@ Submit an answer to a puzzle. https://server/SubmitAnswer?teamId=8b1292ca&category=sequence&points=1&answer=6 { - status: "success", - data: { - epilogue: "That's right: in base 10, 5 + 1 = 6." + "status": "success", + "data": { + "epilogue": "That's right: in base 10, 5 + 1 = 6." } } @@ -207,9 +207,9 @@ Submit a token for points https://server/SubmitToken?teamId=8b1292ca&token=wat:30:xylep-radar-nanox { - status: "success", - data: { - category: "wat", - points: 30 + "status": "success", + "data": { + "category": "wat", + "points": 30 } } From 39f440555eca727302d5606a11e8c7ff3c1fb594 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 18:09:06 +0000 Subject: [PATCH 17/50] Add an epilogue to SubmitToken too --- docs/api.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 1cea57b..7a51838 100644 --- a/docs/api.md +++ b/docs/api.md @@ -200,6 +200,7 @@ Submit a token for points * category: category for which this token awarded points * points: number of points awarded +* epilogue: HTML to display as an "epilogue" to the puzzle ### Example @@ -210,6 +211,7 @@ Submit a token for points "status": "success", "data": { "category": "wat", - "points": 30 + "points": 30, + "epilogue": "" } } From 20e7fc24fbb1c78ac892ab442ac0e5167c4ee96b Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 18:10:41 +0000 Subject: [PATCH 18/50] add teamName parameter to RegisterTeam example --- docs/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 7a51838..26ebbd4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -58,7 +58,7 @@ On failure, message contains an English explanation of why. ### Example - https://server/RegisterTeam?teamId=8b1292ca + https://server/RegisterTeam?teamId=8b1292ca&teamName=Lexical+Pedants { "status": "success", From 9ca5774b4a980cd1d2fcec71ac495b437dc2decd Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 18:12:32 +0000 Subject: [PATCH 19/50] french orthography sucks --- docs/api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api.md b/docs/api.md index 26ebbd4..b0fe692 100644 --- a/docs/api.md +++ b/docs/api.md @@ -173,7 +173,7 @@ Submit an answer to a puzzle. ### Return Data -* epilogue: HTML to display as an "epilogue" to the puzzle +* epilog: HTML to display upon successfully answering the puzzle ### Example @@ -182,7 +182,7 @@ Submit an answer to a puzzle. { "status": "success", "data": { - "epilogue": "That's right: in base 10, 5 + 1 = 6." + "epilog": "That's right: in base 10, 5 + 1 = 6." } } @@ -200,7 +200,7 @@ Submit a token for points * category: category for which this token awarded points * points: number of points awarded -* epilogue: HTML to display as an "epilogue" to the puzzle +* epilog: HTML to display upon successfully answering the puzzle ### Example @@ -212,6 +212,6 @@ Submit a token for points "data": { "category": "wat", "points": 30, - "epilogue": "" + "epilog": "" } } From 2d20bb8f252499a93227dc9ff571f8890fa19b45 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 18:17:53 +0000 Subject: [PATCH 20/50] whitespace prettifying --- docs/api.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/api.md b/docs/api.md index b0fe692..c9645e1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -56,6 +56,7 @@ Register a team name with a team hash. On success, no data is returned. On failure, message contains an English explanation of why. + ### Example https://server/RegisterTeam?teamId=8b1292ca&teamName=Lexical+Pedants @@ -101,6 +102,7 @@ Return a puzzle. * category: name of category to fetch from * points: point value of the puzzle to fetch + ### Return data * authors: List of puzzle authors @@ -175,6 +177,7 @@ Submit an answer to a puzzle. * epilog: HTML to display upon successfully answering the puzzle + ### Example https://server/SubmitAnswer?teamId=8b1292ca&category=sequence&points=1&answer=6 @@ -196,6 +199,7 @@ Submit a token for points * teamId: Team ID * token: Token being submitted + ### Return data * category: category for which this token awarded points From 7b834488738616433e23946338ab1e2391105a0b Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 18:19:36 +0000 Subject: [PATCH 21/50] /api/v3 URL prefix --- docs/api.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/api.md b/docs/api.md index c9645e1..45a3b3d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,7 +16,7 @@ Specification You make requests as HTTP GET query arguments: - https://server/path/to/endpoint?var1=val1&var2=val2 + https://server/path/elements/api/v3/endpoint?var1=val1&var2=val2 The server returns a [JSend](https://labs.omniti.com/labs/jsend) response: @@ -59,7 +59,7 @@ On failure, message contains an English explanation of why. ### Example - https://server/RegisterTeam?teamId=8b1292ca&teamName=Lexical+Pedants + https://server/api/v3/RegisterTeam?teamId=8b1292ca&teamName=Lexical+Pedants { "status": "success", @@ -79,7 +79,7 @@ Return all currently-open puzzles. ### Example - https://server/GetPuzzleList + https://server/api/v3/GetPuzzleList { "status": "success", @@ -113,7 +113,7 @@ Return a puzzle. ### Example - https://server/GetPuzzle?category=sequence&points=1 + https://server/api/v3/GetPuzzle?category=sequence&points=1 { "status": "success", @@ -142,7 +142,7 @@ Note: team number may change between calls. ### Example - https://server/GetEventsLog + https://server/api/v3/GetEventsLog { "status": "success", @@ -180,7 +180,7 @@ Submit an answer to a puzzle. ### Example - https://server/SubmitAnswer?teamId=8b1292ca&category=sequence&points=1&answer=6 + https://server/api/v3/SubmitAnswer?teamId=8b1292ca&category=sequence&points=1&answer=6 { "status": "success", @@ -209,7 +209,7 @@ Submit a token for points ### Example - https://server/SubmitToken?teamId=8b1292ca&token=wat:30:xylep-radar-nanox + https://server/api/v3/SubmitToken?teamId=8b1292ca&token=wat:30:xylep-radar-nanox { "status": "success", From b6dde238f6dabaa1c6852edb82635f1f6fb45056 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 20:37:49 +0000 Subject: [PATCH 22/50] Combine points log and open puzzles call --- docs/api.md | 61 +++++++++++++++++++++-------------------------------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/docs/api.md b/docs/api.md index 45a3b3d..44f65f0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -67,14 +67,19 @@ On failure, message contains an English explanation of why. } -GetPuzzleList() ---------------- +GetState() +---------- -Return all currently-open puzzles. +Return all current state of the puzzle server. ### Return data -* puzzles: dictionary mapping from category to a list of point values. +* puzzles: dictionary mapping from category to one of the following: + * list of point values currently open + * URL to puzzle root (intended for token-based puzzles) +* teams: mapping from anonymized team ID to team name +* log: list of (timestamp, team number, category, points) +* notices: list of HTML broadcast notices to display to the user ### Example @@ -87,7 +92,21 @@ Return all currently-open puzzles. "puzzles": { "sequence": [1, 2], "codebreaking": [10], - } + "wopr": "https://appspot.com/dooted-bagel-8372/entry" + }, + "teams": { + "0": "Zelda", + "1": "Defender" + }, + "log": [ + [1526478368, "0", "sequence", 1], + [1526478524, "1", "sequence", 1], + [1526478536, "0", "nocode", 1] + ], + "notices": [ + "WOPR category is now open", + "Event closes at 18:00 today, and will resume tomorrow at 08:00" + ] } } @@ -127,38 +146,6 @@ Return a puzzle. } -GetPointsLog() ---------------- - -Return the entire points log, and team names. - -### Return data - -* teams: mapping from team number (int) to team name -* log: list of (timestamp, team number, category, points) - -Note: team number may change between calls. - - -### Example - - https://server/api/v3/GetEventsLog - - { - "status": "success", - "data": { - "teams": { - 0: "Zelda", - 1: "Defender" - }, - "log": [ - [1526478368, 0, "sequence", 1], - [1526478524, 1, "sequence", 1], - [1526478536, 0, "nocode", 1] - ] - } - } - SubmitAnswer(teamId, category, points, answer) ---------------------- From 69fd6918c8b2c350533ce98c31c8b4d1e353af43 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 20:39:10 +0000 Subject: [PATCH 23/50] Explicitly say there are no parameters to getstate --- docs/api.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/api.md b/docs/api.md index 44f65f0..e517524 100644 --- a/docs/api.md +++ b/docs/api.md @@ -72,6 +72,11 @@ GetState() Return all current state of the puzzle server. +### Parameters + +None + + ### Return data * puzzles: dictionary mapping from category to one of the following: From 822683972dc4fb58de9918661192235072258e02 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 20:39:35 +0000 Subject: [PATCH 24/50] Fix GetState API endpoint in example --- docs/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index e517524..7fa9160 100644 --- a/docs/api.md +++ b/docs/api.md @@ -89,7 +89,7 @@ None ### Example - https://server/api/v3/GetPuzzleList + https://server/api/v3/GetState { "status": "success", From 37d868b2681d4ac894ce00713fd8ba1767d9f7e6 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 22:53:51 +0000 Subject: [PATCH 25/50] Add a fake server to demonstrate API --- tools/fake-server.py | 133 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tools/fake-server.py diff --git a/tools/fake-server.py b/tools/fake-server.py new file mode 100644 index 0000000..8076e9d --- /dev/null +++ b/tools/fake-server.py @@ -0,0 +1,133 @@ +#! /usr/bin/python3 + +from aiohttp import web + +async def fake_register(request): + teamId = request.query.get("teamId") + teamName = request.query.get("teamName") + if teamId == "ffff" and teamName == "dirtbags": + resp = { + "status": "success", + "data": None, + } + elif teamId and teamName: + resp = { + "status": "error", + "message": "Query was correctly formed but I'm feeling cranky" + } + else: + resp = { + "status": "fail", + "data": "You must send teamId and teamName", + } + return web.json_response(resp) + +async def fake_state(request): + resp = { + "status": "success", + "data": { + "puzzles": { + "sequence": [1, 2], + "codebreaking": [10], + "wopr": "https://appspot.com/dooted-bagel-8372/entry" + }, + "teams": { + "0": "Zelda", + "1": "Defender" + }, + "log": [ + [1526478368, "0", "sequence", 1], + [1526478524, "1", "sequence", 1], + [1526478536, "0", "nocode", 1] + ], + "notices": [ + "WOPR category is now open", + "Event closes at 18:00 today, and will resume tomorrow at 08:00" + ], + } + } + return web.json_response(resp) + +async def fake_getpuzzle(request): + category = request.query.get("category") + points = request.query.get("points") + if category == "sequence" and points == "1": + resp = { + "status": "success", + "data": { + "authors": ["neale"], + "hashes": [177627], + "files": { + "happy.png": "https://cdn/assets/0904cf3a437a348bea2c49d56a3087c26a01a63c.png" + }, + "body": "
1 2 3 4 5 _\n
\n", + } + } + elif category and points: + resp = { + "status": "error", + "message": "Query was correctly formed but I'm feeling cranky" + } + else: + resp = { + "status": "fail", + "data": "You must send category and points" + } + return web.json_response(resp) + +async def fake_submitanswer(request): + teamId = request.query.get("teamId") + category = request.query.get("category") + points = request.query.get("points") + answer = request.query.get("answer") + if category == "sequence" and points == "1" and answer == "6": + resp = { + "status": "success", + "data": { + "epilog": "Now you know the answer, and knowing is half the battle. Go Joe!" + } + } + elif category and points and answer: + resp = { + "status": "error", + "message": "Query was correctly formed but I'm feeling cranky" + } + else: + resp = { + "status": "fail", + "data": "You must send category and points" + } + return web.json_response(resp) + +async def fake_submittoken(request): + teamId = request.query.get("teamId") + token = request.query.get("token") + if token == "wat:30:xylep-radar-nanox": + resp = { + "status": "success", + "data": { + "category": "wat", + "points": 30, + "epilog": "" + } + } + elif category and points and answer: + resp = { + "status": "error", + "message": "Query was correctly formed but I'm feeling cranky" + } + else: + resp = { + "status": "fail", + "data": "You must send category and points" + } + return web.json_response(resp) + +if __name__ == "__main__": + app = web.Application() + app.router.add_route("GET", "/api/v3/RegisterTeam", fake_register) + app.router.add_route("GET", "/api/v3/GetState", fake_state) + app.router.add_route("GET", "/api/v3/GetPuzzle", fake_getpuzzle) + app.router.add_route("GET", "/api/v3/SubmitAnswer", fake_submitanswer) + app.router.add_route("GET", "/api/v3/SubmitToken", fake_submittoken) + web.run_app(app) From 43141a38f45d41bd204993625f4f460c1576c221 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 23 May 2018 22:56:21 +0000 Subject: [PATCH 26/50] Make fake server executable --- tools/fake-server.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 tools/fake-server.py diff --git a/tools/fake-server.py b/tools/fake-server.py old mode 100644 new mode 100755 From 20a6f9217304838906d3f8b487863ad6c6f5b68b Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 24 May 2018 13:56:42 +0000 Subject: [PATCH 27/50] Add time field to GetState --- docs/api.md | 4 +++- tools/fake-server.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 7fa9160..610a814 100644 --- a/docs/api.md +++ b/docs/api.md @@ -85,6 +85,7 @@ None * teams: mapping from anonymized team ID to team name * log: list of (timestamp, team number, category, points) * notices: list of HTML broadcast notices to display to the user +* now: current server time (unix epoch) ### Example @@ -111,7 +112,8 @@ None "notices": [ "WOPR category is now open", "Event closes at 18:00 today, and will resume tomorrow at 08:00" - ] + ], + "now": 1527170088 } } diff --git a/tools/fake-server.py b/tools/fake-server.py index 8076e9d..340b43b 100755 --- a/tools/fake-server.py +++ b/tools/fake-server.py @@ -1,6 +1,7 @@ #! /usr/bin/python3 from aiohttp import web +import time async def fake_register(request): teamId = request.query.get("teamId") @@ -44,6 +45,7 @@ async def fake_state(request): "WOPR category is now open", "Event closes at 18:00 today, and will resume tomorrow at 08:00" ], + "now": int(time.time()), } } return web.json_response(resp) From 61c1129e0f89fee52909628bcaa369a0abe31d01 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sat, 15 Sep 2018 00:24:48 +0000 Subject: [PATCH 28/50] 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 29/50] 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 30/50] 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 31/50] 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 32/50] 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 33/50] 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 34/50] 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 35/50] 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, "