diff --git a/CHANGELOG.md b/CHANGELOG.md index c5879ef..82c2efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A changelog - Support for embedding Python libraries at the category or puzzle level +- Minimal PWA support to permit caching of currently-unlocked content - Embedded graph in scoreboard - Optional tracking of participant IDs - New `notices.html` file for sending broadcast messages to players diff --git a/README.md b/README.md index 9d9d9c9..99549f0 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,18 @@ We sometimes to set `teamids.txt` to a bunch of random 8-digit hex values: Remember that team IDs are essentially passwords. +Enabling offline/PWA mode +------------------- + +If the file `state/export_manifest` is found, the server will expose the +endpoint `/current_manifest.json?id=`. This endpoint will return +a list of all files, including static theme content and JSON and content +for currently-unlocked puzzles. This is used by the native PWA +implementation and `Cache` button on the index page to cache all of the +content necessary to display currently-open puzzles while offline. +Grading will be unavailable while offline. Some puzzles may not function +as expected while offline. A valid team ID must be provided. + Mothball Directory ================== diff --git a/src/handlers.go b/src/handlers.go index 4480787..00eca05 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -8,6 +8,9 @@ import ( "log" "net/http" "os" + "path" + "path/filepath" + "regexp" "strconv" "strings" ) @@ -247,6 +250,65 @@ func (ctx *Instance) staticHandler(w http.ResponseWriter, req *http.Request) { http.ServeContent(w, req, path, d.ModTime(), f) } +func (ctx *Instance) manifestHandler(w http.ResponseWriter, req *http.Request) { + if !ctx.Runtime.export_manifest { + http.Error(w, "Endpoint disabled", http.StatusForbidden) + return + } + + teamId := req.FormValue("id") + if _, err := ctx.TeamName(teamId); err != nil { + http.Error(w, "Must provide a valid team ID", http.StatusUnauthorized) + return + } + + if req.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + + manifest := make([]string, 0) + manifest = append(manifest, "puzzles.json") + manifest = append(manifest, "points.json") + + // Pack up the theme files + theme_root_re := regexp.MustCompile(fmt.Sprintf("^%s/", ctx.ThemeDir)) + filepath.Walk(ctx.ThemeDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() { // Only package up files + localized_path := theme_root_re.ReplaceAllLiteralString(path, "") + manifest = append(manifest, localized_path) + } + return nil + }) + + // Package up files for currently-unlocked puzzles in categories + for category_name, category := range ctx.categories { + if _, ok := ctx.MaxPointsUnlocked[category_name]; ok { // Check that the category is actually unlocked. This should never fail, probably + for _, file := range category.zf.File { + parts := strings.Split(file.Name, "/") + + if parts[0] == "content" { // Only pick up content files, not thing like map.txt + for _, puzzlemap := range category.puzzlemap { // Figure out which puzzles are currently unlocked + if puzzlemap.Path == parts[1] && puzzlemap.Points <= ctx.MaxPointsUnlocked[category_name] { + + manifest = append(manifest, path.Join("content", category_name, path.Join(parts[1:]...))) + break + } + } + } + } + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + manifest_json, _ := json.Marshal(manifest) + w.Write(manifest_json) +} + type FurtiveResponseWriter struct { w http.ResponseWriter statusCode *int @@ -289,4 +351,5 @@ func (ctx *Instance) BindHandlers() { 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) + ctx.mux.HandleFunc(ctx.Base+"/current_manifest.json", ctx.manifestHandler) } diff --git a/src/instance.go b/src/instance.go index 0f0aab4..a415b40 100644 --- a/src/instance.go +++ b/src/instance.go @@ -15,6 +15,10 @@ import ( "time" ) +type RuntimeConfig struct { + export_manifest bool +} + type Instance struct { Base string MothballDir string @@ -22,13 +26,16 @@ type Instance struct { ThemeDir string AttemptInterval time.Duration - categories map[string]*Mothball - update chan bool - jPuzzleList []byte - jPointsLog []byte - nextAttempt map[string]time.Time - nextAttemptMutex *sync.RWMutex - mux *http.ServeMux + Runtime RuntimeConfig + + categories map[string]*Mothball + MaxPointsUnlocked map[string]int + update chan bool + jPuzzleList []byte + jPointsLog []byte + nextAttempt map[string]time.Time + nextAttemptMutex *sync.RWMutex + mux *http.ServeMux } func (ctx *Instance) Initialize() error { diff --git a/src/maintenance.go b/src/maintenance.go index abd51e0..87f35dc 100644 --- a/src/maintenance.go +++ b/src/maintenance.go @@ -12,11 +12,6 @@ import ( "time" ) -type PuzzleMap struct { - Points int - Path string -} - func (pm *PuzzleMap) MarshalJSON() ([]byte, error) { if pm == nil { return []byte("null"), nil @@ -41,45 +36,29 @@ func (ctx *Instance) generatePuzzleList() { ret := map[string][]PuzzleMap{} for catName, mb := range ctx.categories { - mf, err := mb.Open("map.txt") - if err != nil { - // File isn't in there - continue - } - defer mf.Close() - - pm := make([]PuzzleMap, 0, 30) + filtered_puzzlemap := make([]PuzzleMap, 0, 30) completed := true - scanner := bufio.NewScanner(mf) - for scanner.Scan() { - line := scanner.Text() - var pointval int - var dir string + for _, pm := range mb.puzzlemap { + filtered_puzzlemap = append(filtered_puzzlemap, pm) - n, err := fmt.Sscanf(line, "%d %s", &pointval, &dir) - if err != nil { - log.Printf("Parsing map for %s: %v", catName, err) - continue - } else if n != 2 { - log.Printf("Parsing map for %s: short read", catName) - continue - } - - pm = append(pm, PuzzleMap{pointval, dir}) - - if pointval > maxByCategory[catName] { + if pm.Points > maxByCategory[catName] { completed = false + maxByCategory[catName] = pm.Points break } } + if completed { - pm = append(pm, PuzzleMap{0, ""}) + filtered_puzzlemap = append(filtered_puzzlemap, PuzzleMap{0, ""}) } - ret[catName] = pm + ret[catName] = filtered_puzzlemap } + // Cache the unlocked points for use in other functions + ctx.MaxPointsUnlocked = maxByCategory + jpl, err := json.Marshal(ret) if err != nil { log.Printf("Marshalling puzzles.js: %v", err) @@ -124,6 +103,9 @@ func (ctx *Instance) tidy() { // Do they want to reset everything? ctx.MaybeInitialize() + // Check set config + ctx.UpdateConfig() + // Refresh all current categories for categoryName, mb := range ctx.categories { if err := mb.Refresh(); err != nil { @@ -286,6 +268,20 @@ func (ctx *Instance) isEnabled() bool { return true } +func (ctx *Instance) UpdateConfig() { + // Handle export manifest + if _, err := os.Stat(ctx.StatePath("export_manifest")); err == nil { + if !ctx.Runtime.export_manifest { + log.Print("Enabling manifest export") + ctx.Runtime.export_manifest = true + } + } else if ctx.Runtime.export_manifest { + log.Print("Disabling manifest export") + ctx.Runtime.export_manifest = false + } + +} + // maintenance is the goroutine that runs a periodic maintenance task func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) { for { diff --git a/src/mothball.go b/src/mothball.go index 149dbf5..c59c195 100644 --- a/src/mothball.go +++ b/src/mothball.go @@ -2,18 +2,26 @@ package main import ( "archive/zip" + "bufio" "fmt" "io" "io/ioutil" + "log" "os" "strings" "time" ) +type PuzzleMap struct { + Points int + Path string +} + type Mothball struct { - zf *zip.ReadCloser - filename string - mtime time.Time + zf *zip.ReadCloser + filename string + puzzlemap []PuzzleMap + mtime time.Time } type MothballFile struct { @@ -150,6 +158,35 @@ func (m *Mothball) Refresh() error { m.zf = zf m.mtime = mtime + mf, err := m.Open("map.txt") + if err != nil { + // File isn't in there + } else { + defer mf.Close() + + pm := make([]PuzzleMap, 0, 30) + scanner := bufio.NewScanner(mf) + + for scanner.Scan() { + line := scanner.Text() + + var pointval int + var dir string + + n, err := fmt.Sscanf(line, "%d %s", &pointval, &dir) + if err != nil { + log.Printf("Parsing map for %v", err) + } else if n != 2 { + log.Printf("Parsing map: short read") + } + + pm = append(pm, PuzzleMap{pointval, dir}) + + } + + m.puzzlemap = pm + } + return nil } diff --git a/theme/basic.css b/theme/basic.css index cbe7987..bd1c830 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -136,3 +136,7 @@ li[draggable] { [draggable].over { border: 1px white dashed; } + +#cacheButton.disabled { + display: none; +} diff --git a/theme/index.html b/theme/index.html index 4092fb4..f72f5e1 100644 --- a/theme/index.html +++ b/theme/index.html @@ -5,7 +5,9 @@ + +

MOTH

@@ -27,11 +29,12 @@
- + diff --git a/theme/manifest.json b/theme/manifest.json new file mode 100644 index 0000000..acc15b6 --- /dev/null +++ b/theme/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "Monarch of the Hill", + "short_name": "MOTH", + "start_url": ".", + "display": "standalone", + "background_color": "#282a33", + "theme_color": "#ECB", + "description": "The MOTH CTF engine" +} diff --git a/theme/moth-pwa.js b/theme/moth-pwa.js new file mode 100644 index 0000000..780bd1d --- /dev/null +++ b/theme/moth-pwa.js @@ -0,0 +1,17 @@ +function pwa_init() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register("./sw.js").then(function(reg) { + }) + .catch(err => { + console.warn("Error while registering service worker", err) + }) + } else { + console.log("Service workers not supported. Some offline functionality may not work") + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", pwa_init) +} else { + pwa_init() +} diff --git a/theme/moth.js b/theme/moth.js index e2607cd..41ed728 100644 --- a/theme/moth.js +++ b/theme/moth.js @@ -88,6 +88,7 @@ function renderPuzzles(obj) { container.appendChild(puzzlesElement) } + function heartbeat(teamId, participantId) { let noticesUrl = new URL("notices.html", window.location) fetch(noticesUrl) @@ -137,6 +138,90 @@ function showPuzzles(teamId, participantId) { document.getElementById("puzzles").appendChild(spinner) heartbeat(teamId, participantId) setInterval(e => { heartbeat(teamId) }, 40000) + drawCacheButton(teamId) +} + +function drawCacheButton(teamId) { + let cacher = document.querySelector("#cacheButton") + + function updateCacheButton() { + let headers = new Headers() + headers.append("pragma", "no-cache") + headers.append("cache-control", "no-cache") + let url = new URL("current_manifest.json", window.location) + url.searchParams.set("id", teamId) + fetch(url, {method: "HEAD", headers: headers}) + .then( resp => { + if (resp.ok) { + cacher.classList.remove("disabled") + } else { + cacher.classList.add("disabled") + } + }) + .catch(ex => { + cacher.classList.add("disabled") + }) + } + + setInterval (updateCacheButton , 30000) + updateCacheButton() +} + +async function fetchAll() { + let teamId = sessionStorage.getItem("id") + let headers = new Headers() + headers.append("pragma", "no-cache") + headers.append("cache-control", "no-cache") + requests = [] + let url = new URL("current_manifest.json", window.location) + url.searchParams.set("id", teamId) + + toast("Caching all currently-open content") + requests.push( fetch(url, {headers: headers}) + .then( resp => { + if (resp.ok) { + resp.json() + .then(contents => { + console.log("Processing manifest") + for (let resource of contents) { + if (resource == "puzzles.json") { + continue + } + fetch(resource) + .then(e => { + console.log("Fetched " + resource) + }) + } + }) + } + })) + + let resp = await fetch("puzzles.json?id=" + teamId, {headers: headers}) + + if (resp.ok) { + let categories = await resp.json() + let cat_names = Object.keys(categories) + cat_names.sort() + for (let cat_name of cat_names) { + if (cat_name.startsWith("__")) { + // Skip metadata + continue + } + let puzzles = categories[cat_name] + for (let puzzle of puzzles) { + let url = new URL("puzzle.html", window.location) + url.searchParams.set("cat", cat_name) + url.searchParams.set("points", puzzle[0]) + url.searchParams.set("pid", puzzle[1]) + requests.push( fetch(url) + .then(e => { + console.log("Fetched " + url) + })) + } + } + } + await Promise.all(requests) + toast("Done caching content") } function login(e) { @@ -186,12 +271,13 @@ function init() { if (teamId) { showPuzzles(teamId, participantId) } - + document.getElementById("login").addEventListener("submit", login) } if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); + document.addEventListener("DOMContentLoaded", init) } else { - init(); + init() } + diff --git a/theme/puzzle.html b/theme/puzzle.html index 2a8dcba..88de8dc 100644 --- a/theme/puzzle.html +++ b/theme/puzzle.html @@ -5,6 +5,7 @@ + diff --git a/theme/sw.js b/theme/sw.js new file mode 100644 index 0000000..8bdb2b0 --- /dev/null +++ b/theme/sw.js @@ -0,0 +1,49 @@ +var cacheName = "moth:v1" +var content = [ + "index.html", + "basic.css", + "puzzle.js", + "puzzle.html", + "scoreboard.html", + "moth.js", + "sw.js", + "points.json", +] + +self.addEventListener("install", function(e) { + e.waitUntil( + caches.open(cacheName).then(function(cache) { + return cache.addAll(content).then( + function() { + self.skipWaiting() + }) + }) + ) +}) + +/* Attempt to fetch live resources, first, then fall back to cache */ +self.addEventListener('fetch', function(event) { + let cache_used = false + + event.respondWith( + fetch(event.request) + .catch(function(evt) { + //console.log("Falling back to cache for " + event.request.url) + cache_used = true + return caches.match(event.request, {ignoreSearch: true}) + }).then(function(res) { + if (res && res.ok) { + let res_clone = res.clone() + if (! cache_used && event.request.method == "GET" ) { + caches.open(cacheName).then(function(cache) { + cache.put(event.request, res_clone) + //console.log("Storing " + event.request.url + " in cache") + }) + } + return res + } else { + console.log("Failed to retrieve resource") + } + }) + ) +})