Compare commits

..

2 Commits

Author SHA1 Message Date
Neale Pickett c0761933a9 KSA report finished, config.json 2023-09-14 17:42:02 -06:00
Neale Pickett 4ce0dcf11a Stop accepting empty team ID in devel mode 2023-09-14 14:47:20 -06:00
14 changed files with 48618 additions and 112 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- We are now using djb2xor instead of sha256 to hash puzzle answers - We are now using djb2xor instead of sha256 to hash puzzle answers
- Lots of work on the built-in theme - Lots of work on the built-in theme
- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript - [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript
- Devel mode no longer accepts an empty team ID
## [v4.4.9] - 2022-05-12 ## [v4.4.9] - 2022-05-12
### Changed ### Changed

View File

@ -128,7 +128,7 @@ func TestHttpd(t *testing.T) {
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 { if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"error awarding points: points already awarded to this team in this category"}}` { } else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"points already awarded to this team in this category"}}` {
t.Error("Unexpected body", r.Body.String()) t.Error("Unexpected body", r.Body.String())
} }
} }

View File

@ -156,7 +156,7 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string)
return fmt.Errorf("invalid team ID") return fmt.Errorf("invalid team ID")
} }
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil { if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
return fmt.Errorf("error awarding points: %s", err) return err
} }
return nil return nil

View File

@ -522,6 +522,9 @@ func (ds *DevelState) TeamName(teamID string) (string, error) {
if name, err := ds.StateProvider.TeamName(teamID); err == nil { if name, err := ds.StateProvider.TeamName(teamID); err == nil {
return name, nil return name, nil
} }
if teamID == "" {
return "", fmt.Errorf("Empty team ID")
}
return fmt.Sprintf("«devel:%s»", teamID), nil return fmt.Sprintf("«devel:%s»", teamID), nil
} }

View File

@ -75,6 +75,9 @@ input {
.category h2 { .category h2 {
margin: 0 0.2em; margin: 0 0.2em;
} }
.category .solved {
text-decoration: line-through;
}
nav ul, .category ul { nav ul, .category ul {
margin: 0; margin: 0;
padding: 0.2em 1em; padding: 0.2em 1em;

View File

@ -34,10 +34,30 @@ function WhenDOMLoaded(cb) {
} }
} }
/**
* Interprets a String as a Boolean.
*
* Values like "no" or "disabled" to mean false here.
*
* @param {String} s
* @returns {Boolean}
*/
function Truthy(s) {
switch (s.toLowerCase()) {
case "disabled":
case "no":
case "off":
case "false":
return false
}
return true
}
export { export {
Millisecond, Millisecond,
Second, Second,
Minute, Minute,
Toast, Toast,
WhenDOMLoaded, WhenDOMLoaded,
Truthy,
} }

4
theme/config.json Normal file
View File

@ -0,0 +1,4 @@
{
"TrackSolved": true,
"__sentry__": "this is here so you don't have to remember to take the comma off the last item"
}

View File

@ -22,9 +22,19 @@
</form> </form>
<div class="puzzles"></div> <div class="puzzles"></div>
<div class="toasts"></div>
</main> </main>
<div class="notification" data-track-solved="no">
<p>
Solved puzzle tracking: <b>disabled</b>.
</p>
<p>
Your team's Incident Coordinator can help coordinate team activity.
</p>
</div>
<div class="toasts"></div>
<nav> <nav>
<ul> <ul>
<li><a href="scoreboard.html" target="_blank">Scoreboard</a></li> <li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>

View File

@ -6,11 +6,14 @@ import * as common from "./common.mjs"
class App { class App {
constructor(basePath=".") { constructor(basePath=".") {
this.configURL = new URL("config.json", location)
this.config = {}
this.server = new moth.Server(basePath) this.server = new moth.Server(basePath)
let uuid = Math.floor(Math.random() * 1000000).toString(16) let uuid = Math.floor(Math.random() * 1000000).toString(16)
this.fakeRegistration = { this.fakeRegistration = {
TeamId: uuid, TeamID: uuid,
TeamName: `Team ${uuid}`, TeamName: `Team ${uuid}`,
} }
@ -21,8 +24,11 @@ class App {
e.addEventListener("click", () => this.Logout()) e.addEventListener("click", () => this.Logout())
} }
setInterval(() => this.Update(), common.Minute/3) setInterval(() => this.UpdateState(), common.Minute/3)
this.Update() setInterval(() => this.UpdateConfig(), common.Minute* 5)
this.UpdateConfig()
.finally(() => this.UpdateState())
} }
handleLoginSubmit(event) { handleLoginSubmit(event) {
@ -33,14 +39,14 @@ class App {
/** /**
* Attempt to log in to the server. * Attempt to log in to the server.
* *
* @param {String} teamId * @param {String} teamID
* @param {String} teamName * @param {String} teamName
*/ */
async Login(teamId, teamName) { async Login(teamID, teamName) {
try { try {
await this.server.Login(teamId, teamName) await this.server.Login(teamID, teamName)
common.Toast(`Logged in (team id = ${teamId})`) common.Toast(`Logged in (team id = ${teamID})`)
this.Update() this.UpdateState()
} }
catch (error) { catch (error) {
common.Toast(error) common.Toast(error)
@ -54,13 +60,24 @@ class App {
try { try {
this.server.Reset() this.server.Reset()
common.Toast("Logged out") common.Toast("Logged out")
this.Update() this.UpdateState()
} }
catch (error) { catch (error) {
common.Toast(error) common.Toast(error)
} }
} }
/**
* Update app configuration.
*
* Configuration can be updated less frequently than state, to reduce server
* load, since configuration should (hopefully) change less frequently.
*/
async UpdateConfig() {
let resp = await fetch(this.configURL)
this.config = await resp.json()
}
/** /**
* Update the entire page. * Update the entire page.
* *
@ -68,12 +85,18 @@ class App {
* what's returned. If we're in development mode and not logged in, auto * what's returned. If we're in development mode and not logged in, auto
* login too. * login too.
*/ */
async Update() { async UpdateState() {
this.state = await this.server.GetState() this.state = await this.server.GetState()
for (let e of document.querySelectorAll(".messages")) { for (let e of document.querySelectorAll(".messages")) {
e.innerHTML = this.state.Messages e.innerHTML = this.state.Messages
} }
// Update elements with data-track-solved
for (let e of document.querySelectorAll("[data-track-solved]")) {
// Only display if data-track-solved is the same as config.trackSolved
e.classList.toggle("hidden", common.Truthy(e.dataset.trackSolved) != this.config.TrackSolved)
}
for (let e of document.querySelectorAll(".login")) { for (let e of document.querySelectorAll(".login")) {
this.renderLogin(e, !this.server.LoggedIn()) this.renderLogin(e, !this.server.LoggedIn())
} }
@ -84,7 +107,7 @@ class App {
if (this.state.DevelopmentMode() && !this.server.LoggedIn()) { if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
common.Toast("Automatically logging in to devel server") common.Toast("Automatically logging in to devel server")
console.info("Logging in with generated Team ID and Team Name", this.fakeRegistration) console.info("Logging in with generated Team ID and Team Name", this.fakeRegistration)
return this.Login(this.fakeRegistration.TeamId, this.fakeRegistration.TeamName) return this.Login(this.fakeRegistration.TeamID, this.fakeRegistration.TeamName)
} }
} }
@ -133,9 +156,13 @@ class App {
a.textContent = puzzle.Points a.textContent = puzzle.Points
a.href = url a.href = url
a.target = "_blank" a.target = "_blank"
if (this.config.TrackSolved) {
a.classList.toggle("solved", this.state.IsSolved(puzzle))
}
} }
if (!this.state.HasUnsolved(cat)) { if (!this.state.ContainsUnsolved(cat)) {
l.appendChild(document.createElement("li")).textContent = "✿" l.appendChild(document.createElement("li")).textContent = "✿"
} }

View File

@ -130,7 +130,7 @@ class Puzzle {
/** Point value of this puzzle */ /** Point value of this puzzle */
this.Points = Number(points) this.Points = Number(points)
/** Error returned trying to fetch this puzzle */ /** Error returned trying to retrieve this puzzle */
this.Error = { this.Error = {
/** Status code provided by server */ /** Status code provided by server */
Status: 0, Status: 0,
@ -265,7 +265,7 @@ class State {
/** Log of points awarded /** Log of points awarded
* @type {Award[]} * @type {Award[]}
*/ */
this.PointsLog = obj.PointsLog.map((t,i,c,p) => new Award(t,i,c,p)) this.PointsLog = obj.PointsLog.map(entry => new Award(entry[0], entry[1], entry[2], entry[3]))
} }
/** /**
@ -283,7 +283,7 @@ class State {
} }
/** /**
* Check whether a category has unsolved puzzles. * Check whether a category contains unsolved puzzles.
* *
* The server adds a puzzle with 0 points in every "solved" category, * The server adds a puzzle with 0 points in every "solved" category,
* so this just checks whether there is a 0-point puzzle in the category's point list. * so this just checks whether there is a 0-point puzzle in the category's point list.
@ -291,7 +291,7 @@ class State {
* @param {String} category * @param {String} category
* @returns {Boolean} * @returns {Boolean}
*/ */
HasUnsolved(category) { ContainsUnsolved(category) {
return !this.PointsByCategory[category].includes(0) return !this.PointsByCategory[category].includes(0)
} }
@ -330,6 +330,26 @@ class State {
} }
return ret return ret
} }
/**
* Has this puzzle been solved by this team?
*
* @param {Puzzle} puzzle
* @param {String} teamID Team to check, default the logged-in team
* @returns {Boolean}
*/
IsSolved(puzzle, teamID="self") {
for (let award of this.PointsLog) {
if (
(award.Category == puzzle.Category)
&& (award.Points == puzzle.Points)
&& (award.TeamID == teamID)
) {
return true
}
}
return false
}
} }
/** /**
@ -347,8 +367,8 @@ class Server {
throw("Must provide baseURL") throw("Must provide baseURL")
} }
this.baseUrl = new URL(baseUrl, location) this.baseUrl = new URL(baseUrl, location)
this.teamIdKey = this.baseUrl.toString() + " teamID" this.teamIDKey = this.baseUrl.toString() + " teamID"
this.TeamId = localStorage[this.teamIdKey] this.TeamID = localStorage[this.teamIDKey]
} }
/** /**
@ -357,30 +377,24 @@ class Server {
* If anything other than a 2xx code is returned, * If anything other than a 2xx code is returned,
* this function throws an error. * this function throws an error.
* *
* This always sends teamId. * This always sends teamID.
* If args is set, POST will be used instead of GET * If args is set, POST will be used instead of GET
* *
* @param {String} path Path to API endpoint * @param {String} path Path to API endpoint
* @param {Object.<String,String>} args Key/Values to send in POST data * @param {Object.<String,String>} args Key/Values to send in POST data
* @returns {Promise.<Response>} Response * @returns {Promise.<Response>} Response
*/ */
fetch(path, args) { fetch(path, args={}) {
let url = new URL(path, this.baseUrl) let body = new URLSearchParams(args)
if (this.TeamId & (!(args && args.id))) { if (this.TeamID && !body.has("id")) {
url.searchParams.set("id", this.TeamId) body.set("id", this.TeamID)
} }
if (args) { let url = new URL(path, this.baseUrl)
let formData = new FormData() return fetch(url, {
for (let k in args) { method: "POST",
formData.set(k, args[k]) body,
} })
return fetch(url, {
method: "POST",
body: formData,
})
}
return fetch(url)
} }
/** /**
@ -390,7 +404,7 @@ class Server {
* @param {Object.<String,String>} args Key/Values to send in POST * @param {Object.<String,String>} args Key/Values to send in POST
* @returns {Promise.<Object>} JSend Data * @returns {Promise.<Object>} JSend Data
*/ */
async call(path, args) { async call(path, args={}) {
let resp = await this.fetch(path, args) let resp = await this.fetch(path, args)
let obj = await resp.json() let obj = await resp.json()
switch (obj.status) { switch (obj.status) {
@ -408,6 +422,9 @@ class Server {
/** /**
* Make a new URL for the given resource. * Make a new URL for the given resource.
* *
* The returned URL instance will be absolute, and immune to changes to the
* page that would affect relative URLs.
*
* @returns {URL} * @returns {URL}
*/ */
URL(url) { URL(url) {
@ -420,7 +437,7 @@ class Server {
* @returns {Boolean} * @returns {Boolean}
*/ */
LoggedIn() { LoggedIn() {
return this.TeamId ? true : false return this.TeamID ? true : false
} }
/** /**
@ -429,8 +446,8 @@ class Server {
* This is equivalent to logging out. * This is equivalent to logging out.
*/ */
Reset() { Reset() {
localStorage.removeItem(this.teamIdKey) localStorage.removeItem(this.teamIDKey)
this.TeamId = null this.TeamID = null
} }
/** /**
@ -450,15 +467,15 @@ class Server {
* This calls the server's registration endpoint; if the call succeds, or * This calls the server's registration endpoint; if the call succeds, or
* fails with "team already exists", the login is returned as successful. * fails with "team already exists", the login is returned as successful.
* *
* @param {String} teamId * @param {String} teamID
* @param {String} teamName * @param {String} teamName
* @returns {Promise.<String>} Success message from server * @returns {Promise.<String>} Success message from server
*/ */
async Login(teamId, teamName) { async Login(teamID, teamName) {
let data = await this.call("/register", {id: teamId, name: teamName}) let data = await this.call("/register", {id: teamID, name: teamName})
this.TeamId = teamId this.TeamID = teamID
this.TeamName = teamName this.TeamName = teamName
localStorage[this.teamIdKey] = teamId localStorage[this.teamIDKey] = teamID
return data.description || data.short return data.description || data.short
} }

View File

@ -26,11 +26,11 @@ async function formSubmitHandler(event) {
console.info(`Proposed answer: ${proposed}`) console.info(`Proposed answer: ${proposed}`)
try { try {
message = await window.app.puzzle.SubmitAnswer(proposed) message = await window.app.puzzle.SubmitAnswer(proposed)
common.Toast(message)
} }
catch (err) { catch (err) {
common.Toast(err) common.Toast(err)
} }
common.Toast(message)
console.groupEnd("Submit answer") console.groupEnd("Submit answer")
} }

48340
theme/reports/NICEFramework2017.json Executable file

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,55 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title>KSA Report</title> <title>KSA Report</title>
<script src="ksa.mjs" type="module"></script> <meta name="viewport" content="width=device-width">
<meta charset="utf-8">
<script src="ksa.mjs" type="module" async></script>
<script src="../background.mjs" type="module" async></script>
<link rel="stylesheet" href="../basic.css"> <link rel="stylesheet" href="../basic.css">
</head> </head>
<body> <body>
<h1>KSA Report</h1> <h1>KSA Report</h1>
<p> <main>
This report shows all KSAs covered by this server so far. <p>
This is not a report on your progress, but rather This report shows all KSAs covered by this server so far.
what you would have covered if you had worked every exercise available. This is not a report on your progress, but rather
</p> what you would have covered if you had worked every exercise available.
</p>
<div class="notification">
<p class="doing"></p>
<progress class="doing"></progress>
</div>
<h2>All KSAs across all content</h2>
<ul class="allKSAs"></ul>
<div class="KSAsByCategory">
<h2>All KSAs by Category</h2> <h2>All KSAs by Category</h2>
</div> <div class="KSAsByCategory">
</div>
<table class="puzzles"> <h2>KSAs by Puzzle</h2>
<thead> <table class="puzzles">
<tr> <thead>
<th>Category</th>
<th>Points</th>
<th>KSAs</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
<template id="puzzlerow">
<tr> <tr>
<td class="category"></td> <th>Category</th>
<td class="points"></td> <th>Points</th>
<td class="ksas"></td> <th>KSAs</th>
<td><pre class="error"></pre></td> <th>Errors</th>
</tr> </tr>
</template> </thead>
</tbody> <tbody>
</table> <template id="puzzlerow">
<tr>
<td class="category"></td>
<td class="points"></td>
<td class="ksas"></td>
<td><pre class="error"></pre></td>
</tr>
</template>
</tbody>
</table>
</main>
</body> </body>
</html> </html>

View File

@ -1,73 +1,140 @@
import * as moth from "../moth.mjs" import * as moth from "../moth.mjs"
import * as common from "../common.mjs"
function doing(what) { const server = new moth.Server("../")
/**
* Update "doing" indicators
*
* @param {String | null} what Text to display, or null to not update text
* @param {Number | null} finished Percentage complete to display, or null to not update progress
*/
function doing(what, finished = null) {
for (let e of document.querySelectorAll(".doing")) { for (let e of document.querySelectorAll(".doing")) {
e.classList.remove("hidden")
if (what) { if (what) {
e.style.display = "inherit" e.textContent = what
}
if (finished) {
e.value = finished
} else { } else {
e.style.display = "none" e.removeAttribute("value")
}
for (let p of e.querySelectorAll("p")) {
p.textContent = what
} }
} }
} }
function done() {
for (let e of document.querySelectorAll(".doing")) {
e.classList.add("hidden")
}
}
async function GetNice() {
let NiceElementsByIdentifier = {}
let resp = await fetch("NICEFramework2017.json")
let obj = await resp.json()
for (let e of obj.elements) {
NiceElementsByIdentifier[e.element_identifier] = e
}
return NiceElementsByIdentifier
}
/**
* Fetch a puzzle, and fill its KSAs and rows.
*
* This is done once per puzzle, in an asynchronous function, allowing the
* application to perform multiple blocking operations simultaneously.
*/
async function FetchAndFill(puzzle, KSAs, rows) {
try {
await puzzle.Populate()
}
catch (error) {
// Keep on going with whatever Populate was able to fill
}
for (let KSA of (puzzle.KSAs || [])) {
KSAs.add(KSA)
}
for (let row of rows) {
row.querySelector(".category").textContent = puzzle.Category
row.querySelector(".points").textContent = puzzle.Points
row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ")
row.querySelector(".error").textContent = puzzle.Error.Body
}
}
async function init() { async function init() {
let server = new moth.Server("../") doing("Fetching NICE framework data")
let nicePromise = GetNice()
doing("Retrieving server state") doing("Retrieving server state")
let state = await server.GetState() let state = await server.GetState()
doing("Retrieving all puzzles") doing("Retrieving all puzzles")
let KSAsByCategory = {}
let puzzlerowTemplate = document.querySelector("template#puzzlerow") let puzzlerowTemplate = document.querySelector("template#puzzlerow")
let puzzles = state.Puzzles() let puzzles = state.Puzzles()
for (let puzzle of puzzles) { let promises = []
await puzzle.Populate().catch(x => {}) for (let category of state.Categories()) {
KSAsByCategory[category] = new Set()
} }
let pending = puzzles.length
doing("Filling tables")
let KSAsByCategory = {}
for (let puzzle of puzzles) { for (let puzzle of puzzles) {
let KSAs = KSAsByCategory[puzzle.Category] // Make space in the table, so everything fills in sorted order
if (!KSAs) { let rows = []
KSAs = new Set()
KSAsByCategory[puzzle.Category] = KSAs
}
for (let KSA of (puzzle.KSAs || [])) {
KSAs.add(KSA)
}
for (let tbody of document.querySelectorAll("tbody")) { for (let tbody of document.querySelectorAll("tbody")) {
let row = puzzlerowTemplate.content.cloneNode(true) let row = puzzlerowTemplate.content.cloneNode(true).firstElementChild
row.querySelector(".category").textContent = puzzle.Category
row.querySelector(".points").textContent = puzzle.Points
row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ")
row.querySelector(".error").textContent = puzzle.Error.Body
tbody.appendChild(row) tbody.appendChild(row)
rows.push(row)
}
// Queue up a fetch, and update progress bar
let promise = FetchAndFill(puzzle, KSAsByCategory[puzzle.Category], rows)
promises.push(promise)
promise.then(() => doing(null, 1 - (--pending / puzzles.length)))
if (promises.length > 50) {
// Chrome runs out of resources if you queue up too many of these at once
await Promise.all(promises)
promises = []
} }
} }
await Promise.all(promises)
doing("Retrieving NICE identifiers")
let NiceElementsByIdentifier = await nicePromise
doing("Filling KSAs By Category") doing("Filling KSAs By Category")
let allKSAs = new Set()
for (let div of document.querySelectorAll(".KSAsByCategory")) { for (let div of document.querySelectorAll(".KSAsByCategory")) {
for (let category of state.Categories()) { for (let category of state.Categories()) {
doing(`Filling KSAs for category: ${category}`)
let KSAs = [...KSAsByCategory[category]] let KSAs = [...KSAsByCategory[category]]
KSAs.sort() KSAs.sort()
div.appendChild(document.createElement("h3")).textContent = category div.appendChild(document.createElement("h3")).textContent = category
let ul = div.appendChild(document.createElement("ul")) let ul = div.appendChild(document.createElement("ul"))
for (let k of KSAs) { for (let k of KSAs) {
ul.appendChild(document.createElement("li")).textContent = k let ksa = k.split(/\s+/)[0]
let ne = NiceElementsByIdentifier[ksa] || { text: "???" }
let text = `${ksa}: ${ne.text}`
ul.appendChild(document.createElement("li")).textContent = text
allKSAs.add(text)
} }
} }
} }
doing() doing("Filling KSAs")
} for (let e of document.querySelectorAll(".allKSAs")) {
let KSAs = [...allKSAs]
if (document.readyState === "loading") { KSAs.sort()
document.addEventListener("DOMContentLoaded", init) for (let text of KSAs) {
} else { e.appendChild(document.createElement("li")).textContent = text
init() }
}
done()
} }
common.WhenDOMLoaded(init)