Merge pull request #57 from dirtbags/auth

Authenticate puzzle list + check answers
This commit is contained in:
Neale Pickett 2019-02-25 11:42:44 -07:00 committed by GitHub
commit 0ff3679b2c
18 changed files with 758 additions and 385 deletions

View File

@ -1,7 +1,9 @@
FROM alpine:3.8 AS builder FROM alpine:3.9 AS builder
RUN apk --no-cache add go libc-dev RUN apk --no-cache add go libc-dev git
COPY src /src COPY src /root/go/src/github.com/dirtbags/moth/src
RUN go build -o /mothd /src/*.go WORKDIR /root/go/src/github.com/dirtbags/moth/src
RUN go get .
RUN go build -o /mothd *.go
FROM alpine FROM alpine
COPY --from=builder /mothd /mothd COPY --from=builder /mothd /mothd

View File

@ -29,6 +29,36 @@ def get_seed(request):
return int(seedstr) return int(seedstr)
def get_puzzle(request, data=None):
seed = get_seed(request)
if not data:
data = request.match_info
category = data.get("cat")
points = int(data.get("points"))
filename = data.get("filename")
cat = moth.Category(request.app["puzzles_dir"].joinpath(category), seed)
puzzle = cat.puzzle(points)
return puzzle
async def handle_answer(request):
data = await request.post()
puzzle = get_puzzle(request, data)
ret = {
"status": "success",
"data": {
"short": "",
"description": "Provided answer was not in list of answers"
},
}
if data.get("answer") in puzzle.answers:
ret["data"]["description"] = "Answer is correct"
return web.Response(
content_type="application/json",
body=json.dumps(ret),
)
async def handle_puzzlelist(request): async def handle_puzzlelist(request):
seed = get_seed(request) seed = get_seed(request)
puzzles = { puzzles = {
@ -51,7 +81,7 @@ async def handle_puzzlelist(request):
async def handle_puzzle(request): async def handle_puzzle(request):
seed = get_seed(request) seed = get_seed(request)
category = request.match_info.get("category") category = request.match_info.get("cat")
points = int(request.match_info.get("points")) points = int(request.match_info.get("points"))
cat = moth.Category(request.app["puzzles_dir"].joinpath(category), seed) cat = moth.Category(request.app["puzzles_dir"].joinpath(category), seed)
puzzle = cat.puzzle(points) puzzle = cat.puzzle(points)
@ -70,7 +100,7 @@ async def handle_puzzle(request):
async def handle_puzzlefile(request): async def handle_puzzlefile(request):
seed = get_seed(request) seed = get_seed(request)
category = request.match_info.get("category") category = request.match_info.get("cat")
points = int(request.match_info.get("points")) points = int(request.match_info.get("points"))
filename = request.match_info.get("filename") filename = request.match_info.get("filename")
cat = moth.Category(request.app["puzzles_dir"].joinpath(category), seed) cat = moth.Category(request.app["puzzles_dir"].joinpath(category), seed)
@ -87,10 +117,9 @@ async def handle_puzzlefile(request):
content_type=content_type, content_type=content_type,
) )
async def handle_mothballer(request): async def handle_mothballer(request):
seed = get_seed(request) seed = get_seed(request)
category = request.match_info.get("category") category = request.match_info.get("cat")
try: try:
catdir = request.app["puzzles_dir"].joinpath(category) catdir = request.app["puzzles_dir"].joinpath(category)
@ -113,19 +142,37 @@ async def handle_index(request):
seed = random.getrandbits(32) seed = random.getrandbits(32)
body = """<!DOCTYPE html> body = """<!DOCTYPE html>
<html> <html>
<head><title>Dev Server</title></head> <head>
<title>Dev Server</title>
<script>
// Skip trying to log in
sessionStorage.setItem("id", "devel-server")
</script>
</head>
<body> <body>
<h1>Dev Server</h1> <h1>Dev Server</h1>
<p> <p>
You need to provide the contest seed in the URL. Pick a seed:
If you don't have a contest seed in mind,
why not try <a href="{seed}/">{seed}</a>?
</p> </p>
<ul>
<li><a href="{seed}/">{seed}</a>: a special seed I made just for you!</li>
<li><a href="random/">random</a>: will use a different seed every time you load a page (could be useful for debugging)</li>
<li>You can also hack your own seed into the URL, if you want to.</li>
</ul>
<p> <p>
If you are chaotic, Puzzles can be generated from Python code: these puzzles can use a random number generator if they want.
you could even take your chances with a The seed is used to create these random numbers.
<a href="random/">random seed</a> for every HTTP request. </p>
This means generated files will get a different seed than the puzzle itself!
<p>
We like to make a new seed for every contest,
and re-use that seed whenever we regenerate a category during an event
(say to fix a bug).
By using the same seed,
we make sure that all the dynamically-generated puzzles have the same values
in any new packages we build.
</p> </p>
</body> </body>
</html> </html>
@ -140,12 +187,8 @@ async def handle_static(request):
themes = request.app["theme_dir"] themes = request.app["theme_dir"]
fn = request.match_info.get("filename") fn = request.match_info.get("filename")
if not fn: if not fn:
for fn in ("puzzle-list.html", "index.html"): fn = "index.html"
path = themes.joinpath(fn) path = themes.joinpath(fn)
if path.exists():
break
else:
path = themes.joinpath(fn)
return web.FileResponse(path) return web.FileResponse(path)
@ -182,9 +225,10 @@ if __name__ == '__main__':
app["puzzles_dir"] = pathlib.Path(args.puzzles) app["puzzles_dir"] = pathlib.Path(args.puzzles)
app["theme_dir"] = pathlib.Path(args.theme) app["theme_dir"] = pathlib.Path(args.theme)
app.router.add_route("GET", "/", handle_index) app.router.add_route("GET", "/", handle_index)
app.router.add_route("GET", "/{seed}/puzzles.json", handle_puzzlelist) app.router.add_route("*", "/{seed}/answer", handle_answer)
app.router.add_route("GET", "/{seed}/content/{category}/{points}/puzzle.json", handle_puzzle) app.router.add_route("*", "/{seed}/puzzles.json", handle_puzzlelist)
app.router.add_route("GET", "/{seed}/content/{category}/{points}/{filename}", handle_puzzlefile) app.router.add_route("GET", "/{seed}/content/{cat}/{points}/puzzle.json", handle_puzzle)
app.router.add_route("GET", "/{seed}/mothballer/{category}", handle_mothballer) app.router.add_route("GET", "/{seed}/content/{cat}/{points}/{filename}", handle_puzzlefile)
app.router.add_route("GET", "/{seed}/mothballer/{cat}", handle_mothballer)
app.router.add_route("GET", "/{seed}/{filename:.*}", handle_static) app.router.add_route("GET", "/{seed}/{filename:.*}", handle_static)
web.run_app(app, host=addr, port=port) web.run_app(app, host=addr, port=port)

View File

@ -15,9 +15,9 @@ import tempfile
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
def djb2hash(buf): def djb2hash(str):
h = 5381 h = 5381
for c in buf: for c in str.encode("utf-8"):
h = ((h * 33) + c) & 0xffffffff h = ((h * 33) + c) & 0xffffffff
return h return h
@ -75,6 +75,7 @@ class Puzzle:
self.authors = [] self.authors = []
self.answers = [] self.answers = []
self.scripts = [] self.scripts = []
self.pattern = None
self.hint = None self.hint = None
self.files = {} self.files = {}
self.body = io.StringIO() self.body = io.StringIO()
@ -104,6 +105,8 @@ class Puzzle:
self.summary = val self.summary = val
elif key == 'answer': elif key == 'answer':
self.answers.append(val) self.answers.append(val)
elif key == 'pattern':
self.pattern = val
elif key == 'hint': elif key == 'hint':
self.hint = val self.hint = val
elif key == 'name': elif key == 'name':
@ -271,13 +274,14 @@ class Puzzle:
'hashes': self.hashes(), 'hashes': self.hashes(),
'files': files, 'files': files,
'scripts': self.scripts, 'scripts': self.scripts,
'pattern': self.pattern,
'body': self.html_body(), 'body': self.html_body(),
} }
def hashes(self): def hashes(self):
"Return a list of answer hashes" "Return a list of answer hashes"
return [djb2hash(a.encode('utf-8')) for a in self.answers] return [djb2hash(a) for a in self.answers]
class Category: class Category:

View File

@ -0,0 +1,46 @@
function helperUpdateAnswer(event) {
let e = event.currentTarget
let value = e.value
let inputs = e.querySelectorAll("input")
if (inputs.length > 0) {
// If there are child input nodes, join their values with commas
let values = []
for (let c of inputs) {
if (c.type == "checkbox") {
if (c.checked) {
values.push(c.value)
}
} else {
values.push(c.value)
}
}
value = values.join(",")
}
// First make any adjustments to the value
if (e.classList.contains("lower")) {
value = value.toLowerCase()
}
if (e.classList.contains("upper")) {
value = value.toUpperCase()
}
document.querySelector("#answer").value = value
}
function helperActivate(e) {
e.addEventListener("input", helperUpdateAnswer)
}
function helperInit(event) {
for (let e of document.querySelectorAll(".answer")) {
helperActivate(e)
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", helperInit);
} else {
helperInit();
}

View File

@ -0,0 +1,39 @@
Summary: Using JavaScript Input Helpers
Author: neale
Script: helpers.js
Answer: helper
MOTH only takes static answers:
you can't, for instance, write code to check answer correctness.
But you can provide as many correct answers as you like in a single puzzle.
This page has an associated `helpers.js` script
you can include to assist with input formatting,
so people aren't confused about how to enter an answer.
You could also write your own JavaScript to validate things.
This is just a demonstration page.
You will probably only want one of these in a page,
to avoid confusing people.
Timestamp
<input type="datetime-local" class="answer">
All lower-case letters
<input class="answer lower">
Multiple concatenated values
<div class="answer lower">
<input type="color">
<input type="number">
<input type="range" min="0" max="127">
<input>
</div>
Select from an ordered list of options
<ul class="answer">
<li><input type="checkbox" value="horn">Horns</li>
<li><input type="checkbox" value="hoof">Hooves</li>
<li><input type="checkbox" value="antler">Antlers</li>
</ul>

View File

@ -12,33 +12,26 @@ import (
"strings" "strings"
) )
// https://github.com/omniti-labs/jsend
type JSend struct { type JSend struct {
Status string `json:"status"` Status string `json:"status"`
Data JSendData `json:"data"` Data struct {
} Short string `json:"short"`
type JSendData struct { Description string `json:"description"`
Short string `json:"short"` } `json:"data"`
Description string `json:"description"`
} }
// ShowJSend renders a JSend response to w const (
func ShowJSend(w http.ResponseWriter, status Status, short string, description string) { JSendSuccess = "success"
JSendFail = "fail"
JSendError = "error"
)
resp := JSend{ func respond(w http.ResponseWriter, req *http.Request, status string, short string, format string, a ...interface{}) {
Status: "success", resp := JSend{}
Data: JSendData{ resp.Status = status
Short: short, resp.Data.Short = short
Description: description, resp.Data.Description = fmt.Sprintf(format, a...)
},
}
switch status {
case Success:
resp.Status = "success"
case Fail:
resp.Status = "fail"
default:
resp.Status = "error"
}
respBytes, err := json.Marshal(resp) respBytes, err := json.Marshal(resp)
if err != nil { if err != nil {
@ -51,59 +44,6 @@ func ShowJSend(w http.ResponseWriter, status Status, short string, description s
w.Write(respBytes) w.Write(respBytes)
} }
type Status int
const (
Success = iota
Fail
Error
)
// 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, "<!-- If you put `application/json` in the `Accept` header of this request, you would have gotten a JSON object instead of HTML. -->\n")
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\">")
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, "</head><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=\"puzzle-list.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>")
}
func respond(w http.ResponseWriter, req *http.Request, status Status, short string, format string, a ...interface{}) {
long := fmt.Sprintf(format, a...)
// 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, long)
} else {
ShowHtml(w, status, short, long)
}
}
// hasLine returns true if line appears in r. // hasLine returns true if line appears in r.
// The entire line must match. // The entire line must match.
func hasLine(r io.Reader, line string) bool { func hasLine(r io.Reader, line string) bool {
@ -129,7 +69,7 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
if (teamid == "") || (teamname == "") { if (teamid == "") || (teamname == "") {
respond( respond(
w, req, Fail, w, req, JSendFail,
"Invalid Entry", "Invalid Entry",
"Either `id` or `name` was missing from this request.", "Either `id` or `name` was missing from this request.",
) )
@ -139,7 +79,7 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
teamids, err := os.Open(ctx.StatePath("teamids.txt")) teamids, err := os.Open(ctx.StatePath("teamids.txt"))
if err != nil { if err != nil {
respond( respond(
w, req, Fail, w, req, JSendFail,
"Cannot read valid team IDs", "Cannot read valid team IDs",
"An error was encountered trying to read valid teams IDs: %v", err, "An error was encountered trying to read valid teams IDs: %v", err,
) )
@ -148,7 +88,7 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
defer teamids.Close() defer teamids.Close()
if !hasLine(teamids, teamid) { if !hasLine(teamids, teamid) {
respond( respond(
w, req, Fail, w, req, JSendFail,
"Invalid Team ID", "Invalid Team ID",
"I don't have a record of that team ID. Maybe you used capital letters accidentally?", "I don't have a record of that team ID. Maybe you used capital letters accidentally?",
) )
@ -156,10 +96,17 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
} }
f, err := os.OpenFile(ctx.StatePath("teams", 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 os.IsExist(err) {
respond(
w, req, JSendFail,
"Already registered",
"This team ID has already been registered.",
)
return
} else if err != nil {
log.Print(err) log.Print(err)
respond( respond(
w, req, Fail, w, req, JSendFail,
"Registration failed", "Registration failed",
"Unable to register. Perhaps a teammate has already registered?", "Unable to register. Perhaps a teammate has already registered?",
) )
@ -168,7 +115,7 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
defer f.Close() defer f.Close()
fmt.Fprintln(f, teamname) fmt.Fprintln(f, teamname)
respond( respond(
w, req, Success, w, req, JSendSuccess,
"Team registered", "Team registered",
"Okay, your team has been named and you may begin using your team ID!", "Okay, your team has been named and you may begin using your team ID!",
) )
@ -183,7 +130,7 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
points, err := strconv.Atoi(pointstr) points, err := strconv.Atoi(pointstr)
if err != nil { if err != nil {
respond( respond(
w, req, Fail, w, req, JSendFail,
"Cannot parse point value", "Cannot parse point value",
"This doesn't look like an integer: %s", pointstr, "This doesn't look like an integer: %s", pointstr,
) )
@ -193,7 +140,7 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
haystack, err := ctx.OpenCategoryFile(category, "answers.txt") haystack, err := ctx.OpenCategoryFile(category, "answers.txt")
if err != nil { if err != nil {
respond( respond(
w, req, Fail, w, req, JSendFail,
"Cannot list answers", "Cannot list answers",
"Unable to read the list of answers for this category.", "Unable to read the list of answers for this category.",
) )
@ -205,7 +152,7 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
needle := fmt.Sprintf("%d %s", points, answer) needle := fmt.Sprintf("%d %s", points, answer)
if !hasLine(haystack, needle) { if !hasLine(haystack, needle) {
respond( respond(
w, req, Fail, w, req, JSendFail,
"Wrong answer", "Wrong answer",
"That is not the correct answer for %s %d.", category, points, "That is not the correct answer for %s %d.", category, points,
) )
@ -214,20 +161,26 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
if err := ctx.AwardPoints(teamid, category, points); err != nil { if err := ctx.AwardPoints(teamid, category, points); err != nil {
respond( respond(
w, req, Error, w, req, JSendError,
"Cannot award points", "Cannot award points",
"The answer is correct, but there was an error awarding points: %v", err.Error(), "The answer is correct, but there was an error awarding points: %v", err.Error(),
) )
return return
} }
respond( respond(
w, req, Success, w, req, JSendSuccess,
"Points awarded", "Points awarded",
fmt.Sprintf("%d points for %s!", points, teamid), fmt.Sprintf("%d points for %s!", points, teamid),
) )
} }
func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) {
teamid := req.FormValue("id")
if _, err := ctx.TeamName(teamid); err != nil {
http.Error(w, "Unauthorized: must provide team ID", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write(ctx.jPuzzleList) w.Write(ctx.jPuzzleList)
@ -301,11 +254,46 @@ func (ctx *Instance) staticHandler(w http.ResponseWriter, req *http.Request) {
http.ServeContent(w, req, path, d.ModTime(), f) http.ServeContent(w, req, path, d.ModTime(), f)
} }
func (ctx *Instance) BindHandlers(mux *http.ServeMux) { type FurtiveResponseWriter struct {
mux.HandleFunc(ctx.Base+"/", ctx.staticHandler) w http.ResponseWriter
mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler) statusCode *int
mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler) }
mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler)
mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler) func (w FurtiveResponseWriter) WriteHeader(statusCode int) {
mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler) *w.statusCode = statusCode
w.w.WriteHeader(statusCode)
}
func (w FurtiveResponseWriter) Write(buf []byte) (n int, err error) {
n, err = w.w.Write(buf)
return
}
func (w FurtiveResponseWriter) Header() http.Header {
return w.w.Header()
}
// This gives Instances the signature of http.Handler
func (ctx *Instance) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
w := FurtiveResponseWriter{
w: wOrig,
statusCode: new(int),
}
ctx.mux.ServeHTTP(w, r)
log.Printf(
"%s %s %s %d\n",
r.RemoteAddr,
r.Method,
r.URL,
*w.statusCode,
)
}
func (ctx *Instance) BindHandlers() {
ctx.mux.HandleFunc(ctx.Base+"/", ctx.staticHandler)
ctx.mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler)
ctx.mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler)
ctx.mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler)
ctx.mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler)
ctx.mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler)
} }

View File

@ -6,6 +6,8 @@ import (
"io" "io"
"io/ioutil" "io/ioutil"
"log" "log"
"math/rand"
"net/http"
"os" "os"
"path" "path"
"strings" "strings"
@ -21,6 +23,7 @@ type Instance struct {
update chan bool update chan bool
jPuzzleList []byte jPuzzleList []byte
jPointsLog []byte jPointsLog []byte
mux *http.ServeMux
} }
func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) { func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) {
@ -31,6 +34,7 @@ func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, e
ResourcesDir: resourcesDir, ResourcesDir: resourcesDir,
Categories: map[string]*Mothball{}, Categories: map[string]*Mothball{},
update: make(chan bool, 10), update: make(chan bool, 10),
mux: http.NewServeMux(),
} }
// Roll over and die if directories aren't even set up // Roll over and die if directories aren't even set up
@ -41,11 +45,24 @@ func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, e
return nil, err return nil, err
} }
ctx.BindHandlers()
ctx.MaybeInitialize() ctx.MaybeInitialize()
return ctx, nil return ctx, nil
} }
// Stuff people with mediocre handwriting could write down unambiguously, and can be entered without holding down shift
const distinguishableChars = "234678abcdefhijkmnpqrtwxyz="
func mktoken() string {
a := make([]byte, 8)
for i := range a {
char := rand.Intn(len(distinguishableChars))
a[i] = distinguishableChars[char]
}
return string(a)
}
func (ctx *Instance) MaybeInitialize() { func (ctx *Instance) MaybeInitialize() {
// Only do this if it hasn't already been done // Only do this if it hasn't already been done
if _, err := os.Stat(ctx.StatePath("initialized")); err == nil { if _, err := os.Stat(ctx.StatePath("initialized")); err == nil {
@ -69,8 +86,8 @@ func (ctx *Instance) MaybeInitialize() {
// 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 <= 100; i += 1 {
fmt.Fprintf(f, "%04d\n", i) fmt.Fprintln(f, mktoken())
} }
} }
@ -83,18 +100,31 @@ func (ctx *Instance) MaybeInitialize() {
fmt.Fprintln(f, "Remove this file to reinitialize the contest") fmt.Fprintln(f, "Remove this file to reinitialize the contest")
} }
func pathCleanse(parts []string) string {
clean := make([]string, len(parts))
for i := range parts {
part := parts[i]
part = strings.TrimLeft(part, ".")
if p := strings.LastIndex(part, "/"); p >= 0 {
part = part[p+1:]
}
clean[i] = part
}
return path.Join(clean...)
}
func (ctx Instance) MothballPath(parts ...string) string { func (ctx Instance) MothballPath(parts ...string) string {
tail := path.Join(parts...) tail := pathCleanse(parts)
return path.Join(ctx.MothballDir, tail) return path.Join(ctx.MothballDir, tail)
} }
func (ctx *Instance) StatePath(parts ...string) string { func (ctx *Instance) StatePath(parts ...string) string {
tail := path.Join(parts...) tail := pathCleanse(parts)
return path.Join(ctx.StateDir, tail) return path.Join(ctx.StateDir, tail)
} }
func (ctx *Instance) ResourcePath(parts ...string) string { func (ctx *Instance) ResourcePath(parts ...string) string {
tail := path.Join(parts...) tail := pathCleanse(parts)
return path.Join(ctx.ResourcesDir, tail) return path.Join(ctx.ResourcesDir, tail)
} }

View File

@ -1,21 +1,16 @@
package main package main
import ( import (
"flag" "github.com/namsral/flag"
"log" "log"
"math/rand"
"mime" "mime"
"net/http" "net/http"
"time" "time"
) )
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 { func setup() error {
rand.Seed(time.Now().UnixNano())
return nil return nil
} }
@ -60,7 +55,6 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
ctx.BindHandlers(http.DefaultServeMux)
// Add some MIME extensions // Add some MIME extensions
// Doing this avoids decompressing a mothball entry twice per request // Doing this avoids decompressing a mothball entry twice per request
@ -70,5 +64,5 @@ func main() {
go ctx.Maintenance(*maintenanceInterval) go ctx.Maintenance(*maintenanceInterval)
log.Printf("Listening on %s", *listen) log.Printf("Listening on %s", *listen)
log.Fatal(http.ListenAndServe(*listen, logRequest(http.DefaultServeMux))) log.Fatal(http.ListenAndServe(*listen, ctx))
} }

View File

@ -12,7 +12,7 @@ h1 {
background: #5e576b; background: #5e576b;
color: #9e98a8; color: #9e98a8;
} }
.Fail, .Error { .Fail, .Error, #messages {
background: #3a3119; background: #3a3119;
color: #ffcc98; color: #ffcc98;
} }
@ -50,6 +50,13 @@ iframe#body {
img { img {
max-width: 100%; max-width: 100%;
} }
input:invalid {
border-color: red;
}
#messages {
min-height: 3em;
border: solid black 2px;
}
#scoreboard { #scoreboard {
width: 100%; width: 100%;
position: relative; position: relative;
@ -87,3 +94,25 @@ img {
.kvpair { .kvpair {
border: solid black 2px; border: solid black 2px;
} }
.spinner {
display: inline-block;
width: 64px;
height: 64px;
display: block;
width: 46px;
height: 46px;
margin: 1px;
border-radius: 50%;
border: 5px solid #fff;
border-color: #fff transparent #fff transparent;
animation: rotate 1.2s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -1,47 +0,0 @@
// Devel server addons
// devel_addin drops a bunch of development extensions into element e.
// It will only modify stuff inside e.
function devel_addin(obj, e) {
let h = document.createElement("h2");
e.appendChild(h);
h.textContent = "Development Options";
let g = document.createElement("p");
e.appendChild(g);
g.innerText = "This section will not appear for participants."
let keys = Object.keys(obj);
keys.sort();
for (let key of keys) {
switch (key) {
case "body":
continue;
}
let d = document.createElement("div");
e.appendChild(d);
d.classList.add("kvpair");
let ktxt = document.createElement("span");
d.appendChild(ktxt);
ktxt.textContent = key;
let val = obj[key];
if (Array.isArray(val)) {
let vi = document.createElement("select");
d.appendChild(vi);
vi.multiple = true;
for (let a of val) {
let opt = document.createElement("option");
vi.appendChild(opt);
opt.innerText = a;
}
} else {
let vi = document.createElement("input");
d.appendChild(vi);
vi.value = val;
vi.disabled = true;
}
}
}

View File

@ -1,31 +1,30 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>Welcome</title> <title>MOTH</title>
<link rel="stylesheet" href="basic.css"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="basic.css">
<script src="moth.js"></script>
</head> </head>
<body> <body>
<h1>Welcome</h1> <h1 id="title">MOTH</h1>
<section> <section>
<h2>Register your team</h2> <div id="messages"></div>
<form action="register" method="post"> <form id="login">
Team ID: <input name="id"> <br>
Team name: <input name="name"> Team name: <input name="name">
<input type="submit" value="Register"> Team ID: <input name="id"> <br>
<input type="submit" value="Sign In">
</form> </form>
<p> <div id="puzzles"></div>
If someone on your team has already registered,
proceed to the
<a href="puzzle-list.html">puzzles overview</a>.
</p>
</section> </section>
<nav> <nav>
<ul> <ul>
<li><a href="puzzle-list.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li> <li><a href="scoreboard.html">Scoreboard</a></li>
<li><a href="logout.html">Sign Out</a></li>
</ul> </ul>
</nav> </nav>
</body> </body>

23
theme/logout.html Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>MOTH</title>
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="basic.css">
<script>
sessionStorage.removeItem("id")
</script>
</head>
<body>
<h1 id="title">MOTH</h1>
<section>
<p>Okay, you've been logged out.</p>
</section>
<nav>
<ul>
<li><a href="index.html">Sign In</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body>
</html>

167
theme/moth.js Normal file
View File

@ -0,0 +1,167 @@
// jshint asi:true
var teamId
var heartbeatInterval = 40000
function toast(message, timeout=5000) {
let p = document.createElement("p")
p.innerText = message
document.getElementById("messages").appendChild(p)
setTimeout(
e => { p.remove() },
timeout
)
}
function renderPuzzles(obj) {
let puzzlesElement = document.createElement('div')
// Create a sorted list of category names
let cats = Object.keys(obj)
cats.sort()
for (let cat of cats) {
if (cat.startsWith("__")) {
// Skip metadata
continue
}
let puzzles = obj[cat]
let pdiv = document.createElement('div')
pdiv.className = 'category'
let h = document.createElement('h2')
pdiv.appendChild(h)
h.textContent = cat
// Extras if we're running a devel server
if (obj.__devel__) {
let a = document.createElement('a')
h.insertBefore(a, h.firstChild)
a.textContent = "⬇️"
a.href = "mothballer/" + cat
a.classList.add("mothball")
a.title = "Download a compiled puzzle for this category"
}
// List out puzzles in this category
let l = document.createElement('ul')
pdiv.appendChild(l)
for (let puzzle of puzzles) {
let points = puzzle[0]
let id = puzzle[1]
let i = document.createElement('li')
l.appendChild(i)
i.textContent = " "
if (points === 0) {
// Sentry: there are no more puzzles in this category
i.textContent = "✿"
} else {
let a = document.createElement('a')
i.appendChild(a)
a.textContent = points
a.href = "puzzle.html?cat=" + cat + "&points=" + points + "&pid=" + id
}
}
puzzlesElement.appendChild(pdiv)
}
// Drop that thing in
let container = document.getElementById("puzzles")
while (container.firstChild) {
container.firstChild.remove()
}
container.appendChild(puzzlesElement)
}
function heartbeat(teamId) {
let fd = new FormData()
fd.append("id", teamId)
fetch("puzzles.json", {
method: "POST",
body: fd,
})
.then(resp => {
if (resp.ok) {
resp.json()
.then(renderPuzzles)
.catch(err => {
toast("Error fetching recent puzzles. I'll try again in a moment.")
console.log(err)
})
}
})
.catch(err => {
toast("Error fetching recent puzzles. I'll try again in a moment.")
console.log(err)
})
}
function showPuzzles(teamId) {
let spinner = document.createElement("span")
spinner.classList.add("spinner")
sessionStorage.setItem("id", teamId)
document.getElementById("login").style.display = "none"
document.getElementById("puzzles").appendChild(spinner)
heartbeat(teamId)
setInterval(e => { heartbeat(teamId) }, 40000)
}
function login(e) {
e.preventDefault()
let name = document.querySelector("[name=name]").value
let id = document.querySelector("[name=id]").value
fetch("register", {
method: "POST",
body: new FormData(e.target),
})
.then(resp => {
if (resp.ok) {
resp.json()
.then(obj => {
if (obj.status == "success") {
toast("Team registered")
showPuzzles(id)
} else if (obj.data.short == "Already registered") {
toast("Logged in with previously-registered team name")
showPuzzles(id)
} else {
toast(obj.data.description)
}
})
.catch(err => {
toast("Oops, the server has lost its mind. You probably need to tell someone so they can fix it.")
console.log(err, resp)
})
} else {
toast("Oops, something's wrong with the server. Try again in a few seconds.")
console.log(resp)
}
})
.catch(err => {
toast("Oops, something went wrong. Try again in a few seconds.")
console.log(err)
})
}
function init() {
// Already signed in?
let id = sessionStorage.getItem("id")
if (id) {
showPuzzles(id)
}
document.getElementById("login").addEventListener("submit", login)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}

View File

@ -1,100 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Open Puzzles</title>
<link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width">
<meta charset="utf-8">
<script>
function render(obj) {
puzzlesElement = document.createElement('div');
// Create a sorted list of category names
let cats = Object.keys(obj);
cats.sort();
for (let cat of cats) {
if (cat.startsWith("__")) {
// Metadata or something
continue;
}
let puzzles = obj[cat];
let pdiv = document.createElement('div');
pdiv.className = 'category';
let h = document.createElement('h2');
pdiv.appendChild(h);
h.textContent = cat;
// Extras if we're running a devel server
if (obj.__devel__) {
var a = document.createElement('a');
h.insertBefore(a, h.firstChild);
a.textContent = "⬇️";
a.href = "mothballer/" + cat;
a.classList.add("mothball");
a.title = "Download a compiled puzzle for this category";
}
let l = document.createElement('ul');
pdiv.appendChild(l);
for (var puzzle of puzzles) {
var points = puzzle[0];
var id = puzzle[1];
var i = document.createElement('li');
l.appendChild(i);
i.textContent = " ";
if (points === 0) {
// Sentry: there are no more puzzles in this category
i.textContent = "✿";
} else {
var a = document.createElement('a');
i.appendChild(a);
a.textContent = points;
a.href = "puzzle.html?cat=" + cat + "&points=" + points + "&pid=" + id;
}
}
puzzlesElement.appendChild(pdiv);
document.getElementById("puzzles").appendChild(puzzlesElement);
}
}
function init() {
fetch("puzzles.json")
.then(resp => {
return resp.json();
})
.then(obj => {
render(obj);
})
.catch(err => {
console.log("Error", err);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
</script>
</head>
<body>
<h1 class="Success">Open Puzzles</h1>
<section>
<div id="puzzles"></div>
</section>
<nav>
<ul>
<li><a href="puzzle-list.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body>
</html>

View File

@ -5,96 +5,30 @@
<link rel="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<meta charset="utf-8"> <meta charset="utf-8">
<script src="devel.js"></script> <script src="puzzle.js"></script>
<script> <script>
function init() {
let params = new URLSearchParams(window.location.search);
let categoryName = params.get("cat");
let points = params.get("points");
let puzzleId = params.get("pid");
let puzzle = document.getElementById("puzzle");
let base = "content/" + categoryName + "/" + puzzleId + "/";
fetch(base + "puzzle.json")
.then(resp => {
return resp.json();
})
.then(obj => {
// Populate authors
document.getElementById("authors").textContent = obj.authors.join(", ");
// If answers are provided, this is the devel server
if (obj.answers) {
devel_addin(obj, document.getElementById("devel"));
}
// Load scripts
for (let script of obj.scripts) {
let st = document.createElement("script");
document.head.appendChild(st);
st.src = base + script;
}
// List associated files
for (let fn of obj.files) {
let li = document.createElement("li");
let a = document.createElement("a");
a.href = base + fn;
a.innerText = fn;
li.appendChild(a);
document.getElementById("files").appendChild(li);
}
// Prefix `base` to relative URLs in the puzzle body
let doc = new DOMParser().parseFromString(obj.body, "text/html");
for (let se of doc.querySelectorAll("[src],[href]")) {
se.outerHTML = se.outerHTML.replace(/(src|href)="([^/]+)"/i, "$1=\"" + base + "$2\"");
}
// Replace puzzle children with what's in `doc`
Array.from(puzzle.childNodes).map(e => e.remove());
Array.from(doc.body.childNodes).map(e => puzzle.appendChild(e));
})
.catch(err => {
// Show error to the user
Array.from(puzzle.childNodes).map(e => e.remove());
let p = document.createElement("p");
puzzle.appendChild(p);
p.classList.add("Error");
p.textContent = err;
});
document.title = categoryName + " " + points;
document.querySelector("body > h1").innerText = document.title;
document.querySelector("input[name=cat]").value = categoryName;
document.querySelector("input[name=points]").value = points;
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
</script> </script>
</head> </head>
<body> <body>
<h1>Puzzle</h1> <h1>Puzzle</h1>
<section> <section>
<div id="puzzle">Loading...</div> <div id="puzzle"><span class="spinner"></span></div>
<ul id="files"></ul> <ul id="files"></ul>
<p>Puzzle by <span id="authors"></span></p> <p>Puzzle by <span id="authors"></span></p>
</section> </section>
<form action="answer" method="post"> <div id="messages"></div>
<form>
<input type="hidden" name="cat"> <input type="hidden" name="cat">
<input type="hidden" name="points"> <input type="hidden" name="points">
Team ID: <input type="text" name="id"> <br> Team ID: <input type="text" name="id"> <br>
Answer: <input type="text" name="answer" id="answer"> <br> Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br>
<input type="submit" value="Submit"> <input type="submit" value="Submit">
</form> </form>
<div id="devel"></div> <div id="devel"></div>
<nav> <nav>
<ul> <ul>
<li><a href="puzzle-list.html">Puzzles</a></li> <li><a href="index.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li> <li><a href="scoreboard.html">Scoreboard</a></li>
</ul> </ul>
</nav> </nav>

219
theme/puzzle.js Normal file
View File

@ -0,0 +1,219 @@
// devel_addin drops a bunch of development extensions into element e.
// It will only modify stuff inside e.
function devel_addin(obj, e) {
let h = document.createElement("h2")
e.appendChild(h)
h.textContent = "Development Options"
let g = document.createElement("p")
e.appendChild(g)
g.innerText = "This section will not appear for participants."
let keys = Object.keys(obj)
keys.sort()
for (let key of keys) {
switch (key) {
case "body":
continue
}
let d = document.createElement("div")
e.appendChild(d)
d.classList.add("kvpair")
let ktxt = document.createElement("span")
d.appendChild(ktxt)
ktxt.textContent = key
let val = obj[key]
if (Array.isArray(val)) {
let vi = document.createElement("select")
d.appendChild(vi)
vi.multiple = true
for (let a of val) {
let opt = document.createElement("option")
vi.appendChild(opt)
opt.innerText = a
}
} else {
let vi = document.createElement("input")
d.appendChild(vi)
vi.value = val
vi.disabled = true
}
}
}
// The routine used to hash answers in compiled puzzle packages
function djb2hash(buf) {
let h = 5381
for (let c of (new TextEncoder).encode(buf)) { // Encode as UTF-8 and read in each byte
// JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
// So we have to do "unsigned right shift" by zero to get it back to unsigned.
h = (((h * 33) + c) & 0xffffffff) >>> 0
}
return h
}
// Pop up a message
function toast(message, timeout=5000) {
let p = document.createElement("p")
p.innerText = message
document.getElementById("messages").appendChild(p)
setTimeout(
e => { p.remove() },
timeout
)
}
// When the user submits an answer
function submit(e) {
e.preventDefault()
fetch("answer", {
method: "POST",
body: new FormData(e.target),
})
.then(resp => {
if (resp.ok) {
resp.json()
.then(obj => {
toast(obj.data.description)
})
} else {
toast("Error submitting your answer. Try again in a few seconds.")
console.log(resp)
}
})
.catch(err => {
toast("Error submitting your answer. Try again in a few seconds.")
console.log(err)
})
}
function loadPuzzle(categoryName, points, puzzleId) {
let puzzle = document.getElementById("puzzle")
let base = "content/" + categoryName + "/" + puzzleId + "/"
fetch(base + "puzzle.json")
.then(resp => {
return resp.json()
})
.then(obj => {
// Populate authors
document.getElementById("authors").textContent = obj.authors.join(", ")
// Make the whole puzzle available
window.puzzle = obj
// If answers are provided, this is the devel server
if (obj.answers) {
devel_addin(obj, document.getElementById("devel"))
}
// Load scripts
for (let script of obj.scripts) {
let st = document.createElement("script")
document.head.appendChild(st)
st.src = base + script
}
// List associated files
for (let fn of obj.files) {
let li = document.createElement("li")
let a = document.createElement("a")
a.href = base + fn
a.innerText = fn
li.appendChild(a)
document.getElementById("files").appendChild(li)
}
// Prefix `base` to relative URLs in the puzzle body
let doc = new DOMParser().parseFromString(obj.body, "text/html")
for (let se of doc.querySelectorAll("[src],[href]")) {
se.outerHTML = se.outerHTML.replace(/(src|href)="([^/]+)"/i, "$1=\"" + base + "$2\"")
}
// If a validation pattern was provided, set that
if (obj.pattern) {
document.querySelector("#answer").pattern = obj.pattern
}
// Replace puzzle children with what's in `doc`
Array.from(puzzle.childNodes).map(e => e.remove())
Array.from(doc.body.childNodes).map(e => puzzle.appendChild(e))
})
.catch(err => {
// Show error to the user
Array.from(puzzle.childNodes).map(e => e.remove())
let p = document.createElement("p")
puzzle.appendChild(p)
p.classList.add("Error")
p.textContent = err
})
document.title = categoryName + " " + points
document.querySelector("body > h1").innerText = document.title
document.querySelector("input[name=cat]").value = categoryName
document.querySelector("input[name=points]").value = points
}
// Check to see if the answer might be correct
// This might be better done with the "constraint validation API"
// https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation#Validating_forms_using_JavaScript
function answerCheck(e) {
let answer = e.target.value
let ok = document.querySelector("#answer_ok")
// You have to provide someplace to put the check
if (! ok) {
return
}
let possiblyCorrect = false
let answerHash = djb2hash(answer)
for (let correctHash of window.puzzle.hashes) {
if (correctHash == answerHash) {
possiblyCorrect = true
}
}
if (possiblyCorrect) {
ok.textContent = "🙆"
ok.title = "Possibly correct"
} else {
ok.textContent = "🙅"
ok.title = "Definitely not correct"
}
}
function init() {
let params = new URLSearchParams(window.location.search)
let categoryName = params.get("cat")
let points = params.get("points")
let puzzleId = params.get("pid")
if (categoryName && points && puzzleId) {
loadPuzzle(categoryName, points, puzzleId)
}
let teamId = sessionStorage.getItem("id")
if (teamId) {
document.querySelector("input[name=id]").value = teamId
}
if (document.querySelector("#answer")) {
document.querySelector("#answer").addEventListener("input", answerCheck)
}
document.querySelector("form").addEventListener("submit", submit)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

View File

@ -128,7 +128,7 @@ if (document.readyState === "loading") {
</section> </section>
<nav> <nav>
<ul> <ul>
<li><a href="puzzle-list.html">Puzzles</a></li> <li><a href="index.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li> <li><a href="scoreboard.html">Scoreboard</a></li>
</ul> </ul>
</nav> </nav>

View File

@ -4,28 +4,30 @@
<title>Redeem Token</title> <title>Redeem Token</title>
<link rel="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<script src="puzzle.js"></script>
<script> <script>
function erpdert() { function tokenInput(e) {
let vals = document.querySelector("[name=token]").value.split(":"); let vals = e.target.value.split(":")
document.querySelector("[name=cat]").value = vals[0]; document.querySelector("input[name=cat]").value = vals[0]
document.querySelector("[name=points]").value = vals[1]; document.querySelector("input[name=points]").value = vals[1]
document.querySelector("[name=answer]").value = vals[2]; document.querySelector("input[name=answer]").value = vals[2]
} }
function init() { function tokenInit() {
document.querySelector("[name=token]").addEventListener("input", erpdert); document.querySelector("input[name=token]").addEventListener("input", tokenInput)
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init); document.addEventListener("DOMContentLoaded", tokenInit)
} else { } else {
init(); tokenInit()
} }
</script> </script>
</head> </head>
<body> <body>
<h1>Redeem Token</h1> <h1>Redeem Token</h1>
<form action="token" method="post"> <div id="messages"></div>
<form id="tokenForm">
<input type="hidden" name="cat"> <input type="hidden" name="cat">
<input type="hidden" name="points"> <input type="hidden" name="points">
<input type="hidden" name="answer"> <input type="hidden" name="answer">