Merge branch 'master' of https://github.com/dirtbags/moth into neale

This commit is contained in:
Donaldson 2019-11-17 18:10:30 -06:00
commit bab22b8e79
15 changed files with 339 additions and 51 deletions

View File

@ -5,9 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
## [3.4] - 2019-11-13
### Added ### Added
- A changelog - A changelog
- Support for embedding Python libraries at the category or puzzle level - 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 - Embedded graph in scoreboard
- Optional tracking of participant IDs - Optional tracking of participant IDs
- New `notices.html` file for sending broadcast messages to players - New `notices.html` file for sending broadcast messages to players

View File

@ -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. 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=<teamId>`. 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 Mothball Directory
================== ==================

View File

@ -1 +1 @@
3.4_rc1 3.4

View File

@ -8,6 +8,9 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
) )
@ -247,6 +250,65 @@ func (ctx *Instance) staticHandler(w http.ResponseWriter, req *http.Request) {
http.ServeContent(w, req, path, d.ModTime(), f) http.ServeContent(w, req, path, d.ModTime(), f)
} }
func (ctx *Instance) 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 { type FurtiveResponseWriter struct {
w http.ResponseWriter w http.ResponseWriter
statusCode *int statusCode *int
@ -289,4 +351,5 @@ func (ctx *Instance) BindHandlers() {
ctx.mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler) ctx.mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler)
ctx.mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler) ctx.mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler)
ctx.mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler) ctx.mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler)
ctx.mux.HandleFunc(ctx.Base+"/current_manifest.json", ctx.manifestHandler)
} }

View File

@ -15,6 +15,10 @@ import (
"time" "time"
) )
type RuntimeConfig struct {
export_manifest bool
}
type Instance struct { type Instance struct {
Base string Base string
MothballDir string MothballDir string
@ -22,7 +26,10 @@ type Instance struct {
ThemeDir string ThemeDir string
AttemptInterval time.Duration AttemptInterval time.Duration
Runtime RuntimeConfig
categories map[string]*Mothball categories map[string]*Mothball
MaxPointsUnlocked map[string]int
update chan bool update chan bool
jPuzzleList []byte jPuzzleList []byte
jPointsLog []byte jPointsLog []byte

View File

@ -12,11 +12,6 @@ import (
"time" "time"
) )
type PuzzleMap struct {
Points int
Path string
}
func (pm *PuzzleMap) MarshalJSON() ([]byte, error) { func (pm *PuzzleMap) MarshalJSON() ([]byte, error) {
if pm == nil { if pm == nil {
return []byte("null"), nil return []byte("null"), nil
@ -41,45 +36,29 @@ func (ctx *Instance) generatePuzzleList() {
ret := map[string][]PuzzleMap{} ret := map[string][]PuzzleMap{}
for catName, mb := range ctx.categories { for catName, mb := range ctx.categories {
mf, err := mb.Open("map.txt") filtered_puzzlemap := make([]PuzzleMap, 0, 30)
if err != nil {
// File isn't in there
continue
}
defer mf.Close()
pm := make([]PuzzleMap, 0, 30)
completed := true completed := true
scanner := bufio.NewScanner(mf)
for scanner.Scan() {
line := scanner.Text()
var pointval int for _, pm := range mb.puzzlemap {
var dir string filtered_puzzlemap = append(filtered_puzzlemap, pm)
n, err := fmt.Sscanf(line, "%d %s", &pointval, &dir) if pm.Points > maxByCategory[catName] {
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] {
completed = false completed = false
maxByCategory[catName] = pm.Points
break break
} }
} }
if completed { 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) jpl, err := json.Marshal(ret)
if err != nil { if err != nil {
log.Printf("Marshalling puzzles.js: %v", err) log.Printf("Marshalling puzzles.js: %v", err)
@ -124,6 +103,9 @@ func (ctx *Instance) tidy() {
// Do they want to reset everything? // Do they want to reset everything?
ctx.MaybeInitialize() ctx.MaybeInitialize()
// Check set config
ctx.UpdateConfig()
// Refresh all current categories // Refresh all current categories
for categoryName, mb := range ctx.categories { for categoryName, mb := range ctx.categories {
if err := mb.Refresh(); err != nil { if err := mb.Refresh(); err != nil {
@ -286,6 +268,20 @@ func (ctx *Instance) isEnabled() bool {
return true 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 // maintenance is the goroutine that runs a periodic maintenance task
func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) { func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) {
for { for {

View File

@ -2,17 +2,25 @@ package main
import ( import (
"archive/zip" "archive/zip"
"bufio"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"log"
"os" "os"
"strings" "strings"
"time" "time"
) )
type PuzzleMap struct {
Points int
Path string
}
type Mothball struct { type Mothball struct {
zf *zip.ReadCloser zf *zip.ReadCloser
filename string filename string
puzzlemap []PuzzleMap
mtime time.Time mtime time.Time
} }
@ -150,6 +158,35 @@ func (m *Mothball) Refresh() error {
m.zf = zf m.zf = zf
m.mtime = mtime 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 return nil
} }

View File

@ -136,3 +136,7 @@ li[draggable] {
[draggable].over { [draggable].over {
border: 1px white dashed; border: 1px white dashed;
} }
#cacheButton.disabled {
display: none;
}

View File

@ -5,7 +5,9 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<script src="moth-pwa.js" type="text/javascript"></script>
<script src="moth.js"></script> <script src="moth.js"></script>
<link rel="manifest" href="manifest.json">
</head> </head>
<body> <body>
<h1 id="title">MOTH</h1> <h1 id="title">MOTH</h1>
@ -32,6 +34,7 @@
<ul> <ul>
<li><a href="scoreboard.html">Scoreboard</a></li> <li><a href="scoreboard.html">Scoreboard</a></li>
<li><a href="logout.html">Sign Out</a></li> <li><a href="logout.html">Sign Out</a></li>
<li id="cacheButton" class="disabled"><a href="#" onclick='fetchAll()' title="Cache am offline copy of current content">Cache</a></li>
</ul> </ul>
</nav> </nav>
</body> </body>

9
theme/manifest.json Normal file
View File

@ -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"
}

17
theme/moth-pwa.js Normal file
View File

@ -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()
}

View File

@ -88,6 +88,7 @@ function renderPuzzles(obj) {
container.appendChild(puzzlesElement) container.appendChild(puzzlesElement)
} }
function heartbeat(teamId, participantId) { function heartbeat(teamId, participantId) {
let noticesUrl = new URL("notices.html", window.location) let noticesUrl = new URL("notices.html", window.location)
fetch(noticesUrl) fetch(noticesUrl)
@ -137,6 +138,90 @@ function showPuzzles(teamId, participantId) {
document.getElementById("puzzles").appendChild(spinner) document.getElementById("puzzles").appendChild(spinner)
heartbeat(teamId, participantId) heartbeat(teamId, participantId)
setInterval(e => { heartbeat(teamId) }, 40000) 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) { function login(e) {
@ -191,7 +276,8 @@ function init() {
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init); document.addEventListener("DOMContentLoaded", init)
} else { } else {
init(); init()
} }

View File

@ -5,6 +5,7 @@
<link rel="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<meta charset="utf-8"> <meta charset="utf-8">
<script src="moth-pwa.js"></script>
<script src="puzzle.js"></script> <script src="puzzle.js"></script>
<script> <script>

View File

@ -4,6 +4,7 @@
<title>Scoreboard</title> <title>Scoreboard</title>
<link rel="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<script src="moth-pwa.js"></script>
<script src="moment.min.js" async></script> <script src="moment.min.js" async></script>
<script src="Chart.min.js" async></script> <script src="Chart.min.js" async></script>
<script src="scoreboard.js" async></script> <script src="scoreboard.js" async></script>

49
theme/sw.js Normal file
View File

@ -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")
}
})
)
})