Merge branch 'master' of https://github.com/dirtbags/moth into devel

This commit is contained in:
John Donaldson 2020-03-13 21:20:07 +00:00
commit fc7c818789
23 changed files with 449 additions and 60 deletions

36
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,36 @@
---
name: Bug report
about: Create a report to help us improve MOTH
labels: bug
---
### Description
<!-- Description of the issue -->
### Steps to Reproduce
1. <!-- First Step -->
2. <!-- Second Step -->
3. <!-- and so on… -->
**Expected behavior:**
<!-- What you expect to happen -->
**Actual behavior:**
<!-- What actually happens -->
**Reproduces how often:**
<!-- What percentage of the time does it reproduce? -->
### Versions
<!-- What version of MOTH are you running? Is this happening in mothd or in moth-devel? Are you running in Docker or natively? Also, please include the OS and what version of the OS you're running. -->
### Additional Information
<!-- Any additional information, configuration or data that might be necessary to reproduce the issue. -->

View File

@ -0,0 +1,22 @@
---
name: Feature request
about: Suggest an idea for MOTH
labels: enhancement
---
## Summary
<!-- One paragraph explanation of the feature. -->
## Motivation
<!-- Why are we doing this? What use cases does it support? What is the expected outcome? -->
## Describe alternatives you've considered
<!-- A clear and concise description of the alternative solutions you've considered. Be sure to explain why Atom's existing customizability isn't suitable for this feature. -->
## Additional context
<!-- Add any other context or screenshots about the feature request here. -->

View File

@ -0,0 +1,12 @@
name: moth-devel Docker build
on: [push]
jobs:
build-devel:
name: Build moth-devel
runs-on: ubuntu-latest
steps:
- name: Retrieve code
uses: actions/checkout@v1
- name: Build mothd
run: docker build -f Dockerfile.moth-devel .

View File

@ -0,0 +1,12 @@
name: Mothd Docker build
on: [push]
jobs:
build-mothd:
name: Build mothd
runs-on: ubuntu-latest
steps:
- name: Retrieve code
uses: actions/checkout@v1
- name: Build mothd
run: docker build -f Dockerfile.moth .

View File

@ -12,6 +12,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Default theme modifications to handle all this - Default theme modifications to handle all this
- Default theme now automatically "logs you in" with Team ID if it's getting state from the devel server - Default theme now automatically "logs you in" with Team ID if it's getting state from the devel server
## [v3.5.0] - 2020-03-13
### Changed
- We are now using SHA256 instead of djb2hash
### Added
- 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).
- A CONTRIBUTING.md to describe expectations when contributing to MOTH
- Include basic metadata in mothballs
- add_script_stream convenience function allows easy script addition to puzzle
- Autobuild Docker images to test buildability
- 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
- Handle cases where non-legacy puzzles don't have an `author` attribute
- Handle YAML-formatted file and script lists as expected
- YAML-formatted example puzzle actually works as expected
- points.log will now always be sorted chronologically
## [3.4.3] - 2019-11-20 ## [3.4.3] - 2019-11-20
### Fixed ### Fixed
- Made top-scoring teams full-width - Made top-scoring teams full-width

53
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,53 @@
# Contributing to MOTH
We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:
- Reporting a bug
- Discussing the current state of the code
- Submitting a fix
- Proposing new features
## We Develop with Github
We use github to host code, to track issues and feature requests, as well as accept pull requests.
## We Use [Gitflow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow), So All Code Changes Happen Through Pull Requests
Pull requests are the best way to propose changes to the codebase (we use [Gitflow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow)). We actively welcome your pull requests:
1. Fork the repo and create your branch from `master`.
2. If you've added code that should be tested, add tests.
3. If you've changed APIs, update the documentation.
4. Ensure the test suite passes.
5. Make sure your code lints.
6. Update [CHANGELOG.md](CHANGELOG.md)
7. Issue that pull request!
## We Deploy to a Variety of Architectures
MOTH is most often deployed using Docker, but we strive to ensure that it can easily be run outside of a Docker environment. Please ensure that and changes will not break or substantially alter Dockerized deployments and that, conversely, changes will not so substantially tie MOTH to Docker or particular Docker deployment that it becomes impractical to run MOTH anywhere but inside of Docker
## Any contributions you make will be under the MIT Software License
When you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern.
## Report bugs using Github's [issues](https://github.com/dirtbags/moth/issues)
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/dirtbags/moth/issues/new); it's that easy!
## Write bug reports with detail, background, and sample code
**Great Bug Reports** tend to have:
- A quick summary and/or background
- Steps to reproduce
- Be specific!
- Give sample code if you can.
- What you expected would happen
- What actually happens
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
## Use a Consistent Coding Style
### Go
* Run it through `gofmt`
### Javascript
* We use Javascript ASI
## References
This document was adapted from the open-source contribution guidelines from [https://gist.github.com/briandk/3d2e8b3ec8daf5a27a62]

View File

@ -7,4 +7,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o /mo
FROM scratch FROM scratch
COPY --from=builder /mothd /mothd COPY --from=builder /mothd /mothd
COPY theme /theme COPY theme /theme
COPY LICENSE.md /LICENSE
ENTRYPOINT [ "/mothd" ] ENTRYPOINT [ "/mothd" ]

View File

@ -16,6 +16,8 @@ RUN apk --no-cache add \
COPY devel /app/ COPY devel /app/
COPY example-puzzles /puzzles/ COPY example-puzzles /puzzles/
COPY theme /theme/ COPY theme /theme/
COPY LICENSE.md /LICENSE
COPY VERSION /VERSION
ENTRYPOINT [ "python3", "/app/devel-server.py" ] ENTRYPOINT [ "python3", "/app/devel-server.py" ]
CMD [ "--bind", "0.0.0.0:8080", "--puzzles", "/puzzles", "--theme", "/theme" ] CMD [ "--bind", "0.0.0.0:8080", "--puzzles", "/puzzles", "--theme", "/theme" ]

View File

@ -1,6 +1,14 @@
Dirtbags Monarch Of The Hill Server Dirtbags Monarch Of The Hill Server
===================== =====================
Master:
![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=master)
![](https://github.com/dirtbags/moth/workflows/moth-devel%20Docker%20build/badge.svg?branch=master)
Devel:
![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=devel)
![](https://github.com/dirtbags/moth/workflows/moth-devel%20Docker%20build/badge.svg?branch=devel)
This is a set of thingies to run our Monarch-Of-The-Hill contest, This is a set of thingies to run our Monarch-Of-The-Hill contest,
which in the past has been called which in the past has been called
"Tracer FIRE", "Tracer FIRE",
@ -161,4 +169,7 @@ If you remove a mothball,
the category will vanish, the category will vanish,
but points scored in that category won't! but points scored in that category won't!
Contributing to MOTH
==================
Please read [CONTRIBUTING.md](CONTRIBUTING.md)

View File

@ -1 +1 @@
3.4.3 3.5.0

View File

@ -1,6 +1,5 @@
#!/usr/bin/python3 #!/usr/bin/python3
import asyncio
import cgitb import cgitb
import html import html
import cgi import cgi
@ -43,6 +42,13 @@ class MothRequestHandler(http.server.SimpleHTTPRequestHandler):
except TypeError: except TypeError:
super().__init__(request, client_address, server) super().__init__(request, client_address, server)
# Why isn't this the default?!
def guess_type(self, path):
mtype, encoding = mimetypes.guess_type(path)
if encoding:
return "%s; encoding=%s" % (mtype, encoding)
else:
return mtype
# Backport from Python 3.7 # Backport from Python 3.7
def translate_path(self, path): def translate_path(self, path):
@ -91,13 +97,16 @@ 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_json(ret) self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(ret).encode("utf-8"))
endpoints.append(('/{seed}/answer', handle_answer)) endpoints.append(('/{seed}/answer', handle_answer))
@ -142,6 +151,7 @@ class MothRequestHandler(http.server.SimpleHTTPRequestHandler):
obj["hint"] = puzzle.hint obj["hint"] = puzzle.hint
obj["summary"] = puzzle.summary obj["summary"] = puzzle.summary
obj["logs"] = puzzle.logs obj["logs"] = puzzle.logs
obj["format"] = puzzle._source_format
self.send_json(obj) self.send_json(obj)
endpoints.append(('/{seed}/content/{cat}/{points}/puzzle.json', handle_puzzle)) endpoints.append(('/{seed}/content/{cat}/{points}/puzzle.json', handle_puzzle))
@ -278,6 +288,8 @@ if __name__ == '__main__':
logging.basicConfig(level=log_level) logging.basicConfig(level=log_level)
mimetypes.add_type("application/javascript", ".mjs")
server = MothServer((addr, port), MothRequestHandler) server = MothServer((addr, port), MothRequestHandler)
server.args["base_url"] = args.base server.args["base_url"] = args.base
server.args["puzzles_dir"] = pathlib.Path(args.puzzles) server.args["puzzles_dir"] = pathlib.Path(args.puzzles)

View File

@ -22,14 +22,12 @@ 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):
newdir = str(newdir)
curdir = os.getcwd() curdir = os.getcwd()
LOGGER.debug("Attempting to chdir from %s to %s" % (curdir, newdir)) LOGGER.debug("Attempting to chdir from %s to %s" % (curdir, newdir))
os.chdir(newdir) os.chdir(newdir)
@ -123,10 +121,13 @@ class Puzzle:
super().__init__() super().__init__()
self._source_format = "py"
self.points = points self.points = points
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
@ -153,8 +154,10 @@ class Puzzle:
line = "" line = ""
if stream.read(3) == "---": if stream.read(3) == "---":
header = "yaml" header = "yaml"
self._source_format = "yaml"
else: else:
header = "moth" header = "moth"
self._source_format = "moth"
stream.seek(0) stream.seek(0)
@ -210,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):
@ -233,15 +246,36 @@ class Puzzle:
except IndexError: except IndexError:
pass pass
self.files[name] = PuzzleFile(stream, name, not hidden) self.files[name] = PuzzleFile(stream, name, not hidden)
elif key == 'files':
for file in val: elif key == 'files' and isinstance(val, dict):
path = file["path"] for filename, options in val.items():
stream = open(path, "rb") if "source" in options:
name = file.get("name") or path source = options["source"]
self.files[name] = PuzzleFile(stream, name, not file.get("hidden")) else:
source = filename
if "hidden" in options and options["hidden"]:
hidden = True
else:
hidden = False
stream = open(source, "rb")
self.files[filename] = PuzzleFile(stream, filename, not hidden)
elif key == 'files' and isinstance(val, list):
for filename in val:
stream = open(filename, "rb")
self.files[filename] = PuzzleFile(stream, filename)
elif key == 'script': elif key == 'script':
stream = open(val, 'rb') stream = open(val, 'rb')
self.add_script_stream(stream, val) self.add_script_stream(stream, val)
elif key == "scripts" and isinstance(val, list):
for script in val:
stream = open(script, "rb")
self.add_script_stream(stream, script)
elif key == "objective": elif key == "objective":
self.objective = val self.objective = val
elif key == "success": elif key == "success":
@ -393,7 +427,12 @@ class Puzzle:
self.body.write('</pre>') self.body.write('</pre>')
def get_authors(self): def get_authors(self):
return self.authors or [self.author] if len(self.authors) > 0:
return self.authors
elif hasattr(self, "author"):
return [self.author]
else:
return []
def get_body(self): def get_body(self):
return self.body.getvalue() return self.body.getvalue()
@ -419,12 +458,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:

View File

@ -2,12 +2,14 @@
import argparse import argparse
import binascii import binascii
import datetime
import hashlib import hashlib
import io import io
import json import json
import logging import logging
import moth import moth
import os import os
import platform
import shutil import shutil
import tempfile import tempfile
import zipfile import zipfile
@ -61,6 +63,24 @@ def build_category(categorydir, outdir):
zipfileraw.close() zipfileraw.close()
shutil.move(zipfileraw.name, zipfilename) shutil.move(zipfileraw.name, zipfilename)
def write_metadata(ziphandle, category):
metadata = {"platform": {}, "moth": {}, "category": {}}
try:
with open("../VERSION", "r") as infile:
version = infile.read().strip()
metadata["moth"]["version"] = version
except IOError:
pass
metadata["category"]["build_time"] = datetime.datetime.now().strftime("%c")
metadata["category"]["type"] = "catmod" if category.catmod is not None else "traditional"
metadata["platform"]["arch"] = platform.machine()
metadata["platform"]["os"] = platform.system()
metadata["platform"]["version"] = platform.platform()
metadata["platform"]["python_version"] = platform.python_version()
ziphandle.writestr("meta.json", json.dumps(metadata))
# Returns a file-like object containing the contents of the new zip file # Returns a file-like object containing the contents of the new zip file
def package(categoryname, categorydir, seed): def package(categoryname, categorydir, seed):
@ -94,6 +114,7 @@ def package(categoryname, categorydir, seed):
write_kv_pairs(zf, 'map.txt', mapping) write_kv_pairs(zf, 'map.txt', mapping)
write_kv_pairs(zf, 'answers.txt', answers) write_kv_pairs(zf, 'answers.txt', answers)
write_kv_pairs(zf, 'summaries.txt', summary) write_kv_pairs(zf, 'summaries.txt', summary)
write_metadata(zf, cat)
# clean up # clean up
zf.close() zf.close()

View File

@ -3,6 +3,7 @@ Summary: Static puzzle resource files
File: salad.jpg s.jpg File: salad.jpg s.jpg
File: salad2.jpg s2.jpg hidden File: salad2.jpg s2.jpg hidden
Answer: salad Answer: salad
X-Answer-Pattern: *pong
You can include additional resources in a static puzzle, You can include additional resources in a static puzzle,
by dropping them in the directory and listing them in a `File:` header field. by dropping them in the directory and listing them in a `File:` header field.

View File

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

View File

@ -14,6 +14,24 @@ type Award struct {
Points int Points int
} }
type AwardList []*Award
// Implement sort.Interface on AwardList
func (awards AwardList) Len() int {
return len(awards)
}
func (awards AwardList) Less(i, j int) bool {
return awards[i].When.Before(awards[j].When)
}
func (awards AwardList) Swap(i, j int) {
tmp := awards[i]
awards[i] = awards[j]
awards[j] = tmp
}
func ParseAward(s string) (*Award, error) { func ParseAward(s string) (*Award, error) {
ret := Award{} ret := Award{}

View File

@ -2,6 +2,7 @@ package main
import ( import (
"testing" "testing"
"sort"
) )
func TestAward(t *testing.T) { func TestAward(t *testing.T) {
@ -32,3 +33,23 @@ func TestAward(t *testing.T) {
t.Error("Not throwing error on bad points") t.Error("Not throwing error on bad points")
} }
} }
func TestAwardList(t *testing.T) {
a, _ := ParseAward("1536958399 1a2b3c4d counting 1")
b, _ := ParseAward("1536958400 1a2b3c4d counting 1")
c, _ := ParseAward("1536958300 1a2b3c4d counting 1")
list := AwardList{a, b, c}
if sort.IsSorted(list) {
t.Error("Unsorted list thinks it's sorted")
}
sort.Stable(list)
if (list[0] != c) || (list[1] != a) || (list[2] != b) {
t.Error("Sorting didn't")
}
if ! sort.IsSorted(list) {
t.Error("Sorted list thinks it isn't")
}
}

View File

@ -183,9 +183,14 @@ func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) {
} }
func (ctx *Instance) pointsHandler(w http.ResponseWriter, req *http.Request) { func (ctx *Instance) pointsHandler(w http.ResponseWriter, req *http.Request) {
teamId, ok := req.URL.Query()["id"]
pointsLog := ctx.jPointsLog
if ok && len(teamId[0]) > 0 {
pointsLog = ctx.generatePointsLog(teamId[0])
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write(ctx.jPointsLog) w.Write(pointsLog)
} }
func (ctx *Instance) contentHandler(w http.ResponseWriter, req *http.Request) { func (ctx *Instance) contentHandler(w http.ResponseWriter, req *http.Request) {
@ -334,10 +339,22 @@ func (ctx *Instance) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
w: wOrig, w: wOrig,
statusCode: new(int), statusCode: new(int),
} }
clientIP := r.RemoteAddr
if (ctx.UseXForwarded) {
forwardedIP := r.Header.Get("X-Forwarded-For")
forwardedIP = strings.Split(forwardedIP, ", ")[0]
if forwardedIP != "" {
clientIP = forwardedIP
}
}
ctx.mux.ServeHTTP(w, r) ctx.mux.ServeHTTP(w, r)
log.Printf( log.Printf(
"%s %s %s %d\n", "%s %s %s %d\n",
r.RemoteAddr, clientIP,
r.Method, r.Method,
r.URL, r.URL,
*w.statusCode, *w.statusCode,

View File

@ -25,6 +25,7 @@ type Instance struct {
StateDir string StateDir string
ThemeDir string ThemeDir string
AttemptInterval time.Duration AttemptInterval time.Duration
UseXForwarded bool
Runtime RuntimeConfig Runtime RuntimeConfig
@ -83,6 +84,7 @@ func (ctx *Instance) MaybeInitialize() {
os.Remove(ctx.StatePath("until")) os.Remove(ctx.StatePath("until"))
os.Remove(ctx.StatePath("disabled")) os.Remove(ctx.StatePath("disabled"))
os.Remove(ctx.StatePath("points.log")) os.Remove(ctx.StatePath("points.log"))
os.RemoveAll(ctx.StatePath("points.tmp")) os.RemoveAll(ctx.StatePath("points.tmp"))
os.RemoveAll(ctx.StatePath("points.new")) os.RemoveAll(ctx.StatePath("points.new"))
os.RemoveAll(ctx.StatePath("teams")) os.RemoveAll(ctx.StatePath("teams"))
@ -151,14 +153,15 @@ func (ctx *Instance) TooFast(teamId string) bool {
return now.Before(next) return now.Before(next)
} }
func (ctx *Instance) PointsLog() []*Award { func (ctx *Instance) PointsLog(teamId string) AwardList {
var ret []*Award awardlist := AwardList{}
fn := ctx.StatePath("points.log") fn := ctx.StatePath("points.log")
f, err := os.Open(fn) f, err := os.Open(fn)
if err != nil { if err != nil {
log.Printf("Unable to open %s: %s", fn, err) log.Printf("Unable to open %s: %s", fn, err)
return ret return awardlist
} }
defer f.Close() defer f.Close()
@ -170,10 +173,13 @@ func (ctx *Instance) PointsLog() []*Award {
log.Printf("Skipping malformed award line %s: %s", line, err) log.Printf("Skipping malformed award line %s: %s", line, err)
continue continue
} }
ret = append(ret, cur) if len(teamId) > 0 && cur.TeamId != teamId {
continue
}
awardlist = append(awardlist, cur)
} }
return ret return awardlist
} }
// AwardPoints gives points to teamId in category. // AwardPoints gives points to teamId in category.
@ -194,7 +200,7 @@ func (ctx *Instance) AwardPoints(teamId, category string, points int) error {
return fmt.Errorf("No registered team with this hash") return fmt.Errorf("No registered team with this hash")
} }
for _, e := range ctx.PointsLog() { for _, e := range ctx.PointsLog("") {
if a.Same(e) { if a.Same(e) {
return fmt.Errorf("Points already awarded to this team in this category") return fmt.Errorf("Points already awarded to this team in this category")
} }

View File

@ -7,6 +7,7 @@ import (
"io/ioutil" "io/ioutil"
"log" "log"
"os" "os"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -28,7 +29,7 @@ func (pm *PuzzleMap) MarshalJSON() ([]byte, error) {
func (ctx *Instance) generatePuzzleList() { func (ctx *Instance) generatePuzzleList() {
maxByCategory := map[string]int{} maxByCategory := map[string]int{}
for _, a := range ctx.PointsLog() { for _, a := range ctx.PointsLog("") {
if a.Points > maxByCategory[a.Category] { if a.Points > maxByCategory[a.Category] {
maxByCategory[a.Category] = a.Points maxByCategory[a.Category] = a.Points
} }
@ -67,13 +68,13 @@ func (ctx *Instance) generatePuzzleList() {
ctx.jPuzzleList = jpl ctx.jPuzzleList = jpl
} }
func (ctx *Instance) generatePointsLog() { func (ctx *Instance) generatePointsLog(teamId string) []byte {
var ret struct { var ret struct {
Teams map[string]string `json:"teams"` Teams map[string]string `json:"teams"`
Points []*Award `json:"points"` Points []*Award `json:"points"`
} }
ret.Teams = map[string]string{} ret.Teams = map[string]string{}
ret.Points = ctx.PointsLog() ret.Points = ctx.PointsLog(teamId)
teamNumbersById := map[string]int{} teamNumbersById := map[string]int{}
for nr, a := range ret.Points { for nr, a := range ret.Points {
@ -93,10 +94,14 @@ func (ctx *Instance) generatePointsLog() {
jpl, err := json.Marshal(ret) jpl, err := json.Marshal(ret)
if err != nil { if err != nil {
log.Printf("Marshalling points.js: %v", err) log.Printf("Marshalling points.js: %v", err)
return return nil
} }
if len(teamId) == 0 {
ctx.jPointsLog = jpl ctx.jPointsLog = jpl
} }
return jpl
}
// maintenance runs // maintenance runs
func (ctx *Instance) tidy() { func (ctx *Instance) tidy() {
@ -199,17 +204,26 @@ func (ctx *Instance) readTeams() {
// collectPoints gathers up files in points.new/ and appends their contents to points.log, // collectPoints gathers up files in points.new/ and appends their contents to points.log,
// removing each points.new/ file as it goes. // removing each points.new/ file as it goes.
func (ctx *Instance) collectPoints() { func (ctx *Instance) collectPoints() {
logf, err := os.OpenFile(ctx.StatePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) points := ctx.PointsLog("")
pointsFilename := ctx.StatePath("points.log")
pointsNewFilename := ctx.StatePath("points.log.new")
// Yo, this is delicate.
// If we have to return early, we must remove this file.
// If the file's written and we move it successfully,
// we need to remove all the little points files that built it.
newPoints, err := os.OpenFile(pointsNewFilename, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
if err != nil { if err != nil {
log.Printf("Can't append to points log: %s", err) log.Printf("Can't append to points log: %s", err)
return return
} }
defer logf.Close()
files, err := ioutil.ReadDir(ctx.StatePath("points.new")) files, err := ioutil.ReadDir(ctx.StatePath("points.new"))
if err != nil { if err != nil {
log.Printf("Error reading packages: %s", err) log.Printf("Error reading packages: %s", err)
} }
removearino := make([]string, 0, len(files))
for _, f := range files { for _, f := range files {
filename := ctx.StatePath("points.new", f.Name()) filename := ctx.StatePath("points.new", f.Name())
s, err := ioutil.ReadFile(filename) s, err := ioutil.ReadFile(filename)
@ -224,7 +238,7 @@ func (ctx *Instance) collectPoints() {
} }
duplicate := false duplicate := false
for _, e := range ctx.PointsLog() { for _, e := range points {
if award.Same(e) { if award.Same(e) {
duplicate = true duplicate = true
break break
@ -234,10 +248,27 @@ func (ctx *Instance) collectPoints() {
if duplicate { if duplicate {
log.Printf("Skipping duplicate points: %s", award.String()) log.Printf("Skipping duplicate points: %s", award.String())
} else { } else {
fmt.Fprintf(logf, "%s\n", award.String()) points = append(points, award)
}
removearino = append(removearino, filename)
} }
logf.Sync() sort.Stable(points)
for _, point := range points {
fmt.Fprintln(newPoints, point.String())
}
newPoints.Close()
if err := os.Rename(pointsNewFilename, pointsFilename); err != nil {
log.Printf("Unable to move %s to %s: %s", pointsFilename, pointsNewFilename, err)
if err := os.Remove(pointsNewFilename); err != nil {
log.Printf("Also couldn't remove %s: %s", pointsNewFilename, err)
}
return
}
for _, filename := range removearino {
if err := os.Remove(filename); err != nil { if err := os.Remove(filename); err != nil {
log.Printf("Unable to remove %s: %s", filename, err) log.Printf("Unable to remove %s: %s", filename, err)
} }
@ -290,7 +321,7 @@ func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) {
ctx.readTeams() ctx.readTeams()
ctx.collectPoints() ctx.collectPoints()
ctx.generatePuzzleList() ctx.generatePuzzleList()
ctx.generatePointsLog() ctx.generatePointsLog("")
} }
select { select {
case <-ctx.update: case <-ctx.update:

View File

@ -52,6 +52,12 @@ func main() {
20*time.Second, 20*time.Second,
"Time between maintenance tasks", "Time between maintenance tasks",
) )
flag.BoolVar(
&ctx.UseXForwarded,
"x-forwarded-for",
false,
"Emit IPs from the X-Forwarded-For header in logs, when available, instead of the source IP. Use this when running behind a load-balancer or proxy",
)
listen := flag.String( listen := flag.String(
"listen", "listen",
":8080", ":8080",

View File

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

View File

@ -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 end = 0; end <= answer.length; end += 1) {
if (window.puzzle.xAnchors.includes("end") && (end != answer.length)) {
continue
}
for (let beg = 0; beg < answer.length; beg += 1) {
if (window.puzzle.xAnchors.includes("begin") && (beg != 0)) {
continue
}
let sub = answer.substring(beg, end)
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 = "⭕"
}
}
if (possiblyCorrect) {
ok.textContent = "❓"
ok.title = "Possibly correct" ok.title = "Possibly correct"
} else { } else {
ok.textContent = "" ok.textContent = "❌"
ok.title = "Definitely not correct" ok.title = "Definitely not correct"
} }
})
} }
function init() { function init() {