mirror of https://github.com/dirtbags/moth.git
Moving toward a working server
This commit is contained in:
parent
5bb050166e
commit
3b3783f9ca
159
README.md
159
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
|
||||
|
||||
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.
|
||||
|
|
38
src/award.go
38
src/award.go
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -8,26 +9,50 @@ 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)
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
|
256
src/handlers.go
256
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")
|
||||
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?")
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -120,81 +136,119 @@ 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!")
|
||||
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())
|
||||
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>
|
||||
`,
|
||||
if !anchoredSearch(haystack, needle, 0) {
|
||||
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 + "/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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
@ -39,7 +42,7 @@ 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")
|
||||
|
@ -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,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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,9 +10,9 @@ import (
|
|||
)
|
||||
|
||||
type Mothball struct {
|
||||
zf *zip.ReadCloser
|
||||
zf *zip.ReadCloser
|
||||
filename string
|
||||
mtime time.Time
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
func OpenMothball(filename string) (*Mothball, error) {
|
||||
|
@ -28,18 +28,18 @@ 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
|
||||
}
|
||||
|
||||
|
|
42
src/mothd.go
42
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, "<!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(
|
||||
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue