From e68a041f3367196db1678acb63648f30216938aa Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 21 Feb 2019 23:00:06 +0000 Subject: [PATCH 01/15] Add basic authentication and remove legacy html responses --- Dockerfile.moth | 10 ++-- src/handlers.go | 121 +++++++++++++++++++++++------------------------- src/instance.go | 10 +++- src/mothd.go | 19 ++++---- 4 files changed, 81 insertions(+), 79 deletions(-) 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/src/handlers.go b/src/handlers.go index 3d3984b..b60b760 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -21,14 +21,20 @@ type JSendData struct { Description string `json:"description"` } -// ShowJSend renders a JSend response to w -func ShowJSend(w http.ResponseWriter, status Status, short string, description string) { +type Status int +const ( + Success = iota + Fail + Error +) + +func respond(w http.ResponseWriter, req *http.Request, status Status, short string, format string, a ...interface{}) { resp := JSend{ Status: "success", Data: JSendData{ Short: short, - Description: description, + Description: fmt.Sprintf(format, a...), }, } switch status { @@ -51,59 +57,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 { @@ -301,11 +254,53 @@ 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), + } + w.Header().Set("WWW-Authenticate", "Basic") + _, password, _ := r.BasicAuth() + if password != ctx.Password { + http.Error(w, "Authentication Required", 401) + } else { + 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..5b6cb61 100644 --- a/src/instance.go +++ b/src/instance.go @@ -6,6 +6,7 @@ import ( "io" "io/ioutil" "log" + "net/http" "os" "path" "strings" @@ -17,20 +18,24 @@ type Instance struct { MothballDir string StateDir string ResourcesDir string + Password string Categories map[string]*Mothball update chan bool jPuzzleList []byte jPointsLog []byte + mux *http.ServeMux } -func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) { +func NewInstance(base, mothballDir, stateDir, resourcesDir, password string) (*Instance, error) { ctx := &Instance{ Base: strings.TrimRight(base, "/"), MothballDir: mothballDir, StateDir: stateDir, ResourcesDir: resourcesDir, + Password: password, Categories: map[string]*Mothball{}, update: make(chan bool, 10), + mux: http.NewServeMux(), } // Roll over and die if directories aren't even set up @@ -40,7 +45,8 @@ func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, e if _, err := os.Stat(stateDir); err != nil { return nil, err } - + + ctx.BindHandlers() ctx.MaybeInitialize() return ctx, nil diff --git a/src/mothd.go b/src/mothd.go index 5834f24..5f7fef5 100644 --- a/src/mothd.go +++ b/src/mothd.go @@ -1,24 +1,19 @@ package main import ( - "flag" + "github.com/namsral/flag" "log" "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 { return nil } + func main() { base := flag.String( "base", @@ -40,6 +35,11 @@ func main() { "/theme", "Path to static theme resources (HTML, images, css, ...)", ) + password := flag.String( + "password", + "sesame", + "Pass Word (in the 1920s sense) to view the site. Not a secure passphrase.", + ) maintenanceInterval := flag.Duration( "maint", 20*time.Second, @@ -56,11 +56,10 @@ func main() { log.Fatal(err) } - ctx, err := NewInstance(*base, *mothballDir, *stateDir, *themeDir) + ctx, err := NewInstance(*base, *mothballDir, *stateDir, *themeDir, *password) 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 +69,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)) } From 45c0cad5d432c99e87982d0fd4e4aa1649159bfe Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 21 Feb 2019 19:38:53 -0700 Subject: [PATCH 02/15] Better token generation --- src/instance.go | 17 +++++++++++++++-- src/mothd.go | 2 ++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/instance.go b/src/instance.go index 5b6cb61..195c8c3 100644 --- a/src/instance.go +++ b/src/instance.go @@ -11,6 +11,7 @@ import ( "path" "strings" "time" + "math/rand" ) type Instance struct { @@ -52,6 +53,18 @@ func NewInstance(base, mothballDir, stateDir, resourcesDir, password string) (*I return ctx, nil } +// Stuff people with mediocre handwriting could write down unambiguously, and can be entered without holding down shift +const distinguishableChars = "234678abcdefhijkmnpqrtuvwxyz=" + +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 { @@ -75,8 +88,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()) } } diff --git a/src/mothd.go b/src/mothd.go index 5f7fef5..b53bd78 100644 --- a/src/mothd.go +++ b/src/mothd.go @@ -6,10 +6,12 @@ import ( "mime" "net/http" "time" + "math/rand" ) func setup() error { + rand.Seed(time.Now().UnixNano()) return nil } From 3a11cc65ef3551961e9cfdc61d8e57630f3056f6 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 21 Feb 2019 19:40:48 -0700 Subject: [PATCH 03/15] Remove a couple ambiguous letters --- src/instance.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instance.go b/src/instance.go index 195c8c3..3a4021a 100644 --- a/src/instance.go +++ b/src/instance.go @@ -54,7 +54,7 @@ func NewInstance(base, mothballDir, stateDir, resourcesDir, password string) (*I } // Stuff people with mediocre handwriting could write down unambiguously, and can be entered without holding down shift -const distinguishableChars = "234678abcdefhijkmnpqrtuvwxyz=" +const distinguishableChars = "234678abcdefhijkmnpqrtwxyz=" func mktoken() string { a := make([]byte, 8) From 651c8fdfa4e1aa2429b83871c1249ddfdc87ea5e Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 21 Feb 2019 22:08:21 -0700 Subject: [PATCH 04/15] Path traversal fix, beginning to work on teamid as auth --- src/instance.go | 19 +++++++++-- theme/index.html | 27 ++++++--------- theme/puzzle-list.html | 76 ------------------------------------------ 3 files changed, 27 insertions(+), 95 deletions(-) diff --git a/src/instance.go b/src/instance.go index 3a4021a..c226869 100644 --- a/src/instance.go +++ b/src/instance.go @@ -102,18 +102,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/theme/index.html b/theme/index.html index 51ad704..ad385c2 100644 --- a/theme/index.html +++ b/theme/index.html @@ -1,30 +1,25 @@ - Welcome - + Sign In + + -

Welcome

+

Sign In

-

Register your team

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

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

+ Team ID:
+ +
+ +
+
diff --git a/theme/puzzle-list.html b/theme/puzzle-list.html index af42db5..3d1915b 100644 --- a/theme/puzzle-list.html +++ b/theme/puzzle-list.html @@ -7,82 +7,6 @@ From ad9dab3d8f71a85069839788dabe967f6d3660eb Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sat, 23 Feb 2019 00:43:04 +0000 Subject: [PATCH 05/15] work on updated client API --- src/handlers.go | 68 ++++++++++++++++++++++-------------------------- theme/basic.css | 30 +++++++++++++++++++-- theme/index.html | 6 +++-- 3 files changed, 63 insertions(+), 41 deletions(-) diff --git a/src/handlers.go b/src/handlers.go index b60b760..89643b2 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -12,39 +12,26 @@ import ( "strings" ) +// https://github.com/omniti-labs/jsend type JSend struct { Status string `json:"status"` - Data JSendData `json:"data"` + Data struct { + Short string `json:"short"` + Description string `json:"description"` + } `json:"data"` } -type JSendData struct { - Short string `json:"short"` - Description string `json:"description"` -} - -type Status int const ( - Success = iota - Fail - Error + JSendSuccess = "success" + JSendFail = "fail" + JSendError = "error" ) -func respond(w http.ResponseWriter, req *http.Request, status Status, short string, format string, a ...interface{}) { - resp := JSend{ - Status: "success", - Data: JSendData{ - Short: short, - Description: fmt.Sprintf(format, a...), - }, - } - 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 { @@ -82,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.", ) @@ -92,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, ) @@ -101,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?", ) @@ -109,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?", ) @@ -121,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!", ) @@ -136,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, ) @@ -146,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.", ) @@ -158,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, ) @@ -167,14 +161,14 @@ 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), ) diff --git a/theme/basic.css b/theme/basic.css index 2ee7b34..2c5170d 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,10 @@ iframe#body { img { max-width: 100%; } +#messages { + min-height: 3em; + border: solid black 2px; +} #scoreboard { width: 100%; position: relative; @@ -86,4 +90,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/index.html b/theme/index.html index ad385c2..6a69e85 100644 --- a/theme/index.html +++ b/theme/index.html @@ -1,14 +1,16 @@ - Sign In + MOTH -

Sign In

+

MOTH

+
+
Team name: Team ID:
From 33b4f391c9c27cf6575b5339c2360a0b038605ea Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sat, 23 Feb 2019 00:52:41 +0000 Subject: [PATCH 06/15] Add moth.js --- theme/moth.js | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 theme/moth.js diff --git a/theme/moth.js b/theme/moth.js new file mode 100644 index 0000000..bec145e --- /dev/null +++ b/theme/moth.js @@ -0,0 +1,168 @@ +// jshint asi:true + +var teamId +var heartbeatInterval = 40000 + +function rpc(url, params={}) { + let formData = new FormData() + for (let k in params) { + formData.append(k, params[k]) + } + return fetch(url, { + method: "POST", + body: formData, + }) +} + +function renderPuzzles(obj) { + console.log(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) + container.style.display = "none" + + document.getElementById("login").style.display = "block" +} + +function heartbeat(teamId) { + rpc("puzzles.json", {teamid: teamId}) + .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") + + document.getElementById("login").style.display = "none" + document.getElementById("puzzles").appendChild(spinner) + heartbeat(teamId) + setInterval(e => { heartbeat(teamId) }, 40000) +} + +function login() { + let name = document.querySelector("[name=name]").value + let id = document.querySelector("[name=id]").value + + rpc("register", { + name: name, + id: id, + }) + .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 toast(message, timeout=5000) { + let p = document.createElement("p") + + p.innerText = message + document.getElementById("messages").appendChild(p) + setTimeout( + e => { p.remove() }, + timeout + ) +} + +function init() { + document.getElementById("submit").addEventListener("click", login) +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} From 02ed2162c28009613c1005b8cbf770efaa20ef6a Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 22 Feb 2019 19:03:11 -0700 Subject: [PATCH 07/15] teamid auth to puzzles.json is working in prod and dev servers --- theme/{puzzle-list.html => logout.html} | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) rename theme/{puzzle-list.html => logout.html} (60%) diff --git a/theme/puzzle-list.html b/theme/logout.html similarity index 60% rename from theme/puzzle-list.html rename to theme/logout.html index 3d1915b..6aaad26 100644 --- a/theme/puzzle-list.html +++ b/theme/logout.html @@ -1,22 +1,21 @@ - Open Puzzles - + MOTH - + -

Open Puzzles

+

MOTH

-
-
+

Okay, you've been logged out.

+
From 8e67abe0c0fe8446ba6ca3b7c0aedbc8a5040d7b Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 22 Feb 2019 19:09:38 -0700 Subject: [PATCH 08/15] Now you can hit enter to sign in --- devel/devel-server.py | 64 +++++++++++++++++++++++++++++++++---------- src/handlers.go | 14 +++++----- src/instance.go | 4 +-- src/mothd.go | 7 +---- theme/index.html | 8 ++++-- theme/logout.html | 2 +- theme/moth.js | 20 ++++++++++---- theme/puzzle.html | 2 +- theme/scoreboard.html | 2 +- 9 files changed, 80 insertions(+), 43 deletions(-) diff --git a/devel/devel-server.py b/devel/devel-server.py index af0679b..041ba7b 100755 --- a/devel/devel-server.py +++ b/devel/devel-server.py @@ -28,6 +28,16 @@ def get_seed(request): else: return int(seedstr) + +def get_puzzle(request): + seed = get_seed(request) + category = request.match_info.get("category") + points = int(request.match_info.get("points")) + filename = request.match_info.get("filename") + cat = moth.Category(request.app["puzzles_dir"].joinpath(category), seed) + puzzle = cat.puzzle(points) + return puzzle + async def handle_puzzlelist(request): seed = get_seed(request) @@ -87,6 +97,16 @@ async def handle_puzzlefile(request): content_type=content_type, ) + +async def handle_answer(request): + seed = get_seed(request) + category = request.match_info.get("category") + points = int(request.match_info.get("points")) + filename = request.match_info.get("filename") + cat = moth.Category(request.app["puzzles_dir"].joinpath(category), seed) + puzzle = cat.puzzle(points) + + async def handle_mothballer(request): seed = get_seed(request) @@ -113,19 +133,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:

+
    +
  • {seed}: a special seed I made just for you!
  • +
  • random: will use a different seed every time you load a page (could be useful for debugging)
  • +
  • You can also hack your own seed into the URL, if you want to.
  • +
+

- 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 +178,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,7 +216,7 @@ 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("*", "/{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) diff --git a/src/handlers.go b/src/handlers.go index 89643b2..950d45a 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -175,6 +175,12 @@ func (ctx *Instance) answerHandler(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.WriteHeader(http.StatusOK) w.Write(ctx.jPuzzleList) @@ -273,13 +279,7 @@ func (ctx *Instance) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) { w: wOrig, statusCode: new(int), } - w.Header().Set("WWW-Authenticate", "Basic") - _, password, _ := r.BasicAuth() - if password != ctx.Password { - http.Error(w, "Authentication Required", 401) - } else { - ctx.mux.ServeHTTP(w, r) - } + ctx.mux.ServeHTTP(w, r) log.Printf( "%s %s %s %d\n", r.RemoteAddr, diff --git a/src/instance.go b/src/instance.go index c226869..bd89e5d 100644 --- a/src/instance.go +++ b/src/instance.go @@ -19,7 +19,6 @@ type Instance struct { MothballDir string StateDir string ResourcesDir string - Password string Categories map[string]*Mothball update chan bool jPuzzleList []byte @@ -27,13 +26,12 @@ type Instance struct { mux *http.ServeMux } -func NewInstance(base, mothballDir, stateDir, resourcesDir, password string) (*Instance, error) { +func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) { ctx := &Instance{ Base: strings.TrimRight(base, "/"), MothballDir: mothballDir, StateDir: stateDir, ResourcesDir: resourcesDir, - Password: password, Categories: map[string]*Mothball{}, update: make(chan bool, 10), mux: http.NewServeMux(), diff --git a/src/mothd.go b/src/mothd.go index b53bd78..46b7d42 100644 --- a/src/mothd.go +++ b/src/mothd.go @@ -37,11 +37,6 @@ func main() { "/theme", "Path to static theme resources (HTML, images, css, ...)", ) - password := flag.String( - "password", - "sesame", - "Pass Word (in the 1920s sense) to view the site. Not a secure passphrase.", - ) maintenanceInterval := flag.Duration( "maint", 20*time.Second, @@ -58,7 +53,7 @@ func main() { log.Fatal(err) } - ctx, err := NewInstance(*base, *mothballDir, *stateDir, *themeDir, *password) + ctx, err := NewInstance(*base, *mothballDir, *stateDir, *themeDir) if err != nil { log.Fatal(err) } diff --git a/theme/index.html b/theme/index.html index 6a69e85..89bdf48 100644 --- a/theme/index.html +++ b/theme/index.html @@ -2,6 +2,7 @@ MOTH + @@ -11,11 +12,11 @@
-
+
Team name: Team ID:
- -
+ +
@@ -23,6 +24,7 @@ diff --git a/theme/logout.html b/theme/logout.html index 6aaad26..14f3caf 100644 --- a/theme/logout.html +++ b/theme/logout.html @@ -15,7 +15,7 @@ sessionStorage.removeItem("id")
diff --git a/theme/moth.js b/theme/moth.js index bec145e..e81f791 100644 --- a/theme/moth.js +++ b/theme/moth.js @@ -64,6 +64,7 @@ function renderPuzzles(obj) { i.appendChild(a) a.textContent = points a.href = "puzzle.html?cat=" + cat + "&points=" + points + "&pid=" + id + a.target = "_blank" } } @@ -76,13 +77,10 @@ function renderPuzzles(obj) { container.firstChild.remove() } container.appendChild(puzzlesElement) - container.style.display = "none" - - document.getElementById("login").style.display = "block" } function heartbeat(teamId) { - rpc("puzzles.json", {teamid: teamId}) + rpc("puzzles.json", {id: teamId}) .then(resp => { if (resp.ok) { resp.json() @@ -103,16 +101,20 @@ 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() { +function login(e) { let name = document.querySelector("[name=name]").value let id = document.querySelector("[name=id]").value + e.preventDefault() + rpc("register", { name: name, id: id, @@ -158,7 +160,13 @@ function toast(message, timeout=5000) { } function init() { - document.getElementById("submit").addEventListener("click", login) + // Already signed in? + let id = sessionStorage.getItem("id") + if (id) { + showPuzzles(id) + } + + document.getElementById("login").addEventListener("submit", login) } if (document.readyState === "loading") { diff --git a/theme/puzzle.html b/theme/puzzle.html index 5aa19b9..5c0f3f1 100644 --- a/theme/puzzle.html +++ b/theme/puzzle.html @@ -94,7 +94,7 @@ if (document.readyState === "loading") {
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") { From 5d1886b9e69860d9e101c1ca98c85a1574836c0c Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 22 Feb 2019 20:46:52 -0700 Subject: [PATCH 09/15] Finish porting to pure-json RPC --- theme/devel.js | 47 ------------ theme/moth.js | 25 +++---- theme/puzzle.html | 74 +------------------ theme/puzzle.js | 184 ++++++++++++++++++++++++++++++++++++++++++++++ theme/token.html | 22 +++--- 5 files changed, 212 insertions(+), 140 deletions(-) delete mode 100644 theme/devel.js create mode 100644 theme/puzzle.js 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/moth.js b/theme/moth.js index e81f791..5874d13 100644 --- a/theme/moth.js +++ b/theme/moth.js @@ -3,6 +3,17 @@ 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 rpc(url, params={}) { let formData = new FormData() for (let k in params) { @@ -110,11 +121,10 @@ function showPuzzles(teamId) { } function login(e) { + e.preventDefault() let name = document.querySelector("[name=name]").value let id = document.querySelector("[name=id]").value - e.preventDefault() - rpc("register", { name: name, id: id, @@ -148,17 +158,6 @@ function login(e) { }) } -function toast(message, timeout=5000) { - let p = document.createElement("p") - - p.innerText = message - document.getElementById("messages").appendChild(p) - setTimeout( - e => { p.remove() }, - timeout - ) -} - function init() { // Already signed in? let id = sessionStorage.getItem("id") diff --git a/theme/puzzle.html b/theme/puzzle.html index 5c0f3f1..fc03cfa 100644 --- a/theme/puzzle.html +++ b/theme/puzzle.html @@ -5,86 +5,20 @@ - +

Puzzle

-
Loading...
+

    Puzzle by

    -
    +
    + Team ID:
    diff --git a/theme/puzzle.js b/theme/puzzle.js new file mode 100644 index 0000000..8f6f339 --- /dev/null +++ b/theme/puzzle.js @@ -0,0 +1,184 @@ +// 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 + } + } +} + + +function rpc(url, params={}) { + let formData = new FormData() + for (let k in params) { + formData.append(k, params[k]) + } + return fetch(url, { + method: "POST", + body: formData, + }) +} + + +function toast(message, timeout=5000) { + let p = document.createElement("p") + + p.innerText = message + document.getElementById("messages").appendChild(p) + setTimeout( + e => { p.remove() }, + timeout + ) +} + + +function submit(e) { + e.preventDefault() + let cat = document.querySelector("input[name=cat]").value + let points = document.querySelector("input[name=points]").value + let id = document.querySelector("input[name=id]").value + let answer = document.querySelector("input[name=answer]").value + + rpc("answer", { + cat: cat, + points: points, + id: id, + answer: answer, + }) + .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(", ") + + // 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 +} + +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 + } + + document.querySelector("form").addEventListener("submit", submit) +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init) +} else { + init() +} + 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

    - +
    + From c726847cb954f246f0345967dce9eb66cdb4970d Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sat, 23 Feb 2019 12:04:42 -0700 Subject: [PATCH 10/15] Add answer checking to dev server --- devel/devel-server.py | 56 ++++++++++++++++++++++++++----------------- theme/moth.js | 26 +++++++------------- theme/puzzle.js | 25 +++---------------- 3 files changed, 46 insertions(+), 61 deletions(-) diff --git a/devel/devel-server.py b/devel/devel-server.py index 041ba7b..3d2de63 100755 --- a/devel/devel-server.py +++ b/devel/devel-server.py @@ -29,15 +29,37 @@ def get_seed(request): return int(seedstr) -def get_puzzle(request): +def get_puzzle(request, data=None): seed = get_seed(request) - category = request.match_info.get("category") - points = int(request.match_info.get("points")) - filename = request.match_info.get("filename") + 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 +# OMG what is this I hate Python now +@asyncio.coroutine +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) @@ -61,7 +83,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) @@ -80,7 +102,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) @@ -97,20 +119,9 @@ async def handle_puzzlefile(request): content_type=content_type, ) - -async def handle_answer(request): - seed = get_seed(request) - category = request.match_info.get("category") - points = int(request.match_info.get("points")) - filename = request.match_info.get("filename") - cat = moth.Category(request.app["puzzles_dir"].joinpath(category), seed) - puzzle = cat.puzzle(points) - - - 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) @@ -137,7 +148,7 @@ async def handle_index(request): Dev Server @@ -216,9 +227,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("*", "/{seed}/answer", handle_answer) app.router.add_route("*", "/{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("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/theme/moth.js b/theme/moth.js index 5874d13..2522bc8 100644 --- a/theme/moth.js +++ b/theme/moth.js @@ -14,19 +14,7 @@ function toast(message, timeout=5000) { ) } -function rpc(url, params={}) { - let formData = new FormData() - for (let k in params) { - formData.append(k, params[k]) - } - return fetch(url, { - method: "POST", - body: formData, - }) -} - function renderPuzzles(obj) { - console.log(obj) let puzzlesElement = document.createElement('div') // Create a sorted list of category names @@ -75,7 +63,6 @@ function renderPuzzles(obj) { i.appendChild(a) a.textContent = points a.href = "puzzle.html?cat=" + cat + "&points=" + points + "&pid=" + id - a.target = "_blank" } } @@ -91,7 +78,12 @@ function renderPuzzles(obj) { } function heartbeat(teamId) { - rpc("puzzles.json", {id: teamId}) + let fd = new FormData() + fd.append("id", teamId) + fetch("puzzles.json", { + method: "POST", + body: fd, + }) .then(resp => { if (resp.ok) { resp.json() @@ -125,9 +117,9 @@ function login(e) { let name = document.querySelector("[name=name]").value let id = document.querySelector("[name=id]").value - rpc("register", { - name: name, - id: id, + fetch("register", { + method: "POST", + body: new FormData(e.target), }) .then(resp => { if (resp.ok) { diff --git a/theme/puzzle.js b/theme/puzzle.js index 8f6f339..f129a51 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -45,18 +45,6 @@ function devel_addin(obj, e) { } -function rpc(url, params={}) { - let formData = new FormData() - for (let k in params) { - formData.append(k, params[k]) - } - return fetch(url, { - method: "POST", - body: formData, - }) -} - - function toast(message, timeout=5000) { let p = document.createElement("p") @@ -71,16 +59,9 @@ function toast(message, timeout=5000) { function submit(e) { e.preventDefault() - let cat = document.querySelector("input[name=cat]").value - let points = document.querySelector("input[name=points]").value - let id = document.querySelector("input[name=id]").value - let answer = document.querySelector("input[name=answer]").value - - rpc("answer", { - cat: cat, - points: points, - id: id, - answer: answer, + fetch("answer", { + method: "POST", + body: new FormData(e.target), }) .then(resp => { if (resp.ok) { From 15503cb45b604350296318d964a93e0e22ce490d Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sun, 24 Feb 2019 11:53:22 -0700 Subject: [PATCH 11/15] Check user-supplied answers for possible correctness --- devel/devel-server.py | 2 -- devel/moth.py | 6 +++--- theme/puzzle.html | 2 +- theme/puzzle.js | 44 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/devel/devel-server.py b/devel/devel-server.py index 3d2de63..2be469a 100755 --- a/devel/devel-server.py +++ b/devel/devel-server.py @@ -40,8 +40,6 @@ def get_puzzle(request, data=None): puzzle = cat.puzzle(points) return puzzle -# OMG what is this I hate Python now -@asyncio.coroutine async def handle_answer(request): data = await request.post() puzzle = get_puzzle(request, data) diff --git a/devel/moth.py b/devel/moth.py index 9820783..ee13f46 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 @@ -277,7 +277,7 @@ class Puzzle: 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/theme/puzzle.html b/theme/puzzle.html index fc03cfa..2a8dcba 100644 --- a/theme/puzzle.html +++ b/theme/puzzle.html @@ -22,7 +22,7 @@ Team ID:
    - Answer:
    + Answer:
    diff --git a/theme/puzzle.js b/theme/puzzle.js index f129a51..6ab5f07 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -44,7 +44,17 @@ function devel_addin(obj, e) { } } +// The routine used to hash answers in compiled puzzle packages +function djb2hash(buf) { + let h = 5381 + for (let c of (new TextEncoder).encode(buf)) { // JavaScript is weird. + h = ((h * 33) + c) & 0xffffffff + } + return h +} + +// Pop up a message function toast(message, timeout=5000) { let p = document.createElement("p") @@ -56,7 +66,7 @@ function toast(message, timeout=5000) { ) } - +// When the user submits an answer function submit(e) { e.preventDefault() fetch("answer", { @@ -92,6 +102,9 @@ function loadPuzzle(categoryName, points, puzzleId) { // 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")) @@ -139,6 +152,32 @@ function loadPuzzle(categoryName, points, puzzleId) { document.querySelector("input[name=points]").value = points } +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") @@ -154,6 +193,9 @@ function init() { document.querySelector("input[name=id]").value = teamId } + if (document.querySelector("#answer")) { + document.querySelector("#answer").addEventListener("input", answerCheck) + } document.querySelector("form").addEventListener("submit", submit) } From caa957a08dcc4afb973bc07e3ae4a5b7ec91eb71 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sun, 24 Feb 2019 15:51:40 -0700 Subject: [PATCH 12/15] JS Input Helpers --- example-puzzles/example/5/helpers.js | 46 +++++++++++++++++++++++++++ example-puzzles/example/5/puzzle.moth | 39 +++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 example-puzzles/example/5/helpers.js create mode 100644 example-puzzles/example/5/puzzle.moth 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..d59937e --- /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 +
      +
    • Horns
    • +
    • Hooves
    • +
    • Antlers
    • +
    From 60277f6a7ebac02855d495daa2ba59f905fd9c31 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sun, 24 Feb 2019 17:02:28 -0700 Subject: [PATCH 13/15] HTML5 input patterns, plus jas djb2hash fix --- devel/moth.py | 4 ++++ example-puzzles/example/5/puzzle.moth | 2 +- theme/basic.css | 3 +++ theme/puzzle.js | 13 +++++++++++-- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/devel/moth.py b/devel/moth.py index ee13f46..6a62116 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -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,6 +274,7 @@ class Puzzle: 'hashes': self.hashes(), 'files': files, 'scripts': self.scripts, + 'pattern': self.pattern, 'body': self.html_body(), } diff --git a/example-puzzles/example/5/puzzle.moth b/example-puzzles/example/5/puzzle.moth index d59937e..9c3be6b 100644 --- a/example-puzzles/example/5/puzzle.moth +++ b/example-puzzles/example/5/puzzle.moth @@ -11,7 +11,7 @@ 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 +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, diff --git a/theme/basic.css b/theme/basic.css index 2c5170d..9614028 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -50,6 +50,9 @@ iframe#body { img { max-width: 100%; } +input:invalid { + border-color: red; +} #messages { min-height: 3em; border: solid black 2px; diff --git a/theme/puzzle.js b/theme/puzzle.js index 6ab5f07..0781287 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -44,11 +44,15 @@ function devel_addin(obj, e) { } } + + // The routine used to hash answers in compiled puzzle packages function djb2hash(buf) { let h = 5381 - for (let c of (new TextEncoder).encode(buf)) { // JavaScript is weird. - h = ((h * 33) + c) & 0xffffffff + 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 } @@ -132,6 +136,11 @@ function loadPuzzle(categoryName, points, puzzleId) { 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()) From dc4fa96f9e97fc2cb7a6ec7ad0e70b7c3da02d6b Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Sun, 24 Feb 2019 17:13:26 -0700 Subject: [PATCH 14/15] Mention constraint validation API as room for future growth --- theme/puzzle.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/theme/puzzle.js b/theme/puzzle.js index 0781287..eafda60 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -161,6 +161,9 @@ function loadPuzzle(categoryName, points, puzzleId) { 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") From 628d8a0b0565b9d6fc5e8538858119e015da3d10 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Mon, 25 Feb 2019 16:07:53 +0000 Subject: [PATCH 15/15] go fmt --- src/handlers.go | 13 ++++++------- src/instance.go | 8 ++++---- src/mothd.go | 4 +--- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/handlers.go b/src/handlers.go index 950d45a..d4674d7 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -14,17 +14,17 @@ import ( // https://github.com/omniti-labs/jsend type JSend struct { - Status string `json:"status"` + Status string `json:"status"` Data struct { Short string `json:"short"` Description string `json:"description"` - } `json:"data"` + } `json:"data"` } const ( JSendSuccess = "success" - JSendFail = "fail" - JSendError = "error" + JSendFail = "fail" + JSendError = "error" ) func respond(w http.ResponseWriter, req *http.Request, status string, short string, format string, a ...interface{}) { @@ -255,7 +255,7 @@ func (ctx *Instance) staticHandler(w http.ResponseWriter, req *http.Request) { } type FurtiveResponseWriter struct { - w http.ResponseWriter + w http.ResponseWriter statusCode *int } @@ -276,7 +276,7 @@ func (w FurtiveResponseWriter) Header() http.Header { // This gives Instances the signature of http.Handler func (ctx *Instance) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) { w := FurtiveResponseWriter{ - w: wOrig, + w: wOrig, statusCode: new(int), } ctx.mux.ServeHTTP(w, r) @@ -297,4 +297,3 @@ func (ctx *Instance) BindHandlers() { 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 bd89e5d..6f5dd95 100644 --- a/src/instance.go +++ b/src/instance.go @@ -6,12 +6,12 @@ import ( "io" "io/ioutil" "log" + "math/rand" "net/http" "os" "path" "strings" "time" - "math/rand" ) type Instance struct { @@ -23,7 +23,7 @@ type Instance struct { update chan bool jPuzzleList []byte jPointsLog []byte - mux *http.ServeMux + mux *http.ServeMux } func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) { @@ -44,7 +44,7 @@ func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, e if _, err := os.Stat(stateDir); err != nil { return nil, err } - + ctx.BindHandlers() ctx.MaybeInitialize() @@ -56,7 +56,7 @@ const distinguishableChars = "234678abcdefhijkmnpqrtwxyz=" func mktoken() string { a := make([]byte, 8) - for i := range(a) { + for i := range a { char := rand.Intn(len(distinguishableChars)) a[i] = distinguishableChars[char] } diff --git a/src/mothd.go b/src/mothd.go index 46b7d42..ef08832 100644 --- a/src/mothd.go +++ b/src/mothd.go @@ -3,19 +3,17 @@ package main import ( "github.com/namsral/flag" "log" + "math/rand" "mime" "net/http" "time" - "math/rand" ) - func setup() error { rand.Seed(time.Now().UnixNano()) return nil } - func main() { base := flag.String( "base",