mirror of https://github.com/dirtbags/moth.git
Merge pull request #139 from dirtbags/substring-answers
Substring answers. John said it was okay.
This commit is contained in:
commit
be448eadc5
|
@ -5,6 +5,8 @@ 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]
|
||||||
|
### Changed
|
||||||
|
- We are now using SHA256 instead of djb2hash
|
||||||
### Added
|
### Added
|
||||||
- URL parameter to points.json to allow returning only the JSON for a single
|
- URL parameter to points.json to allow returning only the JSON for a single
|
||||||
team by its team id (e.g., points.json?id=abc123).
|
team by its team id (e.g., points.json?id=abc123).
|
||||||
|
@ -13,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- add_script_stream convenience function allows easy script addition to puzzle
|
- add_script_stream convenience function allows easy script addition to puzzle
|
||||||
- Autobuild Docker images to test buildability
|
- Autobuild Docker images to test buildability
|
||||||
- Extract and use X-Forwarded-For headers in mothd logging
|
- Extract and use X-Forwarded-For headers in mothd logging
|
||||||
|
- Mothballs can now specify `X-Answer-Pattern` header fields, which allow `*`
|
||||||
|
at the beginning, end, or both, of an answer. This is `X-` because we
|
||||||
|
are hoping to change how this works in the future.
|
||||||
### Fixed
|
### Fixed
|
||||||
- Handle cases where non-legacy puzzles don't have an `author` attribute
|
- Handle cases where non-legacy puzzles don't have an `author` attribute
|
||||||
- Handle YAML-formatted file and script lists as expected
|
- Handle YAML-formatted file and script lists as expected
|
||||||
|
|
|
@ -77,12 +77,12 @@ class MothRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"data": {
|
"data": {
|
||||||
"short": "",
|
"short": "",
|
||||||
"description": "Provided answer was not in list of answers"
|
"description": "%r was not in list of answers" % self.req.get("answer")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.req.get("answer") in puzzle.answers:
|
if self.req.get("answer") in puzzle.answers:
|
||||||
ret["data"]["description"] = "Answer is correct"
|
ret["data"]["description"] = "Answer %r is correct" % self.req.get("answer")
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
|
@ -22,11 +22,8 @@ messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
def djb2hash(str):
|
def sha256hash(str):
|
||||||
h = 5381
|
return hashlib.sha256(str.encode("utf-8")).hexdigest()
|
||||||
for c in str.encode("utf-8"):
|
|
||||||
h = ((h * 33) + c) & 0xffffffff
|
|
||||||
return h
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def pushd(newdir):
|
def pushd(newdir):
|
||||||
|
@ -130,6 +127,7 @@ class Puzzle:
|
||||||
self.summary = None
|
self.summary = None
|
||||||
self.authors = []
|
self.authors = []
|
||||||
self.answers = []
|
self.answers = []
|
||||||
|
self.xAnchors = {"begin", "end"}
|
||||||
self.scripts = []
|
self.scripts = []
|
||||||
self.pattern = None
|
self.pattern = None
|
||||||
self.hint = None
|
self.hint = None
|
||||||
|
@ -215,6 +213,16 @@ class Puzzle:
|
||||||
if not isinstance(val, str):
|
if not isinstance(val, str):
|
||||||
raise ValueError("Answers must be strings, got %s, instead" % (type(val),))
|
raise ValueError("Answers must be strings, got %s, instead" % (type(val),))
|
||||||
self.answers.append(val)
|
self.answers.append(val)
|
||||||
|
elif key == 'x-answer-pattern':
|
||||||
|
a = val.strip("*")
|
||||||
|
assert "*" not in a, "Patterns may only have * at the beginning and end"
|
||||||
|
assert "?" not in a, "Patterns do not currently support ? characters"
|
||||||
|
assert "[" not in a, "Patterns do not currently support character ranges"
|
||||||
|
self.answers.append(a)
|
||||||
|
if val.startswith("*"):
|
||||||
|
self.xAnchors.discard("begin")
|
||||||
|
if val.endswith("*"):
|
||||||
|
self.xAnchors.discard("end")
|
||||||
elif key == "answers":
|
elif key == "answers":
|
||||||
for answer in val:
|
for answer in val:
|
||||||
if not isinstance(answer, str):
|
if not isinstance(answer, str):
|
||||||
|
@ -448,12 +456,13 @@ class Puzzle:
|
||||||
'success': self.success,
|
'success': self.success,
|
||||||
'solution': self.solution,
|
'solution': self.solution,
|
||||||
'ksas': self.ksas,
|
'ksas': self.ksas,
|
||||||
|
'xAnchors': list(self.xAnchors),
|
||||||
}
|
}
|
||||||
|
|
||||||
def hashes(self):
|
def hashes(self):
|
||||||
"Return a list of answer hashes"
|
"Return a list of answer hashes"
|
||||||
|
|
||||||
return [djb2hash(a) for a in self.answers]
|
return [sha256hash(a) for a in self.answers]
|
||||||
|
|
||||||
|
|
||||||
class Category:
|
class Category:
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
Summary: Answer patterns
|
Summary: Answer patterns
|
||||||
Answer: command.com
|
Answer: command.com
|
||||||
Answer: COMMAND.COM
|
Answer: COMMAND.COM
|
||||||
|
X-Answer-Pattern: PINBALL.*
|
||||||
|
X-Answer-Pattern: pinball.*
|
||||||
Author: neale
|
Author: neale
|
||||||
Pattern: [0-9A-Za-z]{1,8}\.[A-Za-z]{1,3}
|
Pattern: [0-9A-Za-z]{1,8}\.[A-Za-z]{1,3}
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
<form>
|
<form>
|
||||||
<input type="hidden" name="cat">
|
<input type="hidden" name="cat">
|
||||||
<input type="hidden" name="points">
|
<input type="hidden" name="points">
|
||||||
|
<input type="hidden" name="xAnswer">
|
||||||
Team ID: <input type="text" name="id"> <br>
|
Team ID: <input type="text" name="id"> <br>
|
||||||
Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br>
|
Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br>
|
||||||
<input type="submit" value="Submit">
|
<input type="submit" value="Submit">
|
||||||
|
|
|
@ -51,12 +51,10 @@ function devel_addin(obj, e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hash routine used in v3.4 and earlier
|
||||||
|
|
||||||
// The routine used to hash answers in compiled puzzle packages
|
|
||||||
function djb2hash(buf) {
|
function djb2hash(buf) {
|
||||||
let h = 5381
|
let h = 5381
|
||||||
for (let c of (new TextEncoder).encode(buf)) { // Encode as UTF-8 and read in each byte
|
for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte
|
||||||
// JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
|
// JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
|
||||||
// So we have to do "unsigned right shift" by zero to get it back to unsigned.
|
// So we have to do "unsigned right shift" by zero to get it back to unsigned.
|
||||||
h = (((h * 33) + c) & 0xffffffff) >>> 0
|
h = (((h * 33) + c) & 0xffffffff) >>> 0
|
||||||
|
@ -64,6 +62,47 @@ function djb2hash(buf) {
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The routine used to hash answers in compiled puzzle packages
|
||||||
|
async function sha256Hash(message) {
|
||||||
|
const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // hash the message
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
|
||||||
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string
|
||||||
|
return hashHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is the provided answer possibly correct?
|
||||||
|
async function possiblyCorrect(answer) {
|
||||||
|
for (let correctHash of window.puzzle.hashes) {
|
||||||
|
// CPU time is cheap. Especially if it's not our server's time.
|
||||||
|
// So we'll just try absolutely everything and see what happens.
|
||||||
|
// We're counting on hash collisions being extremely rare with the algorithm we use.
|
||||||
|
// And honestly, this pales in comparison to the amount of CPU being eaten by
|
||||||
|
// something like the github 404 page.
|
||||||
|
|
||||||
|
if (djb2hash(answer) == correctHash) {
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
for (let len = 0; len <= answer.length; len += 1) {
|
||||||
|
if (window.puzzle.xAnchors.includes("end") && (len != answer.length)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (let pos = 0; pos < answer.length - len + 1; pos += 1) {
|
||||||
|
if (window.puzzle.xAnchors.includes("begin") && (pos > 0)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let sub = answer.substring(pos, pos+len)
|
||||||
|
let digest = await sha256Hash(sub)
|
||||||
|
|
||||||
|
if (digest == correctHash) {
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Pop up a message
|
// Pop up a message
|
||||||
function toast(message, timeout=5000) {
|
function toast(message, timeout=5000) {
|
||||||
|
@ -80,9 +119,17 @@ function toast(message, timeout=5000) {
|
||||||
// When the user submits an answer
|
// When the user submits an answer
|
||||||
function submit(e) {
|
function submit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
let data = new FormData(e.target)
|
||||||
|
|
||||||
|
// Kludge for patterned answers
|
||||||
|
let xAnswer = data.get("xAnswer")
|
||||||
|
if (xAnswer) {
|
||||||
|
data.set("answer", xAnswer)
|
||||||
|
}
|
||||||
|
window.data = data
|
||||||
fetch("answer", {
|
fetch("answer", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: new FormData(e.target),
|
body: data,
|
||||||
})
|
})
|
||||||
.then(resp => {
|
.then(resp => {
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
|
@ -180,21 +227,17 @@ function answerCheck(e) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let possiblyCorrect = false
|
possiblyCorrect(answer)
|
||||||
let answerHash = djb2hash(answer)
|
.then (correct => {
|
||||||
for (let correctHash of window.puzzle.hashes) {
|
document.querySelector("[name=xAnswer").value = correct || answer
|
||||||
if (correctHash == answerHash) {
|
if (correct) {
|
||||||
possiblyCorrect = true
|
ok.textContent = "⭕"
|
||||||
|
ok.title = "Possibly correct"
|
||||||
|
} else {
|
||||||
|
ok.textContent = "❌"
|
||||||
|
ok.title = "Definitely not correct"
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
if (possiblyCorrect) {
|
|
||||||
ok.textContent = "❓"
|
|
||||||
ok.title = "Possibly correct"
|
|
||||||
} else {
|
|
||||||
ok.textContent = "⛔"
|
|
||||||
ok.title = "Definitely not correct"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
|
Loading…
Reference in New Issue