mirror of https://github.com/dirtbags/moth.git
Merge pull request #83 from dirtbags/81-support-downloading-open-content
Support downloading open content
This commit is contained in:
commit
2e344a78d2
|
@ -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
|
||||
|
|
12
README.md
12
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=<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
|
||||
==================
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -15,6 +15,10 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
type RuntimeConfig struct {
|
||||
export_manifest bool
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
Base string
|
||||
MothballDir string
|
||||
|
@ -22,7 +26,10 @@ type Instance struct {
|
|||
ThemeDir string
|
||||
AttemptInterval time.Duration
|
||||
|
||||
Runtime RuntimeConfig
|
||||
|
||||
categories map[string]*Mothball
|
||||
MaxPointsUnlocked map[string]int
|
||||
update chan bool
|
||||
jPuzzleList []byte
|
||||
jPointsLog []byte
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -2,17 +2,25 @@ 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
|
||||
puzzlemap []PuzzleMap
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -136,3 +136,7 @@ li[draggable] {
|
|||
[draggable].over {
|
||||
border: 1px white dashed;
|
||||
}
|
||||
|
||||
#cacheButton.disabled {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<link rel="stylesheet" href="basic.css">
|
||||
<script src="moth-pwa.js" type="text/javascript"></script>
|
||||
<script src="moth.js"></script>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="title">MOTH</h1>
|
||||
|
@ -32,6 +34,7 @@
|
|||
<ul>
|
||||
<li><a href="scoreboard.html">Scoreboard</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>
|
||||
</nav>
|
||||
</body>
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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) {
|
||||
|
@ -191,7 +276,8 @@ function init() {
|
|||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
document.addEventListener("DOMContentLoaded", init)
|
||||
} else {
|
||||
init();
|
||||
init()
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
<link rel="stylesheet" href="basic.css">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta charset="utf-8">
|
||||
<script src="moth-pwa.js"></script>
|
||||
<script src="puzzle.js"></script>
|
||||
<script>
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
<title>Scoreboard</title>
|
||||
<link rel="stylesheet" href="basic.css">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<script src="moth-pwa.js"></script>
|
||||
<script src="moment.min.js" async></script>
|
||||
<script src="Chart.min.js" async></script>
|
||||
<script src="scoreboard.js" async></script>
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
Loading…
Reference in New Issue