diff --git a/Dockerfile.moth b/Dockerfile.moth index 23a16da..b7bfd37 100644 --- a/Dockerfile.moth +++ b/Dockerfile.moth @@ -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 diff --git a/devel/devel-server.py b/devel/devel-server.py index af0679b..2be469a 100755 --- a/devel/devel-server.py +++ b/devel/devel-server.py @@ -28,6 +28,36 @@ def get_seed(request): else: 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) @@ -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 = """ - Dev Server + + Dev Server + +

Dev Server

+

- You need to provide the contest seed in the URL. - If you don't have a contest seed in mind, - why not try {seed}? + Pick a seed:

+ +

- If you are chaotic, - you could even take your chances with a - random seed 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. +

+ +

+ 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.

@@ -140,12 +187,8 @@ 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: - path = themes.joinpath(fn) + 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) diff --git a/devel/moth.py b/devel/moth.py index 9820783..6a62116 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -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: diff --git a/example-puzzles/example/5/helpers.js b/example-puzzles/example/5/helpers.js new file mode 100644 index 0000000..29ce813 --- /dev/null +++ b/example-puzzles/example/5/helpers.js @@ -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(); +} diff --git a/example-puzzles/example/5/puzzle.moth b/example-puzzles/example/5/puzzle.moth new file mode 100644 index 0000000..9c3be6b --- /dev/null +++ b/example-puzzles/example/5/puzzle.moth @@ -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 + + +All lower-case letters + + +Multiple concatenated values +
+ + + + +
+ +Select from an ordered list of options + diff --git a/src/handlers.go b/src/handlers.go index 3d3984b..d4674d7 100644 --- a/src/handlers.go +++ b/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 { - Short string `json:"short"` - Description string `json:"description"` + Status string `json:"status"` + 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, "") - fmt.Fprintf(w, "\n") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "%s", title) - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") - fmt.Fprintf(w, "

%s

", statusStr, title) - fmt.Fprintf(w, "
%s
", body) - fmt.Fprintf(w, "") - fmt.Fprintf(w, "") -} - -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) } diff --git a/src/instance.go b/src/instance.go index 2794768..6f5dd95 100644 --- a/src/instance.go +++ b/src/instance.go @@ -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) } diff --git a/src/mothd.go b/src/mothd.go index 5834f24..ef08832 100644 --- a/src/mothd.go +++ b/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)) } diff --git a/theme/basic.css b/theme/basic.css index 2ee7b34..9614028 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -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; @@ -86,4 +93,26 @@ img { } .kvpair { border: solid black 2px; -} \ No newline at end of file +} + +.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); + } +} diff --git a/theme/devel.js b/theme/devel.js deleted file mode 100644 index 6abe921..0000000 --- a/theme/devel.js +++ /dev/null @@ -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; - } - } -} \ No newline at end of file diff --git a/theme/index.html b/theme/index.html index 51ad704..89bdf48 100644 --- a/theme/index.html +++ b/theme/index.html @@ -1,31 +1,30 @@ - Welcome - + MOTH + + + -

Welcome

+

MOTH

-

Register your team

- -
- Team ID:
+
+ + Team name: - + Team ID:
+
- -

- If someone on your team has already registered, - proceed to the - puzzles overview. -

+ +
+
diff --git a/theme/logout.html b/theme/logout.html new file mode 100644 index 0000000..14f3caf --- /dev/null +++ b/theme/logout.html @@ -0,0 +1,23 @@ + + + + MOTH + + + + + +

MOTH

+
+

Okay, you've been logged out.

+
+ + + diff --git a/theme/moth.js b/theme/moth.js new file mode 100644 index 0000000..2522bc8 --- /dev/null +++ b/theme/moth.js @@ -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(); +} diff --git a/theme/puzzle-list.html b/theme/puzzle-list.html deleted file mode 100644 index af42db5..0000000 --- a/theme/puzzle-list.html +++ /dev/null @@ -1,100 +0,0 @@ - - - - Open Puzzles - - - - - - -

Open Puzzles

-
-
-
- - - diff --git a/theme/puzzle.html b/theme/puzzle.html index 5aa19b9..2a8dcba 100644 --- a/theme/puzzle.html +++ b/theme/puzzle.html @@ -5,96 +5,30 @@ - +

Puzzle

-
Loading...
+

Puzzle by

-
+
+ Team ID:
- Answer:
+ Answer:
diff --git a/theme/puzzle.js b/theme/puzzle.js new file mode 100644 index 0000000..eafda60 --- /dev/null +++ b/theme/puzzle.js @@ -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() +} + diff --git a/theme/scoreboard.html b/theme/scoreboard.html index db6531a..fcbbc5b 100644 --- a/theme/scoreboard.html +++ b/theme/scoreboard.html @@ -128,7 +128,7 @@ if (document.readyState === "loading") { diff --git a/theme/token.html b/theme/token.html index 44d845a..80d6542 100644 --- a/theme/token.html +++ b/theme/token.html @@ -4,28 +4,30 @@ Redeem Token +

Redeem Token

-
+
+