mirror of https://github.com/dirtbags/moth.git
Moving toward a working server
This commit is contained in:
parent
5bb050166e
commit
3b3783f9ca
161
README.md
161
README.md
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
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,
|
Resetting an instance
|
||||||
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.
|
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!
|
||||||
|
|
||||||
|
|
46
src/award.go
46
src/award.go
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -8,39 +9,62 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Award struct {
|
type Award struct {
|
||||||
When time.Time
|
When time.Time
|
||||||
TeamId string
|
TeamId string
|
||||||
Category string
|
Category string
|
||||||
Points int
|
Points int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Award) String() string {
|
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)
|
||||||
|
|
||||||
ret.TeamId = parts[1]
|
ret.TeamId = parts[1]
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,11 +20,11 @@ func TestAward(t *testing.T) {
|
||||||
if a.Points != 1 {
|
if a.Points != 1 {
|
||||||
t.Error("Points parsed wrong")
|
t.Error("Points parsed wrong")
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.String() != entry {
|
if a.String() != entry {
|
||||||
t.Error("String conversion wonky")
|
t.Error("String conversion wonky")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := ParseAward("bad bad bad 1"); err == nil {
|
if _, err := ParseAward("bad bad bad 1"); err == nil {
|
||||||
t.Error("Not throwing error on bad timestamp")
|
t.Error("Not throwing error on bad timestamp")
|
||||||
}
|
}
|
||||||
|
|
266
src/handlers.go
266
src/handlers.go
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,17 +99,29 @@ func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
if matched, _ := regexp.MatchString("^[A-Za-z0-9_-]", category); matched {
|
if matched, _ := regexp.MatchString("^[A-Za-z0-9_-]", category); matched {
|
||||||
category = ""
|
category = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
@ -118,83 +134,121 @@ func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
points = 0
|
points = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
||||||
|
w, req, Fail,
|
||||||
|
"Wrong answer",
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
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(
|
||||||
return
|
w, req, Error,
|
||||||
}
|
"Error awarding points",
|
||||||
showPage(w, "Points awarded", fmt.Sprintf("%d points for %s!", points, teamid))
|
err.Error(),
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
respond(
|
||||||
http.NotFound(w, req)
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
Categories map[string]*Mothball
|
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{
|
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
|
||||||
|
|
||||||
|
@ -78,7 +98,7 @@ func (ctx *Instance) PointsLog() []Award {
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
scanner := bufio.NewScanner(f)
|
scanner := bufio.NewScanner(f)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
|
@ -89,7 +109,7 @@ func (ctx *Instance) PointsLog() []Award {
|
||||||
}
|
}
|
||||||
ret = append(ret, *cur)
|
ret = append(ret, *cur)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret
|
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)
|
fn := fmt.Sprintf("%s-%s-%d", teamid, category, points)
|
||||||
tmpfn := ctx.StatePath("points.tmp", fn)
|
tmpfn := ctx.StatePath("points.tmp", fn)
|
||||||
newfn := ctx.StatePath("points.new", fn)
|
newfn := ctx.StatePath("points.new", fn)
|
||||||
|
|
||||||
contents := fmt.Sprintf("%d %s %s %d\n", time.Now().Unix(), teamid, category, points)
|
contents := fmt.Sprintf("%d %s %s %d\n", time.Now().Unix(), teamid, category, points)
|
||||||
|
|
||||||
if err := ioutil.WriteFile(tmpfn, []byte(contents), 0644); err != nil {
|
if err := ioutil.WriteFile(tmpfn, []byte(contents), 0644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Rename(tmpfn, newfn); err != nil {
|
if err := os.Rename(tmpfn, newfn); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Award %s %s %d", teamid, category, points)
|
log.Printf("Award %s %s %d", teamid, category, points)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,25 @@
|
||||||
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")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if we've expired
|
// Skip if we've expired
|
||||||
untilspec, err := ioutil.ReadFile(ctx.StatePath("until"))
|
untilspec, err := ioutil.ReadFile(ctx.StatePath("until"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
@ -30,7 +33,7 @@ func (ctx *Instance) Tidy() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any new categories?
|
// Any new categories?
|
||||||
files, err := ioutil.ReadDir(ctx.MothballPath())
|
files, err := ioutil.ReadDir(ctx.MothballPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -39,24 +42,25 @@ 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")
|
||||||
|
|
||||||
if _, ok := ctx.Categories[categoryName]; !ok {
|
if _, ok := ctx.Categories[categoryName]; !ok {
|
||||||
mb, err := OpenMothball(filepath)
|
mb, err := OpenMothball(filepath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any old categories?
|
// Any old categories?
|
||||||
log.Print("XXX: Check for and reap old categories")
|
log.Print("XXX: Check for and reap old categories")
|
||||||
|
|
||||||
ctx.CollectPoints()
|
ctx.CollectPoints()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,7 +73,7 @@ func (ctx *Instance) CollectPoints() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer logf.Close()
|
defer logf.Close()
|
||||||
|
|
||||||
files, err := ioutil.ReadDir(ctx.StatePath("points.new"))
|
files, err := ioutil.ReadDir(ctx.StatePath("points.new"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error reading packages: %s", err)
|
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
|
// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,16 +10,16 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Mothball struct {
|
type Mothball struct {
|
||||||
zf *zip.ReadCloser
|
zf *zip.ReadCloser
|
||||||
filename string
|
filename string
|
||||||
mtime time.Time
|
mtime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenMothball(filename string) (*Mothball, error) {
|
func OpenMothball(filename string) (*Mothball, error) {
|
||||||
var m Mothball
|
var m Mothball
|
||||||
|
|
||||||
m.filename = filename
|
m.filename = filename
|
||||||
|
|
||||||
err := m.Refresh()
|
err := m.Refresh()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -28,21 +28,21 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
zf, err := zip.OpenReader(m.filename)
|
zf, err := zip.OpenReader(m.filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -53,7 +53,7 @@ func (m *Mothball) Refresh() (error) {
|
||||||
}
|
}
|
||||||
m.zf = zf
|
m.zf = zf
|
||||||
m.mtime = mtime
|
m.mtime = mtime
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,7 +73,7 @@ func (m *Mothball) ReadFile(filename string) ([]byte, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
bytes, err := ioutil.ReadAll(f)
|
bytes, err := ioutil.ReadAll(f)
|
||||||
return bytes, err
|
return bytes, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ func TestMothball(t *testing.T) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer os.Remove(tf.Name())
|
defer os.Remove(tf.Name())
|
||||||
|
|
||||||
w := zip.NewWriter(tf)
|
w := zip.NewWriter(tf)
|
||||||
f, err := w.Create("moo.txt")
|
f, err := w.Create("moo.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -33,7 +33,7 @@ func TestMothball(t *testing.T) {
|
||||||
}
|
}
|
||||||
w.Close()
|
w.Close()
|
||||||
tf.Close()
|
tf.Close()
|
||||||
|
|
||||||
// Now read it in
|
// Now read it in
|
||||||
mb, err := OpenMothball(tf.Name())
|
mb, err := OpenMothball(tf.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -46,7 +46,7 @@ func TestMothball(t *testing.T) {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
line := make([]byte, 200)
|
line := make([]byte, 200)
|
||||||
n, err := cow.Read(line)
|
n, err := cow.Read(line)
|
||||||
if (err != nil) && (err != io.EOF) {
|
if (err != nil) && (err != io.EOF) {
|
||||||
|
@ -59,5 +59,5 @@ func TestMothball(t *testing.T) {
|
||||||
t.Error("Contents didn't match")
|
t.Error("Contents didn't match")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
44
src/mothd.go
44
src/mothd.go
|
@ -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)
|
||||||
|
@ -42,11 +19,11 @@ func setup() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
base := flag.String(
|
base := flag.String(
|
||||||
"base",
|
"base",
|
||||||
"/",
|
"/",
|
||||||
"Base URL of this instance",
|
"Base URL of this instance",
|
||||||
)
|
)
|
||||||
mothballDir := flag.String(
|
mothballDir := flag.String(
|
||||||
"mothballs",
|
"mothballs",
|
||||||
"/moth/mothballs",
|
"/moth/mothballs",
|
||||||
|
@ -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(
|
||||||
|
@ -68,12 +50,12 @@ func main() {
|
||||||
"[host]:port to bind and listen",
|
"[host]:port to bind and listen",
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if err := setup(); err != nil {
|
if err := setup(); err != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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