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

161
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
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!

View File

@ -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
}

View File

@ -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")
}

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")
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",
`
<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, 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)
}

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
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
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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
}
}

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)
@ -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)
}

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)
}
}