mirror of https://github.com/dirtbags/moth.git
Merge pull request #57 from dirtbags/auth
Authenticate puzzle list + check answers
This commit is contained in:
commit
0ff3679b2c
|
@ -1,7 +1,9 @@
|
|||
FROM alpine:3.8 AS builder
|
||||
RUN apk --no-cache add go libc-dev
|
||||
COPY src /src
|
||||
RUN go build -o /mothd /src/*.go
|
||||
FROM alpine:3.9 AS builder
|
||||
RUN apk --no-cache add go libc-dev git
|
||||
COPY src /root/go/src/github.com/dirtbags/moth/src
|
||||
WORKDIR /root/go/src/github.com/dirtbags/moth/src
|
||||
RUN go get .
|
||||
RUN go build -o /mothd *.go
|
||||
|
||||
FROM alpine
|
||||
COPY --from=builder /mothd /mothd
|
||||
|
|
|
@ -29,6 +29,36 @@ def get_seed(request):
|
|||
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):
|
||||
seed = get_seed(request)
|
||||
puzzles = {
|
||||
|
@ -51,7 +81,7 @@ async def handle_puzzlelist(request):
|
|||
|
||||
async def handle_puzzle(request):
|
||||
seed = get_seed(request)
|
||||
category = request.match_info.get("category")
|
||||
category = request.match_info.get("cat")
|
||||
points = int(request.match_info.get("points"))
|
||||
cat = moth.Category(request.app["puzzles_dir"].joinpath(category), seed)
|
||||
puzzle = cat.puzzle(points)
|
||||
|
@ -70,7 +100,7 @@ async def handle_puzzle(request):
|
|||
|
||||
async def handle_puzzlefile(request):
|
||||
seed = get_seed(request)
|
||||
category = request.match_info.get("category")
|
||||
category = request.match_info.get("cat")
|
||||
points = int(request.match_info.get("points"))
|
||||
filename = request.match_info.get("filename")
|
||||
cat = moth.Category(request.app["puzzles_dir"].joinpath(category), seed)
|
||||
|
@ -87,10 +117,9 @@ async def handle_puzzlefile(request):
|
|||
content_type=content_type,
|
||||
)
|
||||
|
||||
|
||||
async def handle_mothballer(request):
|
||||
seed = get_seed(request)
|
||||
category = request.match_info.get("category")
|
||||
category = request.match_info.get("cat")
|
||||
|
||||
try:
|
||||
catdir = request.app["puzzles_dir"].joinpath(category)
|
||||
|
@ -113,19 +142,37 @@ async def handle_index(request):
|
|||
seed = random.getrandbits(32)
|
||||
body = """<!DOCTYPE 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>
|
||||
<h1>Dev Server</h1>
|
||||
|
||||
<p>
|
||||
You need to provide the contest seed in the URL.
|
||||
If you don't have a contest seed in mind,
|
||||
why not try <a href="{seed}/">{seed}</a>?
|
||||
Pick a seed:
|
||||
</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>
|
||||
If you are chaotic,
|
||||
you could even take your chances with a
|
||||
<a href="random/">random seed</a> for every HTTP request.
|
||||
This means generated files will get a different seed than the puzzle itself!
|
||||
Puzzles can be generated from Python code: these puzzles can use a random number generator if they want.
|
||||
The seed is used to create these random numbers.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -140,11 +187,7 @@ async def handle_static(request):
|
|||
themes = request.app["theme_dir"]
|
||||
fn = request.match_info.get("filename")
|
||||
if not fn:
|
||||
for fn in ("puzzle-list.html", "index.html"):
|
||||
path = themes.joinpath(fn)
|
||||
if path.exists():
|
||||
break
|
||||
else:
|
||||
fn = "index.html"
|
||||
path = themes.joinpath(fn)
|
||||
return web.FileResponse(path)
|
||||
|
||||
|
@ -182,9 +225,10 @@ if __name__ == '__main__':
|
|||
app["puzzles_dir"] = pathlib.Path(args.puzzles)
|
||||
app["theme_dir"] = pathlib.Path(args.theme)
|
||||
app.router.add_route("GET", "/", handle_index)
|
||||
app.router.add_route("GET", "/{seed}/puzzles.json", handle_puzzlelist)
|
||||
app.router.add_route("GET", "/{seed}/content/{category}/{points}/puzzle.json", handle_puzzle)
|
||||
app.router.add_route("GET", "/{seed}/content/{category}/{points}/{filename}", handle_puzzlefile)
|
||||
app.router.add_route("GET", "/{seed}/mothballer/{category}", handle_mothballer)
|
||||
app.router.add_route("*", "/{seed}/answer", handle_answer)
|
||||
app.router.add_route("*", "/{seed}/puzzles.json", handle_puzzlelist)
|
||||
app.router.add_route("GET", "/{seed}/content/{cat}/{points}/puzzle.json", handle_puzzle)
|
||||
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)
|
||||
web.run_app(app, host=addr, port=port)
|
||||
|
|
|
@ -15,9 +15,9 @@ import tempfile
|
|||
|
||||
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
|
||||
def djb2hash(buf):
|
||||
def djb2hash(str):
|
||||
h = 5381
|
||||
for c in buf:
|
||||
for c in str.encode("utf-8"):
|
||||
h = ((h * 33) + c) & 0xffffffff
|
||||
return h
|
||||
|
||||
|
@ -75,6 +75,7 @@ class Puzzle:
|
|||
self.authors = []
|
||||
self.answers = []
|
||||
self.scripts = []
|
||||
self.pattern = None
|
||||
self.hint = None
|
||||
self.files = {}
|
||||
self.body = io.StringIO()
|
||||
|
@ -104,6 +105,8 @@ class Puzzle:
|
|||
self.summary = val
|
||||
elif key == 'answer':
|
||||
self.answers.append(val)
|
||||
elif key == 'pattern':
|
||||
self.pattern = val
|
||||
elif key == 'hint':
|
||||
self.hint = val
|
||||
elif key == 'name':
|
||||
|
@ -271,13 +274,14 @@ class Puzzle:
|
|||
'hashes': self.hashes(),
|
||||
'files': files,
|
||||
'scripts': self.scripts,
|
||||
'pattern': self.pattern,
|
||||
'body': self.html_body(),
|
||||
}
|
||||
|
||||
def hashes(self):
|
||||
"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:
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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>
|
170
src/handlers.go
170
src/handlers.go
|
@ -12,33 +12,26 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// https://github.com/omniti-labs/jsend
|
||||
type JSend struct {
|
||||
Status string `json:"status"`
|
||||
Data JSendData `json:"data"`
|
||||
}
|
||||
type JSendData struct {
|
||||
Data struct {
|
||||
Short string `json:"short"`
|
||||
Description string `json:"description"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// ShowJSend renders a JSend response to w
|
||||
func ShowJSend(w http.ResponseWriter, status Status, short string, description string) {
|
||||
const (
|
||||
JSendSuccess = "success"
|
||||
JSendFail = "fail"
|
||||
JSendError = "error"
|
||||
)
|
||||
|
||||
resp := JSend{
|
||||
Status: "success",
|
||||
Data: JSendData{
|
||||
Short: short,
|
||||
Description: description,
|
||||
},
|
||||
}
|
||||
switch status {
|
||||
case Success:
|
||||
resp.Status = "success"
|
||||
case Fail:
|
||||
resp.Status = "fail"
|
||||
default:
|
||||
resp.Status = "error"
|
||||
}
|
||||
func respond(w http.ResponseWriter, req *http.Request, status string, short string, format string, a ...interface{}) {
|
||||
resp := JSend{}
|
||||
resp.Status = status
|
||||
resp.Data.Short = short
|
||||
resp.Data.Description = fmt.Sprintf(format, a...)
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
|
@ -51,59 +44,6 @@ func ShowJSend(w http.ResponseWriter, status Status, short string, description s
|
|||
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.
|
||||
// The entire line must match.
|
||||
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 == "") {
|
||||
respond(
|
||||
w, req, Fail,
|
||||
w, req, JSendFail,
|
||||
"Invalid Entry",
|
||||
"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"))
|
||||
if err != nil {
|
||||
respond(
|
||||
w, req, Fail,
|
||||
w, req, JSendFail,
|
||||
"Cannot read valid team IDs",
|
||||
"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()
|
||||
if !hasLine(teamids, teamid) {
|
||||
respond(
|
||||
w, req, Fail,
|
||||
w, req, JSendFail,
|
||||
"Invalid Team ID",
|
||||
"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)
|
||||
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)
|
||||
respond(
|
||||
w, req, Fail,
|
||||
w, req, JSendFail,
|
||||
"Registration failed",
|
||||
"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()
|
||||
fmt.Fprintln(f, teamname)
|
||||
respond(
|
||||
w, req, Success,
|
||||
w, req, JSendSuccess,
|
||||
"Team registered",
|
||||
"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)
|
||||
if err != nil {
|
||||
respond(
|
||||
w, req, Fail,
|
||||
w, req, JSendFail,
|
||||
"Cannot parse point value",
|
||||
"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")
|
||||
if err != nil {
|
||||
respond(
|
||||
w, req, Fail,
|
||||
w, req, JSendFail,
|
||||
"Cannot list answers",
|
||||
"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)
|
||||
if !hasLine(haystack, needle) {
|
||||
respond(
|
||||
w, req, Fail,
|
||||
w, req, JSendFail,
|
||||
"Wrong answer",
|
||||
"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 {
|
||||
respond(
|
||||
w, req, Error,
|
||||
w, req, JSendError,
|
||||
"Cannot award points",
|
||||
"The answer is correct, but there was an error awarding points: %v", err.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
respond(
|
||||
w, req, Success,
|
||||
w, req, JSendSuccess,
|
||||
"Points awarded",
|
||||
fmt.Sprintf("%d points for %s!", points, teamid),
|
||||
)
|
||||
}
|
||||
|
||||
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.WriteHeader(http.StatusOK)
|
||||
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)
|
||||
}
|
||||
|
||||
func (ctx *Instance) BindHandlers(mux *http.ServeMux) {
|
||||
mux.HandleFunc(ctx.Base+"/", ctx.staticHandler)
|
||||
mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler)
|
||||
mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler)
|
||||
mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler)
|
||||
mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler)
|
||||
mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler)
|
||||
type FurtiveResponseWriter struct {
|
||||
w http.ResponseWriter
|
||||
statusCode *int
|
||||
}
|
||||
|
||||
func (w FurtiveResponseWriter) WriteHeader(statusCode int) {
|
||||
*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)
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import (
|
|||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
@ -21,6 +23,7 @@ type Instance struct {
|
|||
update chan bool
|
||||
jPuzzleList []byte
|
||||
jPointsLog []byte
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) {
|
||||
|
@ -31,6 +34,7 @@ func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, e
|
|||
ResourcesDir: resourcesDir,
|
||||
Categories: map[string]*Mothball{},
|
||||
update: make(chan bool, 10),
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
ctx.BindHandlers()
|
||||
ctx.MaybeInitialize()
|
||||
|
||||
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() {
|
||||
// Only do this if it hasn't already been done
|
||||
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
|
||||
if f, err := os.OpenFile(ctx.StatePath("teamids.txt"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
|
||||
defer f.Close()
|
||||
for i := 0; i <= 9999; i += 1 {
|
||||
fmt.Fprintf(f, "%04d\n", i)
|
||||
for i := 0; i <= 100; i += 1 {
|
||||
fmt.Fprintln(f, mktoken())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -83,18 +100,31 @@ func (ctx *Instance) MaybeInitialize() {
|
|||
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 {
|
||||
tail := path.Join(parts...)
|
||||
tail := pathCleanse(parts)
|
||||
return path.Join(ctx.MothballDir, tail)
|
||||
}
|
||||
|
||||
func (ctx *Instance) StatePath(parts ...string) string {
|
||||
tail := path.Join(parts...)
|
||||
tail := pathCleanse(parts)
|
||||
return path.Join(ctx.StateDir, tail)
|
||||
}
|
||||
|
||||
func (ctx *Instance) ResourcePath(parts ...string) string {
|
||||
tail := path.Join(parts...)
|
||||
tail := pathCleanse(parts)
|
||||
return path.Join(ctx.ResourcesDir, tail)
|
||||
}
|
||||
|
||||
|
|
14
src/mothd.go
14
src/mothd.go
|
@ -1,21 +1,16 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"github.com/namsral/flag"
|
||||
"log"
|
||||
"math/rand"
|
||||
"mime"
|
||||
"net/http"
|
||||
"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 {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -60,7 +55,6 @@ func main() {
|
|||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
ctx.BindHandlers(http.DefaultServeMux)
|
||||
|
||||
// Add some MIME extensions
|
||||
// Doing this avoids decompressing a mothball entry twice per request
|
||||
|
@ -70,5 +64,5 @@ func main() {
|
|||
go ctx.Maintenance(*maintenanceInterval)
|
||||
|
||||
log.Printf("Listening on %s", *listen)
|
||||
log.Fatal(http.ListenAndServe(*listen, logRequest(http.DefaultServeMux)))
|
||||
log.Fatal(http.ListenAndServe(*listen, ctx))
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ h1 {
|
|||
background: #5e576b;
|
||||
color: #9e98a8;
|
||||
}
|
||||
.Fail, .Error {
|
||||
.Fail, .Error, #messages {
|
||||
background: #3a3119;
|
||||
color: #ffcc98;
|
||||
}
|
||||
|
@ -50,6 +50,13 @@ iframe#body {
|
|||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
input:invalid {
|
||||
border-color: red;
|
||||
}
|
||||
#messages {
|
||||
min-height: 3em;
|
||||
border: solid black 2px;
|
||||
}
|
||||
#scoreboard {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
@ -87,3 +94,25 @@ img {
|
|||
.kvpair {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,31 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome</title>
|
||||
<link rel="stylesheet" href="basic.css">
|
||||
<title>MOTH</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<link rel="stylesheet" href="basic.css">
|
||||
<script src="moth.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome</h1>
|
||||
<h1 id="title">MOTH</h1>
|
||||
<section>
|
||||
<h2>Register your team</h2>
|
||||
<div id="messages"></div>
|
||||
|
||||
<form action="register" method="post">
|
||||
Team ID: <input name="id"> <br>
|
||||
<form id="login">
|
||||
Team name: <input name="name">
|
||||
<input type="submit" value="Register">
|
||||
Team ID: <input name="id"> <br>
|
||||
<input type="submit" value="Sign In">
|
||||
</form>
|
||||
|
||||
<p>
|
||||
If someone on your team has already registered,
|
||||
proceed to the
|
||||
<a href="puzzle-list.html">puzzles overview</a>.
|
||||
</p>
|
||||
<div id="puzzles"></div>
|
||||
|
||||
</section>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="puzzle-list.html">Puzzles</a></li>
|
||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||
<li><a href="logout.html">Sign Out</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</body>
|
||||
|
|
|
@ -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>
|
|
@ -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();
|
||||
}
|
|
@ -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>
|
|
@ -5,96 +5,30 @@
|
|||
<link rel="stylesheet" href="basic.css">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta charset="utf-8">
|
||||
<script src="devel.js"></script>
|
||||
<script src="puzzle.js"></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>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Puzzle</h1>
|
||||
<section>
|
||||
<div id="puzzle">Loading...</div>
|
||||
<div id="puzzle"><span class="spinner"></span></div>
|
||||
<ul id="files"></ul>
|
||||
<p>Puzzle by <span id="authors"></span></p>
|
||||
</section>
|
||||
<form action="answer" method="post">
|
||||
<div id="messages"></div>
|
||||
<form>
|
||||
<input type="hidden" name="cat">
|
||||
<input type="hidden" name="points">
|
||||
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">
|
||||
</form>
|
||||
<div id="devel"></div>
|
||||
<nav>
|
||||
<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>
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -128,7 +128,7 @@ if (document.readyState === "loading") {
|
|||
</section>
|
||||
<nav>
|
||||
<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>
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
@ -4,28 +4,30 @@
|
|||
<title>Redeem Token</title>
|
||||
<link rel="stylesheet" href="basic.css">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<script src="puzzle.js"></script>
|
||||
<script>
|
||||
function erpdert() {
|
||||
let vals = document.querySelector("[name=token]").value.split(":");
|
||||
document.querySelector("[name=cat]").value = vals[0];
|
||||
document.querySelector("[name=points]").value = vals[1];
|
||||
document.querySelector("[name=answer]").value = vals[2];
|
||||
function tokenInput(e) {
|
||||
let vals = e.target.value.split(":")
|
||||
document.querySelector("input[name=cat]").value = vals[0]
|
||||
document.querySelector("input[name=points]").value = vals[1]
|
||||
document.querySelector("input[name=answer]").value = vals[2]
|
||||
}
|
||||
|
||||
function init() {
|
||||
document.querySelector("[name=token]").addEventListener("input", erpdert);
|
||||
function tokenInit() {
|
||||
document.querySelector("input[name=token]").addEventListener("input", tokenInput)
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
document.addEventListener("DOMContentLoaded", tokenInit)
|
||||
} else {
|
||||
init();
|
||||
tokenInit()
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<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="points">
|
||||
<input type="hidden" name="answer">
|
||||
|
|
Loading…
Reference in New Issue