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
- Lots of work on the built-in theme
- [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
### 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 {
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())
}
}

View File

@ -156,7 +156,7 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string)
return fmt.Errorf("invalid team ID")
}
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
return fmt.Errorf("error awarding points: %s", err)
return err
}
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 {
return name, nil
}
if teamID == "" {
return "", fmt.Errorf("Empty team ID")
}
return fmt.Sprintf("«devel:%s»", teamID), nil
}

View File

@ -75,6 +75,9 @@ input {
.category h2 {
margin: 0 0.2em;
}
.category .solved {
text-decoration: line-through;
}
nav ul, .category ul {
margin: 0;
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 {
Millisecond,
Second,
Minute,
Toast,
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>
<div class="puzzles"></div>
</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>
</main>
<nav>
<ul>
<li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>

View File

@ -6,11 +6,14 @@ import * as common from "./common.mjs"
class App {
constructor(basePath=".") {
this.configURL = new URL("config.json", location)
this.config = {}
this.server = new moth.Server(basePath)
let uuid = Math.floor(Math.random() * 1000000).toString(16)
this.fakeRegistration = {
TeamId: uuid,
TeamID: uuid,
TeamName: `Team ${uuid}`,
}
@ -21,8 +24,11 @@ class App {
e.addEventListener("click", () => this.Logout())
}
setInterval(() => this.Update(), common.Minute/3)
this.Update()
setInterval(() => this.UpdateState(), common.Minute/3)
setInterval(() => this.UpdateConfig(), common.Minute* 5)
this.UpdateConfig()
.finally(() => this.UpdateState())
}
handleLoginSubmit(event) {
@ -33,14 +39,14 @@ class App {
/**
* Attempt to log in to the server.
*
* @param {String} teamId
* @param {String} teamID
* @param {String} teamName
*/
async Login(teamId, teamName) {
async Login(teamID, teamName) {
try {
await this.server.Login(teamId, teamName)
common.Toast(`Logged in (team id = ${teamId})`)
this.Update()
await this.server.Login(teamID, teamName)
common.Toast(`Logged in (team id = ${teamID})`)
this.UpdateState()
}
catch (error) {
common.Toast(error)
@ -54,13 +60,24 @@ class App {
try {
this.server.Reset()
common.Toast("Logged out")
this.Update()
this.UpdateState()
}
catch (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.
*
@ -68,12 +85,18 @@ class App {
* what's returned. If we're in development mode and not logged in, auto
* login too.
*/
async Update() {
async UpdateState() {
this.state = await this.server.GetState()
for (let e of document.querySelectorAll(".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")) {
this.renderLogin(e, !this.server.LoggedIn())
}
@ -84,7 +107,7 @@ class App {
if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
common.Toast("Automatically logging in to devel server")
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.href = url
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 = "✿"
}

View File

@ -130,7 +130,7 @@ class Puzzle {
/** Point value of this puzzle */
this.Points = Number(points)
/** Error returned trying to fetch this puzzle */
/** Error returned trying to retrieve this puzzle */
this.Error = {
/** Status code provided by server */
Status: 0,
@ -265,7 +265,7 @@ class State {
/** Log of points awarded
* @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,
* 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
* @returns {Boolean}
*/
HasUnsolved(category) {
ContainsUnsolved(category) {
return !this.PointsByCategory[category].includes(0)
}
@ -330,6 +330,26 @@ class State {
}
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")
}
this.baseUrl = new URL(baseUrl, location)
this.teamIdKey = this.baseUrl.toString() + " teamID"
this.TeamId = localStorage[this.teamIdKey]
this.teamIDKey = this.baseUrl.toString() + " teamID"
this.TeamID = localStorage[this.teamIDKey]
}
/**
@ -357,31 +377,25 @@ class Server {
* If anything other than a 2xx code is returned,
* this function throws an error.
*
* This always sends teamId.
* This always sends teamID.
* If args is set, POST will be used instead of GET
*
* @param {String} path Path to API endpoint
* @param {Object.<String,String>} args Key/Values to send in POST data
* @returns {Promise.<Response>} Response
*/
fetch(path, args) {
let url = new URL(path, this.baseUrl)
if (this.TeamId & (!(args && args.id))) {
url.searchParams.set("id", this.TeamId)
fetch(path, args={}) {
let body = new URLSearchParams(args)
if (this.TeamID && !body.has("id")) {
body.set("id", this.TeamID)
}
if (args) {
let formData = new FormData()
for (let k in args) {
formData.set(k, args[k])
}
let url = new URL(path, this.baseUrl)
return fetch(url, {
method: "POST",
body: formData,
body,
})
}
return fetch(url)
}
/**
* Send a request to a JSend API endpoint.
@ -390,7 +404,7 @@ class Server {
* @param {Object.<String,String>} args Key/Values to send in POST
* @returns {Promise.<Object>} JSend Data
*/
async call(path, args) {
async call(path, args={}) {
let resp = await this.fetch(path, args)
let obj = await resp.json()
switch (obj.status) {
@ -408,6 +422,9 @@ class Server {
/**
* 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}
*/
URL(url) {
@ -420,7 +437,7 @@ class Server {
* @returns {Boolean}
*/
LoggedIn() {
return this.TeamId ? true : false
return this.TeamID ? true : false
}
/**
@ -429,8 +446,8 @@ class Server {
* This is equivalent to logging out.
*/
Reset() {
localStorage.removeItem(this.teamIdKey)
this.TeamId = null
localStorage.removeItem(this.teamIDKey)
this.TeamID = null
}
/**
@ -450,15 +467,15 @@ class Server {
* This calls the server's registration endpoint; if the call succeds, or
* fails with "team already exists", the login is returned as successful.
*
* @param {String} teamId
* @param {String} teamID
* @param {String} teamName
* @returns {Promise.<String>} Success message from server
*/
async Login(teamId, teamName) {
let data = await this.call("/register", {id: teamId, name: teamName})
this.TeamId = teamId
async Login(teamID, teamName) {
let data = await this.call("/register", {id: teamID, name: teamName})
this.TeamID = teamID
this.TeamName = teamName
localStorage[this.teamIdKey] = teamId
localStorage[this.teamIDKey] = teamID
return data.description || data.short
}

View File

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

48340
theme/reports/NICEFramework2017.json Executable file

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,35 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<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">
</head>
<body>
<h1>KSA Report</h1>
<main>
<p>
This report shows all KSAs covered by this server so far.
This is not a report on your progress, but rather
what you would have covered if you had worked every exercise available.
</p>
<div class="KSAsByCategory">
<h2>All KSAs by Category</h2>
<div class="notification">
<p class="doing"></p>
<progress class="doing"></progress>
</div>
<h2>All KSAs across all content</h2>
<ul class="allKSAs"></ul>
<h2>All KSAs by Category</h2>
<div class="KSAsByCategory">
</div>
<h2>KSAs by Puzzle</h2>
<table class="puzzles">
<thead>
<tr>
@ -37,5 +50,6 @@
</template>
</tbody>
</table>
</main>
</body>
</html>

View File

@ -1,73 +1,140 @@
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")) {
e.classList.remove("hidden")
if (what) {
e.style.display = "inherit"
e.textContent = what
}
if (finished) {
e.value = finished
} 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 init() {
let server = new moth.Server("../")
doing("Retrieving server state")
let state = await server.GetState()
doing("Retrieving all puzzles")
let puzzlerowTemplate = document.querySelector("template#puzzlerow")
let puzzles = state.Puzzles()
for (let puzzle of puzzles) {
await puzzle.Populate().catch(x => {})
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
}
doing("Filling tables")
let KSAsByCategory = {}
for (let puzzle of puzzles) {
let KSAs = KSAsByCategory[puzzle.Category]
if (!KSAs) {
KSAs = new Set()
KSAsByCategory[puzzle.Category] = KSAs
/**
* 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 tbody of document.querySelectorAll("tbody")) {
let row = puzzlerowTemplate.content.cloneNode(true)
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
tbody.appendChild(row)
}
}
async function init() {
doing("Fetching NICE framework data")
let nicePromise = GetNice()
doing("Retrieving server state")
let state = await server.GetState()
doing("Retrieving all puzzles")
let KSAsByCategory = {}
let puzzlerowTemplate = document.querySelector("template#puzzlerow")
let puzzles = state.Puzzles()
let promises = []
for (let category of state.Categories()) {
KSAsByCategory[category] = new Set()
}
let pending = puzzles.length
for (let puzzle of puzzles) {
// Make space in the table, so everything fills in sorted order
let rows = []
for (let tbody of document.querySelectorAll("tbody")) {
let row = puzzlerowTemplate.content.cloneNode(true).firstElementChild
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")
let allKSAs = new Set()
for (let div of document.querySelectorAll(".KSAsByCategory")) {
for (let category of state.Categories()) {
doing(`Filling KSAs for category: ${category}`)
let KSAs = [...KSAsByCategory[category]]
KSAs.sort()
div.appendChild(document.createElement("h3")).textContent = category
let ul = div.appendChild(document.createElement("ul"))
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]
KSAs.sort()
for (let text of KSAs) {
e.appendChild(document.createElement("li")).textContent = text
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
done()
}
common.WhenDOMLoaded(init)