Moving toward a working server

This commit is contained in:
Neale Pickett 2018-09-17 23:00:08 +00:00
parent 61c1129e0f
commit 5b018eab42
10 changed files with 622 additions and 266 deletions

159
README.md
View File

@ -8,7 +8,9 @@ which in the past has been called
"HACK", "HACK",
"Queen Of The Hill", "Queen Of The Hill",
"Cyber Spark", "Cyber Spark",
and "Cyber Fire". "Cyber Fire",
"Cyber Fire Puzzles",
and "Cyber Fire Foundry".
Information about these events is at Information about these events is at
http://dirtbags.net/contest/ http://dirtbags.net/contest/
@ -48,75 +50,110 @@ More on how the devel sever works in
Running A Production Server 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, You can override any path with an option,
so you can run multiple contests at once if you want. run `mothd -help` for usage.
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.
How to run a web server State Directory
----------------------------- ===============
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
Installing Puzzle Categories Pausing scoring
------------------------------------ -------------------
Puzzle categories are distributed in a different way than the server. Create the file `state/disabled`
After setting up (see above), just run 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
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.
Permissions Resetting an instance
---------------- -------------------
It's up to you not to be a bonehead about permissions. 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!
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.

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@ -18,16 +19,40 @@ func (a *Award) String() string {
return fmt.Sprintf("%d %s %s %d", a.When.Unix(), a.TeamId, a.Category, a.Points) 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) { func ParseAward(s string) (*Award, error) {
ret := Award{} ret := Award{}
s = strings.Trim(s, " \t\n")
parts := strings.SplitN(s, " ", 5) parts := strings.SplitN(s, " ", 5)
if len(parts) < 4 { if len(parts) < 4 {
return nil, fmt.Errorf("Malformed award string") return nil, fmt.Errorf("Malformed award string")
} }
whenEpoch, err := strconv.ParseInt(parts[0], 10, 64) whenEpoch, err := strconv.ParseInt(parts[0], 10, 64)
if (err != nil) { if err != nil {
return nil, fmt.Errorf("Malformed timestamp: %s", parts[0]) return nil, fmt.Errorf("Malformed timestamp: %s", parts[0])
} }
ret.When = time.Unix(whenEpoch, 0) ret.When = time.Unix(whenEpoch, 0)
@ -36,11 +61,10 @@ func ParseAward(s string) (*Award, error) {
ret.Category = parts[2] ret.Category = parts[2]
points, err := strconv.Atoi(parts[3]) points, err := strconv.Atoi(parts[3])
if (err != nil) { if err != nil {
return nil, fmt.Errorf("Malformed Points: %s", parts[3]) return nil, fmt.Errorf("Malformed Points: %s: %v", parts[3], err)
} }
ret.Points = points ret.Points = points
return &ret, nil return &ret, nil
} }

View File

@ -1,65 +1,61 @@
package main package main
import ( import (
"bufio"
"encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os" "os"
"regexp" "regexp"
"strings"
"strconv" "strconv"
"io" "strings"
"log"
"bufio"
) )
// anchoredSearch looks for needle in r, func respond(w http.ResponseWriter, req *http.Request, status Status, short string, description string) {
// skipping the first skip space-delimited words // This is a kludge. Do proper parsing when this causes problems.
func anchoredSearch(r io.Reader, needle string, skip int) bool { accept := req.Header.Get("Accept")
scanner := bufio.NewScanner(r) if strings.Contains(accept, "application/json") {
for scanner.Scan() { ShowJSend(w, status, short, description)
line := scanner.Text() } else {
parts := strings.SplitN(line, " ", skip+1) ShowHtml(w, status, short, description)
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) { func (ctx Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
teamname := req.FormValue("n") teamname := req.FormValue("name")
teamid := req.FormValue("h") teamid := req.FormValue("id")
if matched, _ := regexp.MatchString("[^0-9a-z]", teamid); matched { // Keep foolish operators from shooting themselves in the foot
teamid = "" // You would have to add a pathname to your list of Team IDs to open this vulnerability,
// but I have learned not to overestimate people.
if strings.Contains(teamid, "../") {
teamid = "rodney"
} }
if (teamid == "") || (teamname == "") { 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 return
} }
if ! anchoredSearchFile(ctx.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?") respond(
w, req, Fail,
"Invalid Team ID",
"I don't have a record of that team ID. Maybe you used capital letters accidentally?",
)
return 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 { if err != nil {
showPage( log.Print(err)
w, respond(
w, req, Fail,
"Registration failed", "Registration failed",
"Unable to register. Perhaps a teammate has already registered?", "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() defer f.Close()
fmt.Fprintln(f, teamname) 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) { 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") token := req.FormValue("k")
// Check answer // Check answer
if ! anchoredSearchFile(ctx.StatePath("tokens.txt"), token, 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?") respond(
w, req, Fail,
"Unrecognized token",
"I don't recognize that token. Did you type in the whole thing?",
)
return return
} }
@ -97,15 +101,27 @@ func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) {
} }
if (category == "") || (points == 0) { 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 return
} }
if err := ctx.AwardPoints(teamid, category, points); err != nil { 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 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) { func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
@ -120,81 +136,119 @@ func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
} }
catmb, ok := ctx.Categories[category] catmb, ok := ctx.Categories[category]
if ! ok { if !ok {
showPage(w, "Category does not exist", "The specified category does not exist. Sorry!") respond(
w, req, Fail,
"Category does not exist",
"The requested category does not exist. Sorry!",
)
return return
} }
// Get the answers // Get the answers
haystack, err := catmb.Open("answers.txt") haystack, err := catmb.Open("answers.txt")
if err != nil { if err != nil {
showPage(w, "Answers do not exist", respond(
"Please tell the contest people that the mothball for this category has no answers.txt in it!") 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 return
} }
defer haystack.Close() defer haystack.Close()
// Look for the answer // Look for the answer
needle := fmt.Sprintf("%d %s", points, answer) needle := fmt.Sprintf("%d %s", points, answer)
if ! anchoredSearch(haystack, needle, 0) { if !anchoredSearch(haystack, needle, 0) {
showPage(w, "Wrong answer", err.Error()) respond(
return w, req, Fail,
} "Wrong answer",
err.Error(),
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",
`
<h2>Register your team</h2>
<form action="register" action="post">
Team ID: <input name="h"> <br>
Team name: <input name="n">
<input type="submit" value="Register">
</form>
<div>
If someone on your team has already registered,
proceed to the
<a href="puzzles">puzzles overview</a>.
</div>
`,
) )
return return
} }
http.NotFound(w, req) if err := ctx.AwardPoints(teamid, category, points); err != nil {
respond(
w, req, Error,
"Error awarding points",
err.Error(),
)
return
}
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) { func (ctx Instance) BindHandlers(mux *http.ServeMux) {
mux.HandleFunc(ctx.Base + "/", ctx.rootHandler) mux.HandleFunc(ctx.Base+"/", ctx.staticHandler)
mux.HandleFunc(ctx.Base + "/register", ctx.registerHandler) mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler)
mux.HandleFunc(ctx.Base + "/token", ctx.tokenHandler) mux.HandleFunc(ctx.Base+"/token", ctx.tokenHandler)
mux.HandleFunc(ctx.Base + "/answer", ctx.answerHandler) mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler)
mux.HandleFunc(ctx.Base + "/puzzles.json", ctx.puzzlesHandler) mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler)
mux.HandleFunc(ctx.Base + "/points.json", ctx.pointsHandler) mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler)
} }

View File

@ -1,28 +1,31 @@
package main package main
import ( import (
"os"
"log"
"bufio" "bufio"
"fmt" "fmt"
"time"
"io/ioutil" "io/ioutil"
"log"
"os"
"path" "path"
"strings" "strings"
"time"
) )
type Instance struct { type Instance struct {
Base string Base string
MothballDir string MothballDir string
StateDir string StateDir string
ResourcesDir string
Categories map[string]*Mothball Categories map[string]*Mothball
} }
func NewInstance(base, mothballDir, stateDir string) (*Instance, error) { func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) {
ctx := &Instance{ ctx := &Instance{
Base: strings.TrimRight(base, "/"), Base: strings.TrimRight(base, "/"),
MothballDir: mothballDir, MothballDir: mothballDir,
StateDir: stateDir, StateDir: stateDir,
ResourcesDir: resourcesDir,
Categories: map[string]*Mothball{},
} }
// Roll over and die if directories aren't even set up // 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 return nil, err
} }
ctx.Initialize() ctx.MaybeInitialize()
return ctx, nil return ctx, nil
} }
func (ctx *Instance) Initialize () { func (ctx *Instance) MaybeInitialize() {
// Make sure points directories exist // 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.tmp"), 0755)
os.Mkdir(ctx.StatePath("points.new"), 0755) os.Mkdir(ctx.StatePath("points.new"), 0755)
os.Mkdir(ctx.StatePath("teams"), 0755)
// Preseed available team ids if file doesn't exist // 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() defer f.Close()
for i := 0; i <= 9999; i += 1 { for i := 0; i <= 9999; i += 1 {
fmt.Fprintf(f, "%04d\n", i) 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 { // Create initialized file that signals whether we're set up
defer f.Close() f, err := os.OpenFile(ctx.StatePath("initialized"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
fmt.Println("Remove this file to reinitialize the contest") 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 { func (ctx Instance) MothballPath(parts ...string) string {
@ -67,7 +88,6 @@ func (ctx *Instance) StatePath(parts ...string) string {
return path.Join(ctx.StateDir, tail) return path.Join(ctx.StateDir, tail)
} }
func (ctx *Instance) PointsLog() []Award { func (ctx *Instance) PointsLog() []Award {
var ret []Award var ret []Award

View File

@ -1,16 +1,19 @@
package main package main
import ( import (
"log" "fmt"
"io/ioutil" "io/ioutil"
"time" "log"
"os" "os"
"strings" "strings"
"fmt" "time"
) )
// maintenance runs // maintenance runs
func (ctx *Instance) Tidy() { func (ctx *Instance) Tidy() {
// Do they want to reset everything?
ctx.MaybeInitialize()
// Skip if we've been disabled // Skip if we've been disabled
if _, err := os.Stat(ctx.StatePath("disabled")); err == nil { if _, err := os.Stat(ctx.StatePath("disabled")); err == nil {
log.Print("disabled file found, suspending maintenance") log.Print("disabled file found, suspending maintenance")
@ -39,7 +42,7 @@ func (ctx *Instance) Tidy() {
for _, f := range files { for _, f := range files {
filename := f.Name() filename := f.Name()
filepath := ctx.MothballPath(filename) filepath := ctx.MothballPath(filename)
if ! strings.HasSuffix(filename, ".mb") { if !strings.HasSuffix(filename, ".mb") {
continue continue
} }
categoryName := strings.TrimSuffix(filename, ".mb") categoryName := strings.TrimSuffix(filename, ".mb")
@ -50,6 +53,7 @@ func (ctx *Instance) Tidy() {
log.Printf("Error opening %s: %s", filepath, err) log.Printf("Error opening %s: %s", filepath, err)
continue continue
} }
log.Printf("New category: %s", filename)
ctx.Categories[categoryName] = mb ctx.Categories[categoryName] = mb
} }
} }
@ -95,11 +99,9 @@ func (ctx *Instance) CollectPoints() {
} }
} }
// maintenance is the goroutine that runs a periodic maintenance task // maintenance is the goroutine that runs a periodic maintenance task
func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) { func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) {
for ;; time.Sleep(maintenanceInterval) { for ; ; time.Sleep(maintenanceInterval) {
ctx.Tidy() ctx.Tidy()
} }
} }

View File

@ -28,18 +28,18 @@ func OpenMothball(filename string) (*Mothball, error) {
return &m, nil return &m, nil
} }
func (m *Mothball) Close() (error) { func (m *Mothball) Close() error {
return m.zf.Close() return m.zf.Close()
} }
func (m *Mothball) Refresh() (error) { func (m *Mothball) Refresh() error {
info, err := os.Stat(m.filename) info, err := os.Stat(m.filename)
if err != nil { if err != nil {
return err return err
} }
mtime := info.ModTime() mtime := info.ModTime()
if ! mtime.After(m.mtime) { if !mtime.After(m.mtime) {
return nil return nil
} }

View File

@ -2,34 +2,11 @@ package main
import ( import (
"flag" "flag"
"fmt"
"log" "log"
"net/http" "net/http"
"time" "time"
) )
func showPage(w http.ResponseWriter, title string, body string) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "<!DOCTYPE html>")
fmt.Fprintf(w, "<html><head>")
fmt.Fprintf(w, "<title>%s</title>", title)
fmt.Fprintf(w, "<link rel=\"stylesheet\" href=\"static/style.css\">")
fmt.Fprintf(w, "<meta name=\"viewport\" content=\"width=device-width\"></head>")
fmt.Fprintf(w, "<link rel=\"icon\" href=\"res/luna-moth.svg\" type=\"image/svg+xml\">")
fmt.Fprintf(w, "<link rel=\"icon\" href=\"res/luna-moth.png\" type=\"image/png\">")
fmt.Fprintf(w, "<body><h1>%s</h1>", title)
fmt.Fprintf(w, "<section>%s</section>", body)
fmt.Fprintf(w, "<nav>")
fmt.Fprintf(w, "<ul>")
fmt.Fprintf(w, "<li><a href=\"static/puzzles.html\">Puzzles</a></li>")
fmt.Fprintf(w, "<li><a href=\"static/scoreboard.html\">Scoreboard</a></li>")
fmt.Fprintf(w, "</ul>")
fmt.Fprintf(w, "</nav>")
fmt.Fprintf(w, "</body></html>")
}
func logRequest(handler http.Handler) http.Handler { func logRequest(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("HTTP %s %s %s\n", r.RemoteAddr, r.Method, r.URL) log.Printf("HTTP %s %s %s\n", r.RemoteAddr, r.Method, r.URL)
@ -57,9 +34,14 @@ func main() {
"/moth/state", "/moth/state",
"Path to write state", "Path to write state",
) )
resourcesDir := flag.String(
"resources",
"/moth/resources",
"Path to static resources (HTML, images, css, ...)",
)
maintenanceInterval := flag.Duration( maintenanceInterval := flag.Duration(
"maint", "maint",
20 * time.Second, 20*time.Second,
"Maintenance interval", "Maintenance interval",
) )
listen := flag.String( listen := flag.String(
@ -73,7 +55,7 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
ctx, err := NewInstance(*base, *mothballDir, *stateDir) ctx, err := NewInstance(*base, *mothballDir, *stateDir, *resourcesDir)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

237
src/static.go Normal file
View File

@ -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, "<!DOCTYPE html>")
fmt.Fprintf(w, "<html><head>")
fmt.Fprintf(w, "<title>%s</title>", title)
fmt.Fprintf(w, "<link rel=\"stylesheet\" href=\"basic.css\">")
fmt.Fprintf(w, "<meta name=\"viewport\" content=\"width=device-width\"></head>")
fmt.Fprintf(w, "<link rel=\"icon\" href=\"res/icon.svg\" type=\"image/svg+xml\">")
fmt.Fprintf(w, "<link rel=\"icon\" href=\"res/icon.png\" type=\"image/png\">")
fmt.Fprintf(w, "<body><h1 class=\"%s\">%s</h1>", statusStr, title)
fmt.Fprintf(w, "<section>%s</section>", body)
fmt.Fprintf(w, "<nav>")
fmt.Fprintf(w, "<ul>")
fmt.Fprintf(w, "<li><a href=\"puzzles.html\">Puzzles</a></li>")
fmt.Fprintf(w, "<li><a href=\"scoreboard.html\">Scoreboard</a></li>")
fmt.Fprintf(w, "</ul>")
fmt.Fprintf(w, "</nav>")
fmt.Fprintf(w, "</body></html>")
}
// 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",
`
<h2>Register your team</h2>
<form action="register" action="post">
Team ID: <input name="id"> <br>
Team name: <input name="name">
<input type="submit" value="Register">
</form>
<p>
If someone on your team has already registered,
proceed to the
<a href="puzzles.html">puzzles overview</a>.
</p>
`,
)
}
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)
}
}