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
|
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
|
||||||
|
|
|
@ -28,6 +28,36 @@ def get_seed(request):
|
||||||
else:
|
else:
|
||||||
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)
|
||||||
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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>
|
176
src/handlers.go
176
src/handlers.go
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
14
src/mothd.go
14
src/mothd.go
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
@ -86,4 +93,26 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
<!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>
|
||||||
|
|
|
@ -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">
|
<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>
|
||||||
|
|
|
@ -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>
|
</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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
Loading…
Reference in New Issue