Merge pull request #89 from dirtbags/v3.4_devel

Release v3.4
This commit is contained in:
int00h5525 2019-11-13 16:34:22 -06:00 committed by GitHub
commit a393c923e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1035 additions and 370 deletions

18
CHANGELOG.md Normal file
View File

@ -0,0 +1,18 @@
# Changelog
All notable changes to this project will be documented in this file.
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).
## [Unreleased]
## [3.4] - 2019-11-13
### 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
### Changed
- Use native JS URL objects instead of wrangling everything by hand

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

View File

@ -1 +1 @@
3.3.2
3.4

View File

@ -224,8 +224,9 @@ sessionStorage.setItem("id", "devel-server")
},
)
url = urllib.parse.urlparse(self.path)
for pattern, function in self.endpoints:
result = parse.parse(pattern, self.path)
result = parse.parse(pattern, url.path)
if result:
self.req = result.named
seed = self.req.get("seed", "random")
@ -265,12 +266,24 @@ if __name__ == '__main__':
'--base', default="",
help="Base URL to this server, for reverse proxy setup"
)
parser.add_argument(
"-v", "--verbose",
action="count",
default=1, # Leave at 1, for now, to maintain current default behavior
help="Include more verbose logging. Use multiple flags to increase level",
)
args = parser.parse_args()
parts = args.bind.split(":")
addr = parts[0] or "0.0.0.0"
port = int(parts[1])
if args.verbose >= 2:
log_level = logging.DEBUG
elif args.verbose == 1:
log_level = logging.INFO
else:
log_level = logging.WARNING
logging.basicConfig(level=logging.INFO)
logging.basicConfig(level=log_level)
server = MothServer((addr, port), MothRequestHandler)
server.args["base_url"] = args.base

View File

@ -2,21 +2,26 @@
import argparse
import contextlib
import copy
import glob
import hashlib
import html
import io
import importlib.machinery
import logging
import mistune
import os
import random
import string
import sys
import tempfile
import shlex
import yaml
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
LOGGER = logging.getLogger(__name__)
def djb2hash(str):
h = 5381
for c in str.encode("utf-8"):
@ -26,10 +31,28 @@ def djb2hash(str):
@contextlib.contextmanager
def pushd(newdir):
curdir = os.getcwd()
LOGGER.debug("Attempting to chdir from %s to %s" % (curdir, newdir))
os.chdir(newdir)
# Force a copy of the old path, instead of just a reference
old_path = list(sys.path)
old_modules = copy.copy(sys.modules)
sys.path.append(newdir)
try:
yield
finally:
# Restore the old path
to_remove = []
for module in sys.modules:
if module not in old_modules:
to_remove.append(module)
for module in to_remove:
del(sys.modules[module])
sys.path = old_path
LOGGER.debug("Changing directory back from %s to %s" % (newdir, curdir))
os.chdir(curdir)
@ -136,8 +159,10 @@ class Puzzle:
stream.seek(0)
if header == "yaml":
LOGGER.info("Puzzle is YAML-formatted")
self.read_yaml_header(stream)
elif header == "moth":
LOGGER.info("Puzzle is MOTH-formatted")
self.read_moth_header(stream)
for line in stream:
@ -172,6 +197,7 @@ class Puzzle:
self.handle_header_key(key, val)
def handle_header_key(self, key, val):
LOGGER.debug("Handling key: %s, value: %s", key, val)
if key == 'author':
self.authors.append(val)
elif key == 'authors':
@ -199,6 +225,7 @@ class Puzzle:
parts = shlex.split(val)
name = parts[0]
hidden = False
LOGGER.debug("Attempting to open %s", os.path.abspath(name))
stream = open(name, 'rb')
try:
name = parts[1]
@ -367,7 +394,7 @@ class Puzzle:
files = [fn for fn,f in self.files.items() if f.visible]
return {
'authors': self.authors,
'authors': self.get_authors(),
'hashes': self.hashes(),
'files': files,
'scripts': self.scripts,
@ -415,6 +442,7 @@ class Category:
with pushd(self.path):
self.catmod.make(points, puzzle)
else:
with pushd(self.path):
puzzle.read_directory(path)
return puzzle

View File

@ -1,169 +0,0 @@
#!/usr/bin/env python3
import argparse
import binascii
import glob
import hashlib
import io
import json
import logging
import moth
import os
import shutil
import string
import sys
import tempfile
import zipfile
def write_kv_pairs(ziphandle, filename, kv):
""" Write out a sorted map to file
:param ziphandle: a zipfile object
:param filename: The filename to write within the zipfile object
:param kv: the map to write out
:return:
"""
filehandle = io.StringIO()
for key in sorted(kv.keys()):
if type(kv[key]) == type([]):
for val in kv[key]:
filehandle.write("%s %s\n" % (key, val))
else:
filehandle.write("%s %s\n" % (key, kv[key]))
filehandle.seek(0)
ziphandle.writestr(filename, filehandle.read())
def escape(s):
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def generate_html(ziphandle, puzzle, puzzledir, category, points, authors, files):
html_content = io.StringIO()
file_content = io.StringIO()
if files:
file_content.write(
''' <section id="files">
<h2>Associated files:</h2>
<ul>
''')
for fn in files:
file_content.write(' <li><a href="{fn}">{efn}</a></li>\n'.format(fn=fn, efn=escape(fn)))
file_content.write(
''' </ul>
</section>
''')
scripts = ['<script src="{}"></script>'.format(s) for s in puzzle.scripts]
html_content.write(
'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width">
<title>{category} {points}</title>
<link rel="stylesheet" href="../../style.css">
{scripts}
</head>
<body>
<h1>{category} for {points} points</h1>
<section id="readme">
{body} </section>
{file_content} <section id="form">
<form id="puzzler" action="../../cgi-bin/puzzler.cgi" method="get" accept-charset="utf-8" autocomplete="off">
<input type="hidden" name="c" value="{category}">
<input type="hidden" name="p" value="{points}">
<div>Team hash:<input name="t" size="8"></div>
<div>Answer:<input name="a" id="answer" size="20"></div>
<input type="submit" value="submit">
</form>
</section>
<address>Puzzle by <span class="authors" data-handle="{authors}">{authors}</span></address>
</body>
</html>'''.format(
category=category,
points=points,
body=puzzle.html_body(),
file_content=file_content.getvalue(),
authors=', '.join(authors),
scripts='\n'.join(scripts),
)
)
ziphandle.writestr(os.path.join(puzzledir, 'index.html'), html_content.getvalue())
def build_category(categorydir, outdir):
zipfileraw = tempfile.NamedTemporaryFile(delete=False)
zf = zipfile.ZipFile(zipfileraw, 'x')
category_seed = binascii.b2a_hex(os.urandom(20))
puzzles_dict = {}
secrets = {}
categoryname = os.path.basename(categorydir.strip(os.sep))
seedfn = os.path.join("category_seed.txt")
zipfilename = os.path.join(outdir, "%s.mb" % categoryname)
logging.info("Building {} from {}".format(zipfilename, categorydir))
if os.path.exists(zipfilename):
# open and gather some state
existing = zipfile.ZipFile(zipfilename, 'r')
try:
category_seed = existing.open(seedfn).read().strip()
except:
pass
existing.close()
logging.debug("Using PRNG seed {}".format(category_seed))
zf.writestr(seedfn, category_seed)
cat = moth.Category(categorydir, category_seed)
mapping = {}
answers = {}
summary = {}
for puzzle in cat:
logging.info("Processing point value {}".format(puzzle.points))
hashmap = hashlib.sha1(category_seed)
hashmap.update(str(puzzle.points).encode('utf-8'))
puzzlehash = hashmap.hexdigest()
mapping[puzzle.points] = puzzlehash
answers[puzzle.points] = puzzle.answers
summary[puzzle.points] = puzzle.summary
puzzledir = os.path.join('content', puzzlehash)
files = []
for fn, f in puzzle.files.items():
if f.visible:
files.append(fn)
payload = f.stream.read()
zf.writestr(os.path.join(puzzledir, fn), payload)
puzzledict = {
'authors': puzzle.authors,
'hashes': puzzle.hashes(),
'files': files,
'body': puzzle.html_body(),
}
puzzlejson = json.dumps(puzzledict)
zf.writestr(os.path.join(puzzledir, 'puzzle.json'), puzzlejson)
generate_html(zf, puzzle, puzzledir, categoryname, puzzle.points, puzzle.get_authors(), files)
write_kv_pairs(zf, 'map.txt', mapping)
write_kv_pairs(zf, 'answers.txt', answers)
write_kv_pairs(zf, 'summaries.txt', summary)
# clean up
zf.close()
shutil.move(zipfileraw.name, zipfilename)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Build a category package')
parser.add_argument('outdir', help='Output directory')
parser.add_argument('categorydirs', nargs='+', help='Directory of category source')
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG)
for categorydir in args.categorydirs:
build_category(categorydir, args.outdir)

View File

@ -0,0 +1,19 @@
import io
import categorylib # Category-level libraries can be imported here
def make(puzzle):
import puzzlelib # puzzle-level libraries can only be imported inside of the make function
puzzle.authors = ['donaldson']
puzzle.summary = 'more crazy stuff you can do with puzzle generation using Python libraries'
puzzle.body.write("## Crazy Things You Can Do With Puzzle Generation (part II)\n")
puzzle.body.write("\n")
puzzle.body.write("The source to this puzzle has some more advanced examples of stuff you can do in Python.\n")
puzzle.body.write("\n")
puzzle.body.write("1 == %s\n\n" % puzzlelib.getone(),)
puzzle.body.write("2 == %s\n\n" % categorylib.gettwo(),)
puzzle.answers.append('tea')
answer = puzzle.make_answer() # Generates a random answer, appending it to puzzle.answers too
puzzle.log("Answers: {}".format(puzzle.answers))

View File

@ -0,0 +1,7 @@
"""This is an example of a puzzle-level library.
This library can be imported by sibling puzzles using `import puzzlelib`
"""
def getone():
return 1

View File

@ -0,0 +1,7 @@
"""This is an example of a category-level library.
This library can be imported by child puzzles using `import categorylib`
"""
def gettwo():
return 2

View File

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

View File

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

View File

@ -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 {

View File

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

7
theme/Chart.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,9 @@ body {
background: #282a33;
color: #f6efdc;
}
body.wide {
max-width: 100%;
}
a:any-link {
color: #8b969a;
}
@ -133,3 +136,7 @@ li[draggable] {
[draggable].over {
border: 1px white dashed;
}
#cacheButton.disabled {
display: none;
}

View File

@ -5,16 +5,25 @@
<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>
<section>
<div id="messages"></div>
<div id="messages">
<div id="notices"></div>
</div>
<form id="login">
Team name: <input name="name">
<!--
<span id="pid">
Participant ID: <input name="pid"> (optional) <br>
</span>
-->
Team ID: <input name="id"> <br>
Team name: <input name="name"> <br>
<input type="submit" value="Sign In">
</form>
@ -25,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>

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

1
theme/moment.min.js vendored Normal file

File diff suppressed because one or more lines are too long

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

@ -14,6 +14,13 @@ function toast(message, timeout=5000) {
)
}
function renderNotices(obj) {
let ne = document.getElementById("notices")
if (ne) {
ne.innerHTML = obj
}
}
function renderPuzzles(obj) {
let puzzlesElement = document.createElement('div')
@ -62,7 +69,11 @@ function renderPuzzles(obj) {
let a = document.createElement('a')
i.appendChild(a)
a.textContent = points
a.href = "puzzle.html?cat=" + cat + "&points=" + points + "&pid=" + id
let url = new URL("puzzle.html", window.location)
url.searchParams.set("cat", cat)
url.searchParams.set("points", points)
url.searchParams.set("pid", id)
a.href = url.toString()
}
}
@ -77,13 +88,27 @@ function renderPuzzles(obj) {
container.appendChild(puzzlesElement)
}
function heartbeat(teamId) {
function heartbeat(teamId, participantId) {
let noticesUrl = new URL("notices.html", window.location)
fetch(noticesUrl)
.then(resp => {
if (resp.ok) {
resp.text()
.then(renderNotices)
.catch(err => console.log)
}
})
.catch(err => console.log)
let url = new URL("puzzles.json", window.location)
url.searchParams.set("id", teamId)
if (participantId) {
url.searchParams.set("pid", participantId)
}
let fd = new FormData()
fd.append("id", teamId)
fetch("puzzles.json", {
method: "POST",
body: fd,
})
fetch(url)
.then(resp => {
if (resp.ok) {
resp.json()
@ -100,22 +125,111 @@ function heartbeat(teamId) {
})
}
function showPuzzles(teamId) {
function showPuzzles(teamId, participantId) {
let spinner = document.createElement("span")
spinner.classList.add("spinner")
sessionStorage.setItem("id", teamId)
if (participantId) {
sessionStorage.setItem("pid", participantId)
}
document.getElementById("login").style.display = "none"
document.getElementById("puzzles").appendChild(spinner)
heartbeat(teamId)
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) {
e.preventDefault()
let name = document.querySelector("[name=name]").value
let id = document.querySelector("[name=id]").value
let teamId = document.querySelector("[name=id]").value
let pide = document.querySelector("[name=pid]")
let participantId = pide?pide.value:""
fetch("register", {
method: "POST",
@ -127,10 +241,10 @@ function login(e) {
.then(obj => {
if (obj.status == "success") {
toast("Team registered")
showPuzzles(id)
showPuzzles(teamId, participantId)
} else if (obj.data.short == "Already registered") {
toast("Logged in with previously-registered team name")
showPuzzles(id)
showPuzzles(teamId, participantId)
} else {
toast(obj.data.description)
}
@ -152,16 +266,18 @@ function login(e) {
function init() {
// Already signed in?
let id = sessionStorage.getItem("id")
if (id) {
showPuzzles(id)
let teamId = sessionStorage.getItem("id")
let participantId = sessionStorage.getItem("pid")
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()
}

1
theme/notices.html Normal file
View File

@ -0,0 +1 @@
<!-- notices.html: contents will be rendered verbatim in the puzzles overview. -->

File diff suppressed because one or more lines are too long

View File

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

54
theme/puzzles.json Normal file
View File

@ -0,0 +1,54 @@
{
"__comment__": [
"This file is to help debug themes.",
"MOTHd will ignore it."
],
"codebreaking": [
[1,"37117e6b034696b86c6516477cc0bc60bc1e642e"],
[2,"546b586428979771b061608489327da4940086a7"],
[4,"6f2a33c93f56b4f29cc79e6576ba4d1000aa1756"],
[5,"c654fe263909b1940d7aad8c572363a0569c07c6"],
[6,"f30bd32bf940f2bb03506ec334d2d204efc4695b"],
[7,"128b119083b6ae70c380a8eb70ec6a518425e7af"],
[8,"edd4f57aeb565b3b053fa194f5e677cb77ef0285"],
[15,"9781863bca9f596972e2a10460932ec5ec6be3fe"]
],
"nocode": [
[1,"37117e6b034696b86c6516477cc0bc60bc1e642e"],
[2,"546b586428979771b061608489327da4940086a7"],
[3,"79c08697a1923da1118fd0c2e922b5d3899cabcc"],
[4,"6f2a33c93f56b4f29cc79e6576ba4d1000aa1756"],
[10,"bf4fae263bf6e4243b143f4ecd64e471f3ec75dd"],
[20,"9f374f6dac9f972fac4693099a7bfa7c535f7503"],
[30,"02de1196d43976b2d050c6c597f068623d2df201"],
[50,"9acb3af947cb4aa10a9c1221c04518f956cdc0d0"],
[80,"78f807ac44f3cbf537861e7cdf1ac53937e4ee47"],
[90,"6d537653aa599178c72528f7e1f2fbb36e6333f9"],
[100,"4f5982a3a7cc9b9af0320130132e8cab39a1fd2c"]
],
"sequence": [
[1,"37117e6b034696b86c6516477cc0bc60bc1e642e"],
[2,"546b586428979771b061608489327da4940086a7"],
[8,"edd4f57aeb565b3b053fa194f5e677cb77ef0285"],
[16,"a9ace4b773f045c422260edefaa8563dcd80ac59"],
[19,"f11ca0172451f37ba6f4d66ff9add80013480a49"],
[25,"0458533d28705548829e53d686215cc6fbeec8f5"],
[35,"91aac06bae090ae7d1699b5a78601ef8d29e9271"],
[50,"9acb3af947cb4aa10a9c1221c04518f956cdc0d0"],
[60,"bf84beed9e382268ab40d0113dfeb73c96aa919a"],
[100,"4f5982a3a7cc9b9af0320130132e8cab39a1fd2c"],
[200,"3b9b8993fe639cf0c19a58b39ebbf6077828887a"],
[300,"0f13c4d19bc5d2e10d43e8cd2e40f759e731cece"],
[400,"db7a59f313818fc9598969d2a0a04e21bd26697f"],
[500,"81c5389eb5406aa44053662f6482f246b8a12e0c"]
],
"steg": [
[1,"200e8cd902ba7304765c463f6ed1322bc25f3454"],
[2,"707328988c3986d450d8fe419eb49f078fb7998c"],
[3,"d0b336ad59cbcd4415ddf200c6c099db5c3fea1d"],
[4,"f071503b403ffee2b38e186e800bfd5dd28e8f0e"],
[5,"186f425fa5762ef37f874cc602fe0edc4325a5d2"],
[6,"c6527c3c30c4e6a33026192d358d83d259cd17a7"],
[10,"84973f77a1b14e4666f3d8a8bdeead7633c4ed56"]
]
}

View File

@ -4,130 +4,15 @@
<title>Scoreboard</title>
<link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width">
<script>
function update(state) {
let element = document.getElementById("scoreboard");
let teamnames = state["teams"];
let pointslog = state["points"];
let highscore = {};
let teams = {};
// Every machine that's displaying the scoreboard helpfully stores the last 20 values of
// points.json for us, in case of catastrophe. Thanks, y'all!
//
// We have been doing some letiation on this "everybody backs up the server state" trick since 2009.
// We have needed it 0 times.
let pointshistory = JSON.parse(localStorage.getItem("pointshistory")) || [];
if (pointshistory.length >= 20){
pointshistory.shift();
}
pointshistory.push(pointslog);
localStorage.setItem("pointshistory", JSON.stringify(pointshistory));
// Dole out points
for (let i in pointslog) {
let entry = pointslog[i];
let timestamp = entry[0];
let teamhash = entry[1];
let category = entry[2];
let points = entry[3];
let team = teams[teamhash] || {__hash__: teamhash};
// Add points to team's points for that category
team[category] = (team[category] || 0) + points;
// Record highest score in a category
highscore[category] = Math.max(highscore[category] || 0, team[category]);
teams[teamhash] = team;
}
// Sort by team score
function teamScore(t) {
let score = 0;
for (let category in highscore) {
score += (t[category] || 0) / highscore[category];
}
return score;
}
function teamCompare(a, b) {
return teamScore(a) - teamScore(b);
}
// Figure out how to order each team on the scoreboard
let winners = [];
for (let i in teams) {
winners.push(teams[i]);
}
winners.sort(teamCompare);
winners.reverse();
// Clear out the element we're about to populate
Array.from(element.childNodes).map(e => e.remove());
let maxWidth = 100 / Object.keys(highscore).length;
for (let i in winners) {
let team = winners[i];
let row = document.createElement("div");
let ncat = 0;
for (let category in highscore) {
let catHigh = highscore[category];
let catTeam = team[category] || 0;
let catPct = catTeam / catHigh;
let width = maxWidth * catPct;
let bar = document.createElement("span");
bar.classList.add("category");
bar.classList.add("cat" + ncat);
bar.style.width = width + "%";
bar.textContent = category + ": " + catTeam;
bar.title = bar.textContent;
row.appendChild(bar);
ncat += 1;
}
let te = document.createElement("span");
te.classList.add("teamname");
te.textContent = teamnames[team.__hash__];
row.appendChild(te);
element.appendChild(row);
}
}
function once() {
fetch("points.json")
.then(resp => {
return resp.json();
})
.then(obj => {
update(obj);
})
.catch(err => {
console.log(err);
});
}
function init() {
let base = window.location.href.replace("scoreboard.html", "")
document.querySelector("#location").textContent = base
setInterval(once, 60000);
once();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
</script>
<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>
</head>
<body>
<h1 class="Success">Scoreboard</h1>
<body class="wide">
<h4 id="location"></h4>
<section>
<canvas id="chart"></canvas>
<div id="scoreboard"></div>
</section>
<nav>

224
theme/scoreboard.js Normal file
View File

@ -0,0 +1,224 @@
// jshint asi:true
chartColors = [
"rgb(255, 99, 132)",
"rgb(255, 159, 64)",
"rgb(255, 205, 86)",
"rgb(75, 192, 192)",
"rgb(54, 162, 235)",
"rgb(153, 102, 255)",
"rgb(201, 203, 207)"
]
function update(state) {
let element = document.getElementById("scoreboard")
let teamNames = state.teams
let pointsLog = state.points
// Every machine that's displaying the scoreboard helpfully stores the last 20 values of
// points.json for us, in case of catastrophe. Thanks, y'all!
//
// We have been doing some variation on this "everybody backs up the server state" trick since 2009.
// We have needed it 0 times.
let pointsHistory = JSON.parse(localStorage.getItem("pointsHistory")) || []
if (pointsHistory.length >= 20) {
pointsHistory.shift()
}
pointsHistory.push(pointsLog)
localStorage.setItem("pointsHistory", JSON.stringify(pointsHistory))
let teams = {}
let highestCategoryScore = {} // map[string]int
// Initialize data structures
for (let teamId in teamNames) {
teams[teamId] = {
categoryScore: {}, // map[string]int
overallScore: 0, // int
historyLine: [], // []{x: int, y: int}
name: teamNames[teamId],
id: teamId
}
}
// Dole out points
for (let entry of pointsLog) {
let timestamp = entry[0]
let teamId = entry[1]
let category = entry[2]
let points = entry[3]
let team = teams[teamId]
let score = team.categoryScore[category] || 0
score += points
team.categoryScore[category] = score
let highest = highestCategoryScore[category] || 0
if (score > highest) {
highestCategoryScore[category] = score
}
}
for (let entry of pointsLog) {
let timestamp = entry[0]
let teamId = entry[1]
let category = entry[2]
let points = entry[3]
let team = teams[teamId]
let score = team.categoryScore[category] || 0
score += points
team.categoryScore[category] = score
let overall = 0
for (let cat in team.categoryScore) {
overall += team.categoryScore[cat] / highestCategoryScore[cat]
}
team.historyLine.push({t: new Date(timestamp * 1000), y: overall})
}
// Compute overall scores based on current highest
for (let teamId in teams) {
let team = teams[teamId]
team.overallScore = 0
for (let cat in team.categoryScore) {
team.overallScore += team.categoryScore[cat] / highestCategoryScore[cat]
}
}
// Sort by team score
function teamCompare(a, b) {
return a.overallScore - b.overallScore
}
// Figure out how to order each team on the scoreboard
let winners = []
for (let teamId in teams) {
winners.push(teams[teamId])
}
winners.sort(teamCompare)
winners.reverse()
// Clear out the element we're about to populate
Array.from(element.childNodes).map(e => e.remove())
let maxWidth = 100 / Object.keys(highestCategoryScore).length
for (let team of winners) {
let row = document.createElement("div")
let ncat = 0
for (let category in highestCategoryScore) {
let catHigh = highestCategoryScore[category]
let catTeam = team.categoryScore[category] || 0
let catPct = catTeam / catHigh
let width = maxWidth * catPct
let bar = document.createElement("span")
bar.classList.add("category")
bar.classList.add("cat" + ncat)
bar.style.width = width + "%"
bar.textContent = category + ": " + catTeam
bar.title = bar.textContent
row.appendChild(bar)
ncat += 1
}
let te = document.createElement("span")
te.classList.add("teamname")
te.textContent = team.name
row.appendChild(te)
element.appendChild(row)
}
let datasets = []
for (let i in winners) {
if (i > 5) {
break
}
let team = winners[i]
let color = chartColors[i % chartColors.length]
datasets.push({
label: team.name,
backgroundColor: color,
borderColor: color,
data: team.historyLine,
lineTension: 0,
fill: false
})
}
let config = {
type: "line",
data: {
datasets: datasets
},
options: {
responsive: true,
scales: {
xAxes: [{
display: true,
type: "time",
time: {
tooltipFormat: "ll HH:mm"
},
scaleLabel: {
display: true,
labelString: "Time"
}
}],
yAxes: [{
display: true,
scaleLabel: {
display: true,
labelString: "Points"
}
}]
},
tooltips: {
mode: "index",
intersect: false
},
hover: {
mode: "nearest",
intersect: true
}
}
}
let ctx = document.getElementById("chart").getContext("2d")
window.myline = new Chart(ctx, config)
window.myline.update()
}
function once() {
fetch("points.json")
.then(resp => {
return resp.json()
})
.then(obj => {
update(obj)
})
.catch(err => {
console.log(err)
})
}
function init() {
let base = window.location.href.replace("scoreboard.html", "")
let location = document.querySelector("#location")
if (location) {
location.textContent = base
}
setInterval(once, 60000)
once()
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

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