Moving toward a working server

This commit is contained in:
Neale Pickett 2018-09-17 23:00:08 +00:00
parent 5bb050166e
commit 3b3783f9ca
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",
"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
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
import (
"encoding/json"
"fmt"
"strconv"
"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)
}
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)
@ -36,11 +61,10 @@ func ParseAward(s string) (*Award, error) {
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
}

View File

@ -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")
teamname := req.FormValue("name")
teamid := req.FormValue("id")
if matched, _ := regexp.MatchString("[^0-9a-z]", teamid); matched {
teamid = ""
// 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?")
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) {
@ -76,7 +76,11 @@ func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) {
// 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?")
respond(
w, req, Fail,
"Unrecognized token",
"I don't recognize that token. Did you type in the whole thing?",
)
return
}
@ -97,15 +101,27 @@ func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) {
}
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) {
@ -121,15 +137,22 @@ func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
catmb, ok := ctx.Categories[category]
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
}
// 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()
@ -137,61 +160,92 @@ 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) {
showPage(w, "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",
`
<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>
`,
respond(
w, req, Fail,
"Wrong answer",
err.Error(),
)
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) {
mux.HandleFunc(ctx.Base + "/", ctx.rootHandler)
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)

View File

@ -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
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,
ResourcesDir: resourcesDir,
Categories: map[string]*Mothball{},
}
// Roll over and die if directories aren't even set up
@ -33,15 +36,30 @@ 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 {
@ -51,10 +69,13 @@ func (ctx *Instance) Initialize () {
}
}
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

View File

@ -1,16 +1,19 @@
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")
@ -50,6 +53,7 @@ func (ctx *Instance) Tidy() {
log.Printf("Error opening %s: %s", filepath, err)
continue
}
log.Printf("New category: %s", filename)
ctx.Categories[categoryName] = mb
}
}
@ -95,8 +99,6 @@ 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) {

View File

@ -28,11 +28,11 @@ 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

View File

@ -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, "<!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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("HTTP %s %s %s\n", r.RemoteAddr, r.Method, r.URL)
@ -57,6 +34,11 @@ 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,
@ -73,7 +55,7 @@ func main() {
log.Fatal(err)
}
ctx, err := NewInstance(*base, *mothballDir, *stateDir)
ctx, err := NewInstance(*base, *mothballDir, *stateDir, *resourcesDir)
if err != nil {
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)
}
}