From 3b3783f9caa2777eae8cbb57f5e6b37cc522208b Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Mon, 17 Sep 2018 23:00:08 +0000 Subject: [PATCH] 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) + } +}