mirror of https://github.com/dirtbags/moth.git
Add epilogue to SubmitAnswer, formatting change
This commit is contained in:
parent
da2caa247d
commit
bcc9b6dee5
39
docs/api.md
39
docs/api.md
|
@ -48,7 +48,7 @@ RegisterTeam(teamId, teamName)
|
|||
|
||||
Register a team name with a team hash.
|
||||
|
||||
Parameters:
|
||||
### Parameters
|
||||
|
||||
* teamId: Team's unique identifier (usually a hex value)
|
||||
* teamName: Team's human-readable name
|
||||
|
@ -56,13 +56,13 @@ Parameters:
|
|||
On success, no data is returned.
|
||||
On failure, message contains an English explanation of why.
|
||||
|
||||
Example:
|
||||
### Example
|
||||
|
||||
https://server/RegisterTeam?teamId=8b1292ca
|
||||
|
||||
{
|
||||
status: "success",
|
||||
data: nil
|
||||
data: null
|
||||
}
|
||||
|
||||
|
||||
|
@ -71,12 +71,12 @@ GetPuzzleList()
|
|||
|
||||
Return all currently-open puzzles.
|
||||
|
||||
Return data:
|
||||
### Return data
|
||||
|
||||
* puzzles: dictionary mapping from category to a list of point values.
|
||||
|
||||
|
||||
Example:
|
||||
### Example
|
||||
|
||||
https://server/GetPuzzleList
|
||||
|
||||
|
@ -96,12 +96,12 @@ GetPuzzle(category, points)
|
|||
|
||||
Return a puzzle.
|
||||
|
||||
Parameters:
|
||||
### Parameters
|
||||
|
||||
* category: name of category to fetch from
|
||||
* points: point value of the puzzle to fetch
|
||||
|
||||
Return data:
|
||||
### Return data
|
||||
|
||||
* authors: List of puzzle authors
|
||||
* hashes: list of djbhash values of acceptable answers
|
||||
|
@ -109,7 +109,7 @@ Return data:
|
|||
* body: HTML body of the puzzle
|
||||
|
||||
|
||||
Example:
|
||||
### Example
|
||||
|
||||
https://server/GetPuzzle?category=sequence&points=1
|
||||
|
||||
|
@ -130,7 +130,7 @@ GetPointsLog()
|
|||
|
||||
Return the entire points log, and team names.
|
||||
|
||||
Return data:
|
||||
### Return data
|
||||
|
||||
* teams: mapping from team number (int) to team name
|
||||
* log: list of (timestamp, team number, category, points)
|
||||
|
@ -138,7 +138,7 @@ Return data:
|
|||
Note: team number may change between calls.
|
||||
|
||||
|
||||
Example:
|
||||
### Example
|
||||
|
||||
https://server/GetEventsLog
|
||||
|
||||
|
@ -163,20 +163,27 @@ SubmitAnswer(teamId, category, points, answer)
|
|||
|
||||
Submit an answer to a puzzle.
|
||||
|
||||
Parameters:
|
||||
### Parameters
|
||||
|
||||
* teamId: Team ID (optional: if ommitted, answer is verified but no points are awarded)
|
||||
* category: category name of puzzle
|
||||
* points: point value of puzzle
|
||||
* answer: attempted answer
|
||||
|
||||
Example:
|
||||
|
||||
### Return Data
|
||||
|
||||
* epilogue: HTML to display as an "epilogue" to the puzzle
|
||||
|
||||
### Example
|
||||
|
||||
https://server/SubmitAnswer?teamId=8b1292ca&category=sequence&points=1&answer=6
|
||||
|
||||
{
|
||||
status: "success",
|
||||
data: null
|
||||
data: {
|
||||
epilogue: "That's right: in base 10, 5 + 1 = 6."
|
||||
}
|
||||
}
|
||||
|
||||
SubmitToken(teamId, token)
|
||||
|
@ -184,18 +191,18 @@ SubmitToken(teamId, token)
|
|||
|
||||
Submit a token for points
|
||||
|
||||
Parameters:
|
||||
### Parameters
|
||||
|
||||
* teamId: Team ID
|
||||
* token: Token being submitted
|
||||
|
||||
Return data:
|
||||
### Return data
|
||||
|
||||
* category: category for which this token awarded points
|
||||
* points: number of points awarded
|
||||
|
||||
|
||||
Example:
|
||||
### Example
|
||||
|
||||
https://server/SubmitToken?teamId=8b1292ca&token=wat:30:xylep-radar-nanox
|
||||
|
||||
|
|
152
handlers.go
152
handlers.go
|
@ -1,152 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func 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 = ""
|
||||
}
|
||||
|
||||
if (teamid == "") || (teamname == "") {
|
||||
showPage(w, "Invalid Entry", "Oops! Are you sure you got that right?")
|
||||
return
|
||||
}
|
||||
|
||||
if ! anchoredSearch(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?")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(statePath("state", teamid), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
showPage(
|
||||
w,
|
||||
"Registration failed",
|
||||
"Unable to register. Perhaps a teammate has already registered?",
|
||||
)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
fmt.Fprintln(f, teamname)
|
||||
showPage(w, "Success", "Okay, your team has been named and you may begin using your team ID!")
|
||||
}
|
||||
|
||||
func tokenHandler(w http.ResponseWriter, req *http.Request) {
|
||||
teamid := req.FormValue("t")
|
||||
token := req.FormValue("k")
|
||||
|
||||
// Check answer
|
||||
if ! anchoredSearch(token, statePath("tokens.txt"), 0) {
|
||||
showPage(w, "Unrecognized token", "I don't recognize that token. Did you type in the whole thing?")
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(token, ":")
|
||||
category := ""
|
||||
pointstr := ""
|
||||
if len(parts) >= 2 {
|
||||
category = parts[0]
|
||||
pointstr = parts[1]
|
||||
}
|
||||
points, err := strconv.Atoi(pointstr)
|
||||
if err != nil {
|
||||
points = 0
|
||||
}
|
||||
// Defang category name; prevent directory traversal
|
||||
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")
|
||||
return
|
||||
}
|
||||
|
||||
if err := 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 answerHandler(w http.ResponseWriter, req *http.Request) {
|
||||
teamid := req.FormValue("t")
|
||||
category := req.FormValue("c")
|
||||
pointstr := req.FormValue("p")
|
||||
answer := req.FormValue("a")
|
||||
|
||||
points, err := strconv.Atoi(pointstr)
|
||||
if err != nil {
|
||||
points = 0
|
||||
}
|
||||
|
||||
// Defang category name; prevent directory traversal
|
||||
if matched, _ := regexp.MatchString("^[A-Za-z0-9_-]", category); matched {
|
||||
category = ""
|
||||
}
|
||||
|
||||
// Check answer
|
||||
needle := fmt.Sprintf("%s %s", points, answer)
|
||||
haystack := cachePath(category, "answers.txt")
|
||||
if ! anchoredSearch(haystack, needle, 0) {
|
||||
showPage(w, "Wrong answer", err.Error())
|
||||
}
|
||||
|
||||
if err := 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 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 categories {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func pointsHandler(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
}
|
||||
|
||||
// staticHandler serves up static files.
|
||||
func 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
|
||||
}
|
||||
|
||||
http.NotFound(w, req)
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func cacheMothball(filepath string, categoryName string) {
|
||||
log.Printf("I'm exploding a mothball %s %s", filepath, categoryName)
|
||||
}
|
||||
|
||||
// maintenance runs
|
||||
func tidy() {
|
||||
// Skip if we've been disabled
|
||||
if _, err := os.Stat(statePath("disabled")); err == nil {
|
||||
log.Print("disabled file found, suspending maintenance")
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if we've expired
|
||||
untilspec, err := ioutil.ReadFile(statePath("until"))
|
||||
if err == nil {
|
||||
until, err := time.Parse(time.RFC3339, string(untilspec))
|
||||
if err != nil {
|
||||
log.Print("Unparseable date in until file: %s", until)
|
||||
} else {
|
||||
if until.Before(time.Now()) {
|
||||
log.Print("until file time reached, suspending maintenance")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current list of categories
|
||||
newCategories := []string{}
|
||||
files, err := ioutil.ReadDir(modulesPath())
|
||||
if err != nil {
|
||||
log.Printf("Error reading packages: %s", err)
|
||||
}
|
||||
for _, f := range files {
|
||||
filename := f.Name()
|
||||
filepath := modulesPath(filename)
|
||||
if ! strings.HasSuffix(filename, ".mb") {
|
||||
continue
|
||||
}
|
||||
|
||||
categoryName := strings.TrimSuffix(filename, ".mb")
|
||||
newCategories = append(newCategories, categoryName)
|
||||
|
||||
// Uncompress into cache directory
|
||||
cacheMothball(filepath, categoryName)
|
||||
}
|
||||
categories = newCategories
|
||||
|
||||
collectPoints()
|
||||
}
|
||||
|
||||
// maintenance is the goroutine that runs a periodic maintenance task
|
||||
func maintenance(maintenanceInterval time.Duration) {
|
||||
for ;; time.Sleep(maintenanceInterval) {
|
||||
tidy()
|
||||
}
|
||||
}
|
167
mothd.go
167
mothd.go
|
@ -1,167 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"github.com/namsral/flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var moduleDir string
|
||||
var stateDir string
|
||||
var cacheDir string
|
||||
var categories = []string{}
|
||||
|
||||
// anchoredSearch looks for needle in filename,
|
||||
// skipping the first skip space-delimited words
|
||||
func anchoredSearch(filename string, needle string, skip int) bool {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
log.Print("Can't open %s: %s", filename, err)
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.SplitN(" ", line, skip+1)
|
||||
if parts[skip+1] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
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 modulesPath(parts ...string) string {
|
||||
tail := path.Join(parts...)
|
||||
return path.Join(moduleDir, tail)
|
||||
}
|
||||
|
||||
func statePath(parts ...string) string {
|
||||
tail := path.Join(parts...)
|
||||
return path.Join(stateDir, tail)
|
||||
}
|
||||
|
||||
func cachePath(parts ...string) string {
|
||||
tail := path.Join(parts...)
|
||||
return path.Join(cacheDir, tail)
|
||||
}
|
||||
|
||||
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)
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func setup() error {
|
||||
// Roll over and die if directories aren't even set up
|
||||
if _, err := os.Stat(modulesPath()); os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(statePath()); os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(cachePath()); os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make sure points directories exist
|
||||
os.Mkdir(statePath("points.tmp"), 0755)
|
||||
os.Mkdir(statePath("points.new"), 0755)
|
||||
|
||||
// Preseed available team ids if file doesn't exist
|
||||
if f, err := os.OpenFile(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)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
var maintenanceInterval time.Duration
|
||||
var listen string
|
||||
|
||||
fs := flag.NewFlagSetWithEnvPrefix(os.Args[0], "MOTH", flag.ExitOnError)
|
||||
fs.StringVar(
|
||||
&moduleDir,
|
||||
"modules",
|
||||
"/moth/modules",
|
||||
"Path where your moth modules live",
|
||||
)
|
||||
fs.StringVar(
|
||||
&stateDir,
|
||||
"state",
|
||||
"/moth/state",
|
||||
"Path where state should be written",
|
||||
)
|
||||
fs.StringVar(
|
||||
&cacheDir,
|
||||
"cache",
|
||||
"/moth/cache",
|
||||
"Path for ephemeral cache",
|
||||
)
|
||||
fs.DurationVar(
|
||||
&maintenanceInterval,
|
||||
"maint",
|
||||
20 * time.Second,
|
||||
"Maintenance interval",
|
||||
)
|
||||
fs.StringVar(
|
||||
&listen,
|
||||
"listen",
|
||||
":8080",
|
||||
"[host]:port to bind and listen",
|
||||
)
|
||||
fs.Parse(os.Args[1:])
|
||||
|
||||
if err := setup(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go maintenance(maintenanceInterval)
|
||||
|
||||
fileserver := http.FileServer(http.Dir(cacheDir))
|
||||
http.HandleFunc("/", rootHandler)
|
||||
http.Handle("/static/", http.StripPrefix("/static", fileserver))
|
||||
|
||||
http.HandleFunc("/register", registerHandler)
|
||||
http.HandleFunc("/token", tokenHandler)
|
||||
http.HandleFunc("/answer", answerHandler)
|
||||
|
||||
http.HandleFunc("/puzzles.json", puzzlesHandler)
|
||||
http.HandleFunc("/points.json", pointsHandler)
|
||||
|
||||
log.Printf("Listening on %s", listen)
|
||||
log.Fatal(http.ListenAndServe(listen, logRequest(http.DefaultServeMux)))
|
||||
}
|
129
points.go
129
points.go
|
@ -1,129 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Award struct {
|
||||
when time.Time
|
||||
teamid string
|
||||
category string
|
||||
points int
|
||||
}
|
||||
|
||||
func ParseAward(s string) (*Award, error) {
|
||||
ret := Award{}
|
||||
|
||||
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) {
|
||||
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])
|
||||
}
|
||||
ret.points = points
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func (a *Award) String() string {
|
||||
return fmt.Sprintf("%d %s %s %d", a.when.Unix(), a.teamid, a.category, a.points)
|
||||
}
|
||||
|
||||
func pointsLog() []Award {
|
||||
var ret []Award
|
||||
|
||||
fn := statePath("points.log")
|
||||
f, err := os.Open(fn)
|
||||
if err != nil {
|
||||
log.Printf("Unable to open %s: %s", fn, err)
|
||||
return ret
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
cur, err := ParseAward(line)
|
||||
if err != nil {
|
||||
log.Printf("Skipping malformed award line %s: %s", line, err)
|
||||
continue
|
||||
}
|
||||
ret = append(ret, *cur)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// awardPoints gives points points to team teamid in category category
|
||||
func awardPoints(teamid string, category string, points int) error {
|
||||
fn := fmt.Sprintf("%s-%s-%d", teamid, category, points)
|
||||
tmpfn := statePath("points.tmp", fn)
|
||||
newfn := statePath("points.new", fn)
|
||||
|
||||
contents := fmt.Sprintf("%d %s %s %d\n", time.Now().Unix(), teamid, 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
|
||||
}
|
||||
|
||||
// collectPoints gathers up files in points.new/ and appends their contents to points.log,
|
||||
// removing each points.new/ file as it goes.
|
||||
func collectPoints() {
|
||||
logf, err := os.OpenFile(statePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Printf("Can't append to points log: %s", err)
|
||||
return
|
||||
}
|
||||
defer logf.Close()
|
||||
|
||||
files, err := ioutil.ReadDir(statePath("points.new"))
|
||||
if err != nil {
|
||||
log.Printf("Error reading packages: %s", err)
|
||||
}
|
||||
for _, f := range files {
|
||||
filename := statePath("points.new", f.Name())
|
||||
s, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
log.Printf("Can't read points file %s: %s", filename, err)
|
||||
continue
|
||||
}
|
||||
award, err := ParseAward(string(s))
|
||||
if err != nil {
|
||||
log.Printf("Can't parse award file %s: %s", filename, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(logf, "%s\n", award.String())
|
||||
log.Print(award.String())
|
||||
logf.Sync()
|
||||
if err := os.Remove(filename); err != nil {
|
||||
log.Printf("Unable to remove %s: %s", filename, err)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue