mirror of https://github.com/dirtbags/moth.git
Remove dev server, update documentation.
This commit is contained in:
parent
522e5dbf6c
commit
4ec9666a7c
152
README.md
152
README.md
|
@ -9,8 +9,8 @@ Devel:
|
||||||
![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=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)
|
![](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,
|
Monarch Of The Hill (MOTH) is a puzzle server.
|
||||||
which in the past has been called
|
We (the authors) have used it for instructional and contest events called
|
||||||
"Tracer FIRE",
|
"Tracer FIRE",
|
||||||
"Project 2",
|
"Project 2",
|
||||||
"HACK",
|
"HACK",
|
||||||
|
@ -23,152 +23,38 @@ and "Cyber Fire Foundry".
|
||||||
Information about these events is at
|
Information about these events is at
|
||||||
http://dirtbags.net/contest/
|
http://dirtbags.net/contest/
|
||||||
|
|
||||||
This software serves up puzzles in a manner similar to Jeopardy.
|
A few things make MOTH different than other Capture The Flag server projects:
|
||||||
It also tracks scores,
|
|
||||||
and comes with a JavaScript-based scoreboard to display team rankings.
|
* Once any team opens a puzzle, all teams can work on it (high fives to DC949/Orange County for this idea)
|
||||||
|
* No penalties for wrong answers
|
||||||
|
* No time-based point deductions (if you're faster, you get to answer more puzzles)
|
||||||
|
* No internal notion of ranking or score: it only stores an event log, and scoreboards parse it however they want
|
||||||
|
* All puzzles must be compiled to static content before it can be served up
|
||||||
|
* The server does very little: most functionality is in client-side JavaScript
|
||||||
|
|
||||||
|
You can read more about why we made these decisions in [philosophy](doc/philosophy.md).
|
||||||
|
|
||||||
|
|
||||||
Running a Development Server
|
Documentation
|
||||||
============================
|
==========
|
||||||
|
|
||||||
To use example puzzles
|
|
||||||
|
|
||||||
docker run --rm -it -p 8080:8080 dirtbags/moth-devel
|
|
||||||
|
|
||||||
or, to use your own puzzles
|
|
||||||
|
|
||||||
docker run --rm -it -p 8080:8080 -v /path/to/puzzles:/puzzles:ro dirtbags/moth-devel
|
|
||||||
|
|
||||||
And point a browser to http://localhost:8080/ (or whatever host is running the server).
|
|
||||||
|
|
||||||
The development server includes a number of Python libraries that we have found useful in writing puzzles.
|
|
||||||
|
|
||||||
When you're ready to create your own puzzles,
|
|
||||||
read [the devel server documentation](doc/devel-server.md).
|
|
||||||
|
|
||||||
Click the `[mb]` link by a puzzle category to compile and download a mothball that the production server can read.
|
|
||||||
|
|
||||||
|
* [Development](doc/development.md): The development server lets you create and test categories, and compile mothballs.
|
||||||
|
* [Getting Started](doc/getting-started.md): This guide will get you started with a production server.
|
||||||
|
* [Administration](doc/administration.md): How to set hours, and change setup.
|
||||||
|
|
||||||
Running a Production Server
|
Running a Production Server
|
||||||
===========================
|
===========================
|
||||||
|
|
||||||
docker run --rm -it -p 8080:8080 -v /path/to/moth/state:/state -v /path/to/moth/balls:/mothballs:ro dirtbags/moth
|
docker run --rm -it -p 8080:8080 -v /path/to/moth/state:/state -v /path/to/moth/mothballs:/mothballs:ro dirtbags/moth
|
||||||
|
|
||||||
You can be more fine-grained about directories, if you like.
|
You can be more fine-grained about directories, if you like.
|
||||||
Inside the container, you need the following paths:
|
Inside the container, you need the following paths:
|
||||||
|
|
||||||
* `/state` (rw) Where state is stored. Read [the overview](doc/overview.md) to learn what's what in here.
|
* `/state` (rw) Where state is stored. Read [the overview](doc/overview.md) to learn what's what in here.
|
||||||
* `/mothballs` (ro) Mothballs (puzzle bundles) as provided by the development server.
|
* `/mothballs` (ro) Mothballs (puzzle bundles) as provided by the development server.
|
||||||
* `/resources` (ro) Overrides for built-in HTML/CSS resources.
|
* `/theme` (ro) Overrides for the built-in theme.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Getting Started Developing
|
|
||||||
-------------------------------
|
|
||||||
|
|
||||||
If you don't have a `puzzles` directory,
|
|
||||||
you can copy the example puzzles as a starting point:
|
|
||||||
|
|
||||||
$ cp -r example-puzzles puzzles
|
|
||||||
|
|
||||||
Then launch the development server:
|
|
||||||
|
|
||||||
$ python3 devel/devel-server.py
|
|
||||||
|
|
||||||
Point a web browser at http://localhost:8080/
|
|
||||||
and start hacking on things in your `puzzles` directory.
|
|
||||||
|
|
||||||
More on how the devel sever works in
|
|
||||||
[the devel server documentation](doc/devel-server.md)
|
|
||||||
|
|
||||||
|
|
||||||
Running A Production Server
|
|
||||||
====================
|
|
||||||
|
|
||||||
Run `dirtbags/moth` (Docker) or `mothd` (native).
|
|
||||||
|
|
||||||
`mothd` assumes you're running a contest out of `/moth`.
|
|
||||||
For Docker, you'll need to bind-mount your actual directories
|
|
||||||
(`state`, `mothballs`, and optionally `resources`) into
|
|
||||||
`/moth/`.
|
|
||||||
|
|
||||||
You can override any path with an option,
|
|
||||||
run `mothd -help` for usage.
|
|
||||||
|
|
||||||
|
|
||||||
State Directory
|
|
||||||
===============
|
|
||||||
|
|
||||||
|
|
||||||
Pausing scoring
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
Create the file `state/disabled`
|
|
||||||
to pause scoring,
|
|
||||||
and remove it to resume.
|
|
||||||
You can use the Unix `touch` command to create the file:
|
|
||||||
|
|
||||||
touch state/disabled
|
|
||||||
|
|
||||||
When scoring is paused,
|
|
||||||
participants can still submit answers,
|
|
||||||
and the system will tell them whether the answer is correct.
|
|
||||||
As soon as you unpause,
|
|
||||||
all correctly-submitted answers will be scored.
|
|
||||||
|
|
||||||
|
|
||||||
Resetting an instance
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
Remove the file `state/initialized`,
|
|
||||||
and the server will zap everything.
|
|
||||||
|
|
||||||
|
|
||||||
Setting up custom team IDs
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
The file `state/teamids.txt` has all the team IDs,
|
|
||||||
one per line.
|
|
||||||
This defaults to all 4-digit natural numbers.
|
|
||||||
You can edit it to be whatever strings you like.
|
|
||||||
|
|
||||||
We sometimes to set `teamids.txt` to a bunch of random 8-digit hex values:
|
|
||||||
|
|
||||||
for i in $(seq 50); do od -x /dev/urandom | awk '{print $2 $3; exit;}'; done
|
|
||||||
|
|
||||||
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
|
|
||||||
==================
|
|
||||||
|
|
||||||
Installing puzzle categories
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
The development server will provide you with a `.mb` (mothball) file,
|
|
||||||
when you click the `[mb]` link next to a category.
|
|
||||||
|
|
||||||
Just drop that file into the `mothballs` directory,
|
|
||||||
and the server will pick it up.
|
|
||||||
|
|
||||||
If you remove a mothball,
|
|
||||||
the category will vanish,
|
|
||||||
but points scored in that category won't!
|
|
||||||
|
|
||||||
Contributing to MOTH
|
Contributing to MOTH
|
||||||
==================
|
==================
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ func NewTestServer() *MothServer {
|
||||||
afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644)
|
afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644)
|
||||||
go theme.Maintain(TestMaintenanceInterval)
|
go theme.Maintain(TestMaintenanceInterval)
|
||||||
|
|
||||||
return NewMothServer(Configuration{Devel: true}, theme, state, puzzles)
|
return NewMothServer(Configuration{}, theme, state, puzzles)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer(t *testing.T) {
|
func TestServer(t *testing.T) {
|
||||||
|
@ -47,7 +47,7 @@ func TestServer(t *testing.T) {
|
||||||
|
|
||||||
es := handler.ExportState()
|
es := handler.ExportState()
|
||||||
if es.Config.Devel {
|
if es.Config.Devel {
|
||||||
t.Error("Marked as development server")
|
t.Error("Marked as development server", es.Config)
|
||||||
}
|
}
|
||||||
if len(es.Puzzles) != 1 {
|
if len(es.Puzzles) != 1 {
|
||||||
t.Error("Puzzle categories wrong length")
|
t.Error("Puzzle categories wrong length")
|
||||||
|
|
|
@ -17,7 +17,11 @@ import (
|
||||||
// DistinguishableChars are visually unambiguous glyphs.
|
// DistinguishableChars are visually unambiguous glyphs.
|
||||||
// People with mediocre handwriting could write these down unambiguously,
|
// People with mediocre handwriting could write these down unambiguously,
|
||||||
// and they can be entered without holding down shift.
|
// and they can be entered without holding down shift.
|
||||||
const DistinguishableChars = "234678abcdefhikmnpqrtwxyz="
|
const DistinguishableChars = "34678abcdefhikmnpqrtwxy="
|
||||||
|
|
||||||
|
// RFC3339Space is a time layout which replaces 'T' with a space.
|
||||||
|
// This is also a valid RFC3339 format.
|
||||||
|
const RFC3339Space = "2006-01-02 15:04:05Z07:00"
|
||||||
|
|
||||||
// State defines the current state of a MOTH instance.
|
// State defines the current state of a MOTH instance.
|
||||||
// We use the filesystem for synchronization between threads.
|
// We use the filesystem for synchronization between threads.
|
||||||
|
@ -50,11 +54,11 @@ func NewState(fs afero.Fs) *State {
|
||||||
// updateEnabled checks a few things to see if this state directory is "enabled".
|
// updateEnabled checks a few things to see if this state directory is "enabled".
|
||||||
func (s *State) updateEnabled() {
|
func (s *State) updateEnabled() {
|
||||||
nextEnabled := true
|
nextEnabled := true
|
||||||
why := "`state/enabled` present, `state/hours` missing"
|
why := "`state/enabled` present, `state/hours.txt` missing"
|
||||||
|
|
||||||
if untilFile, err := s.Open("hours"); err == nil {
|
if untilFile, err := s.Open("hours.txt"); err == nil {
|
||||||
defer untilFile.Close()
|
defer untilFile.Close()
|
||||||
why = "`state/hours` present"
|
why = "`state/hours.txt` present"
|
||||||
|
|
||||||
scanner := bufio.NewScanner(untilFile)
|
scanner := bufio.NewScanner(untilFile)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
|
@ -74,10 +78,13 @@ func (s *State) updateEnabled() {
|
||||||
case '#':
|
case '#':
|
||||||
continue
|
continue
|
||||||
default:
|
default:
|
||||||
log.Println("Misformatted line in hours file")
|
log.Println("Misformatted line in hours.txt file")
|
||||||
}
|
}
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
until, err := time.Parse(time.RFC3339, line)
|
until, err := time.Parse(time.RFC3339, line)
|
||||||
|
if err != nil {
|
||||||
|
until, err = time.Parse(RFC3339Space, line)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Suspended: Unparseable until date:", line)
|
log.Println("Suspended: Unparseable until date:", line)
|
||||||
continue
|
continue
|
||||||
|
@ -283,7 +290,7 @@ func (s *State) maybeInitialize() {
|
||||||
|
|
||||||
// Remove any extant control and state files
|
// Remove any extant control and state files
|
||||||
s.Remove("enabled")
|
s.Remove("enabled")
|
||||||
s.Remove("hours")
|
s.Remove("hours.txt")
|
||||||
s.Remove("points.log")
|
s.Remove("points.log")
|
||||||
s.Remove("messages.html")
|
s.Remove("messages.html")
|
||||||
s.Remove("mothd.log")
|
s.Remove("mothd.log")
|
||||||
|
@ -327,8 +334,8 @@ func (s *State) maybeInitialize() {
|
||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
if f, err := s.Create("hours"); err == nil {
|
if f, err := s.Create("hours.txt"); err == nil {
|
||||||
fmt.Fprintln(f, "# hours: when the contest is enabled")
|
fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
|
||||||
fmt.Fprintln(f, "#")
|
fmt.Fprintln(f, "#")
|
||||||
fmt.Fprintln(f, "# Enable: + timestamp")
|
fmt.Fprintln(f, "# Enable: + timestamp")
|
||||||
fmt.Fprintln(f, "# Disable: - timestamp")
|
fmt.Fprintln(f, "# Disable: - timestamp")
|
||||||
|
|
|
@ -34,7 +34,7 @@ func TestState(t *testing.T) {
|
||||||
|
|
||||||
mustExist("initialized")
|
mustExist("initialized")
|
||||||
mustExist("enabled")
|
mustExist("enabled")
|
||||||
mustExist("hours")
|
mustExist("hours.txt")
|
||||||
|
|
||||||
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
|
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -133,7 +133,7 @@ func TestStateDisabled(t *testing.T) {
|
||||||
t.Error("Brand new state is disabled")
|
t.Error("Brand new state is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
hoursFile, err := s.Create("hours")
|
hoursFile, err := s.Create("hours.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
@ -146,7 +146,7 @@ func TestStateDisabled(t *testing.T) {
|
||||||
t.Error("Disabling 1970-01-01")
|
t.Error("Disabling 1970-01-01")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "+ 1970-01-01T01:01:01+05:00")
|
fmt.Fprintln(hoursFile, "+ 1970-01-01 01:01:01+05:00")
|
||||||
hoursFile.Sync()
|
hoursFile.Sync()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled {
|
if !s.Enabled {
|
||||||
|
@ -175,12 +175,12 @@ func TestStateDisabled(t *testing.T) {
|
||||||
t.Error("Disabling 1980-01-01")
|
t.Error("Disabling 1980-01-01")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Remove("hours"); err != nil {
|
if err := s.Remove("hours.txt"); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled {
|
if !s.Enabled {
|
||||||
t.Error("Removing `hours` disabled event")
|
t.Error("Removing `hours.txt` disabled event")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Remove("enabled"); err != nil {
|
if err := s.Remove("enabled"); err != nil {
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,296 +0,0 @@
|
||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
import cgitb
|
|
||||||
import html
|
|
||||||
import cgi
|
|
||||||
import http.server
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import mimetypes
|
|
||||||
import moth
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import random
|
|
||||||
import shutil
|
|
||||||
import socketserver
|
|
||||||
import sys
|
|
||||||
import traceback
|
|
||||||
import mothballer
|
|
||||||
import parse
|
|
||||||
import urllib.parse
|
|
||||||
import posixpath
|
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
|
|
||||||
sys.dont_write_bytecode = True # Don't write .pyc files
|
|
||||||
|
|
||||||
|
|
||||||
class MothServer(socketserver.ForkingMixIn, http.server.HTTPServer):
|
|
||||||
def __init__(self, server_address, RequestHandlerClass):
|
|
||||||
super().__init__(server_address, RequestHandlerClass)
|
|
||||||
self.args = {}
|
|
||||||
|
|
||||||
|
|
||||||
class MothRequestHandler(http.server.SimpleHTTPRequestHandler):
|
|
||||||
endpoints = []
|
|
||||||
|
|
||||||
def __init__(self, request, client_address, server):
|
|
||||||
self.directory = str(server.args["theme_dir"])
|
|
||||||
try:
|
|
||||||
super().__init__(request, client_address, server, directory=server.args["theme_dir"])
|
|
||||||
except TypeError:
|
|
||||||
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
|
|
||||||
def translate_path(self, path):
|
|
||||||
# I guess we just hope that some other thread doesn't call getcwd
|
|
||||||
getcwd = os.getcwd
|
|
||||||
os.getcwd = lambda: self.directory
|
|
||||||
ret = super().translate_path(path)
|
|
||||||
os.getcwd = getcwd
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def get_puzzle(self):
|
|
||||||
category = self.req.get("cat")
|
|
||||||
points = int(self.req.get("points"))
|
|
||||||
catpath = str(self.server.args["puzzles_dir"].joinpath(category))
|
|
||||||
cat = moth.Category(catpath, self.seed)
|
|
||||||
puzzle = cat.puzzle(points)
|
|
||||||
return puzzle
|
|
||||||
|
|
||||||
|
|
||||||
def send_json(self, obj):
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps(obj).encode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_register(self):
|
|
||||||
# Everybody eats when they come to my house
|
|
||||||
ret = {
|
|
||||||
"status": "success",
|
|
||||||
"data": {
|
|
||||||
"short": "You win",
|
|
||||||
"description": "Welcome to the development server, you wily hacker you"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.send_json(ret)
|
|
||||||
endpoints.append(('/{seed}/register', handle_register))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_answer(self):
|
|
||||||
for f in ("cat", "points", "answer"):
|
|
||||||
self.req[f] = self.fields.getfirst(f)
|
|
||||||
puzzle = self.get_puzzle()
|
|
||||||
ret = {
|
|
||||||
"status": "success",
|
|
||||||
"data": {
|
|
||||||
"short": "",
|
|
||||||
"description": "%r was not in list of answers" % self.req.get("answer")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.req.get("answer") in puzzle.answers:
|
|
||||||
ret["data"]["description"] = "Answer is correct"
|
|
||||||
self.send_json(ret)
|
|
||||||
endpoints.append(('/{seed}/answer', handle_answer))
|
|
||||||
|
|
||||||
|
|
||||||
def puzzlelist(self):
|
|
||||||
puzzles = {}
|
|
||||||
for p in self.server.args["puzzles_dir"].glob("*"):
|
|
||||||
if not p.is_dir() or p.match(".*"):
|
|
||||||
continue
|
|
||||||
catName = p.parts[-1]
|
|
||||||
cat = moth.Category(str(p), self.seed)
|
|
||||||
puzzles[catName] = [[i, str(i)] for i in cat.pointvals()]
|
|
||||||
puzzles[catName].append([0, ""])
|
|
||||||
if len(puzzles) <= 1:
|
|
||||||
logging.warning("No directories found matching {}/*".format(self.server.args["puzzles_dir"]))
|
|
||||||
|
|
||||||
return puzzles
|
|
||||||
|
|
||||||
|
|
||||||
# XXX: Remove this (redundant) when we've upgraded the bundled theme (probably v3.6 and beyond)
|
|
||||||
def handle_puzzlelist(self):
|
|
||||||
self.send_json(self.puzzlelist())
|
|
||||||
endpoints.append(('/{seed}/puzzles.json', handle_puzzlelist))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_state(self):
|
|
||||||
resp = {
|
|
||||||
"Config": {
|
|
||||||
"Devel": True,
|
|
||||||
},
|
|
||||||
"Puzzles": self.puzzlelist(),
|
|
||||||
"Messages": "<p><b>[MOTH Development Server]</b> Participant broadcast messages would go here.</p>",
|
|
||||||
}
|
|
||||||
self.send_json(resp)
|
|
||||||
endpoints.append(('/{seed}/state', handle_state))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_puzzle(self):
|
|
||||||
puzzle = self.get_puzzle()
|
|
||||||
|
|
||||||
obj = puzzle.package()
|
|
||||||
obj["answers"] = puzzle.answers
|
|
||||||
obj["hint"] = puzzle.hint
|
|
||||||
obj["summary"] = puzzle.summary
|
|
||||||
obj["logs"] = puzzle.logs
|
|
||||||
obj["format"] = puzzle._source_format
|
|
||||||
|
|
||||||
self.send_json(obj)
|
|
||||||
endpoints.append(('/{seed}/content/{cat}/{points}/puzzle.json', handle_puzzle))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_puzzlefile(self):
|
|
||||||
puzzle = self.get_puzzle()
|
|
||||||
|
|
||||||
try:
|
|
||||||
file = puzzle.files[self.req["filename"]]
|
|
||||||
except KeyError:
|
|
||||||
self.send_error(
|
|
||||||
HTTPStatus.NOT_FOUND,
|
|
||||||
"File Not Found: %s" % self.req["filename"],
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", mimetypes.guess_type(file.name))
|
|
||||||
self.end_headers()
|
|
||||||
shutil.copyfileobj(file.stream, self.wfile)
|
|
||||||
endpoints.append(("/{seed}/content/{cat}/{points}/{filename}", handle_puzzlefile))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_mothballer(self):
|
|
||||||
category = self.req.get("cat")
|
|
||||||
|
|
||||||
try:
|
|
||||||
catdir = self.server.args["puzzles_dir"].joinpath(category)
|
|
||||||
mb = mothballer.package(category, str(catdir), self.seed)
|
|
||||||
except Exception as ex:
|
|
||||||
logging.exception(ex)
|
|
||||||
self.send_response(500)
|
|
||||||
self.send_header("Content-Type", "text/html; charset=\"utf-8\"")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(cgitb.html(sys.exc_info()).encode("utf-8"))
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "application/octet_stream")
|
|
||||||
self.end_headers()
|
|
||||||
shutil.copyfileobj(mb, self.wfile)
|
|
||||||
endpoints.append(("/{seed}/mothballer/{cat}.mb", handle_mothballer))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_index(self):
|
|
||||||
seed = random.getrandbits(32)
|
|
||||||
self.send_response(307)
|
|
||||||
self.send_header("Location", "%s/" % seed)
|
|
||||||
self.send_header("Content-Type", "text/html")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b"Your browser was supposed to redirect you to <a href=\"%i/\">here</a>." % seed)
|
|
||||||
endpoints.append((r"/", handle_index))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_theme_file(self):
|
|
||||||
self.path = "/" + self.req.get("path", "")
|
|
||||||
super().do_GET()
|
|
||||||
endpoints.append(("/{seed}/", handle_theme_file))
|
|
||||||
endpoints.append(("/{seed}/{path}", handle_theme_file))
|
|
||||||
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
self.fields = cgi.FieldStorage(
|
|
||||||
fp=self.rfile,
|
|
||||||
headers=self.headers,
|
|
||||||
environ={
|
|
||||||
"REQUEST_METHOD": self.command,
|
|
||||||
"CONTENT_TYPE": self.headers["Content-Type"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
url = urllib.parse.urlparse(self.path)
|
|
||||||
for pattern, function in self.endpoints:
|
|
||||||
result = parse.parse(pattern, url.path)
|
|
||||||
if result:
|
|
||||||
self.req = result.named
|
|
||||||
seed = self.req.get("seed", "random")
|
|
||||||
if seed == "random":
|
|
||||||
self.seed = random.getrandbits(32)
|
|
||||||
else:
|
|
||||||
self.seed = int(seed)
|
|
||||||
return function(self)
|
|
||||||
super().do_GET()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
self.do_GET()
|
|
||||||
|
|
||||||
def do_HEAD(self):
|
|
||||||
self.send_error(
|
|
||||||
HTTPStatus.NOT_IMPLEMENTED,
|
|
||||||
"Unsupported method (%r)" % self.command,
|
|
||||||
)
|
|
||||||
|
|
||||||
# I don't fully understand why you can't do this inside the class definition.
|
|
||||||
MothRequestHandler.extensions_map[".mjs"] = "application/ecmascript"
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="MOTH puzzle development server")
|
|
||||||
parser.add_argument(
|
|
||||||
'--puzzles', default='puzzles',
|
|
||||||
help="Directory containing your puzzles"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--theme', default='theme',
|
|
||||||
help="Directory containing theme files")
|
|
||||||
parser.add_argument(
|
|
||||||
'--bind', default=":8080",
|
|
||||||
help="Bind to ip:port"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--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]
|
|
||||||
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=log_level)
|
|
||||||
|
|
||||||
mimetypes.add_type("application/javascript", ".mjs")
|
|
||||||
|
|
||||||
server = MothServer((addr, port), MothRequestHandler)
|
|
||||||
server.args["base_url"] = args.base
|
|
||||||
server.args["puzzles_dir"] = pathlib.Path(args.puzzles)
|
|
||||||
server.args["theme_dir"] = args.theme
|
|
||||||
|
|
||||||
logging.info("Listening on %s:%d", addr, port)
|
|
||||||
server.serve_forever()
|
|
1190
devel/mistune.py
1190
devel/mistune.py
File diff suppressed because it is too large
Load Diff
508
devel/moth.py
508
devel/moth.py
|
@ -1,508 +0,0 @@
|
||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
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 sha256hash(str):
|
|
||||||
return hashlib.sha256(str.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def pushd(newdir):
|
|
||||||
newdir = str(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)
|
|
||||||
|
|
||||||
|
|
||||||
def loadmod(name, path):
|
|
||||||
abspath = os.path.abspath(path)
|
|
||||||
loader = importlib.machinery.SourceFileLoader(name, abspath)
|
|
||||||
return loader.load_module()
|
|
||||||
|
|
||||||
|
|
||||||
# Get a big list of clean words for our answer file.
|
|
||||||
ANSWER_WORDS = [w.strip() for w in open(os.path.join(os.path.dirname(__file__),
|
|
||||||
'answer_words.txt'))]
|
|
||||||
|
|
||||||
class PuzzleFile:
|
|
||||||
"""A file associated with a puzzle.
|
|
||||||
|
|
||||||
path: The path to the original input file. May be None (when this is created from a file handle
|
|
||||||
and there is no original input.
|
|
||||||
handle: A File-like object set to read the file from. You should be able to read straight
|
|
||||||
from it without having to seek to the beginning of the file.
|
|
||||||
name: The name of the output file.
|
|
||||||
visible: A boolean indicating whether this file should visible to the user. If False,
|
|
||||||
the file is still expected to be accessible, but it's path must be known
|
|
||||||
(or figured out) to retrieve it."""
|
|
||||||
|
|
||||||
def __init__(self, stream, name, visible=True):
|
|
||||||
self.stream = stream
|
|
||||||
self.name = name
|
|
||||||
self.visible = visible
|
|
||||||
|
|
||||||
class PuzzleSuccess(dict):
|
|
||||||
"""Puzzle success objectives
|
|
||||||
|
|
||||||
:param acceptable: Learning outcome from acceptable knowledge of the subject matter
|
|
||||||
:param mastery: Learning outcome from mastery of the subject matter
|
|
||||||
"""
|
|
||||||
|
|
||||||
valid_fields = ["acceptable", "mastery"]
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super(PuzzleSuccess, self).__init__()
|
|
||||||
for key in self.valid_fields:
|
|
||||||
self[key] = None
|
|
||||||
for key, value in kwargs.items():
|
|
||||||
if key in self.valid_fields:
|
|
||||||
self[key] = value
|
|
||||||
|
|
||||||
def __getattr__(self, attr):
|
|
||||||
if attr in self.valid_fields:
|
|
||||||
return self[attr]
|
|
||||||
raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr))
|
|
||||||
|
|
||||||
def __setattr__(self, attr, value):
|
|
||||||
if attr in self.valid_fields:
|
|
||||||
self[attr] = value
|
|
||||||
else:
|
|
||||||
raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr))
|
|
||||||
|
|
||||||
|
|
||||||
class Puzzle:
|
|
||||||
def __init__(self, category_seed, points):
|
|
||||||
"""A MOTH Puzzle.
|
|
||||||
|
|
||||||
:param category_seed: A byte string to use as a seed for random numbers for this puzzle.
|
|
||||||
It is combined with the puzzle points.
|
|
||||||
:param points: The point value of the puzzle.
|
|
||||||
"""
|
|
||||||
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
self._source_format = "py"
|
|
||||||
|
|
||||||
self.points = points
|
|
||||||
self.summary = None
|
|
||||||
self.authors = []
|
|
||||||
self.answers = []
|
|
||||||
self.xAnchors = {"begin", "end"}
|
|
||||||
self.scripts = []
|
|
||||||
self.pattern = None
|
|
||||||
self.hint = None
|
|
||||||
self.files = {}
|
|
||||||
self.body = io.StringIO()
|
|
||||||
|
|
||||||
# NIST NICE objective content
|
|
||||||
self.objective = None # Text describing the expected learning outcome from solving this puzzle, *why* are you solving this puzzle
|
|
||||||
self.success = PuzzleSuccess() # Text describing criteria for different levels of success, e.g. {"Acceptable": "Did OK", "Mastery": "Did even better"}
|
|
||||||
self.solution = None # Text describing how to solve the puzzle
|
|
||||||
self.ksas = [] # A list of references to related NICE KSAs (e.g. K0058, . . .)
|
|
||||||
|
|
||||||
self.logs = []
|
|
||||||
self.randseed = category_seed * self.points
|
|
||||||
self.rand = random.Random(self.randseed)
|
|
||||||
|
|
||||||
def log(self, *vals):
|
|
||||||
"""Add a new log message to this puzzle."""
|
|
||||||
msg = ' '.join(str(v) for v in vals)
|
|
||||||
self.logs.append(msg)
|
|
||||||
|
|
||||||
def read_stream(self, stream):
|
|
||||||
header = True
|
|
||||||
line = ""
|
|
||||||
if stream.read(3) == "---":
|
|
||||||
header = "yaml"
|
|
||||||
self._source_format = "yaml"
|
|
||||||
else:
|
|
||||||
header = "moth"
|
|
||||||
self._source_format = "moth"
|
|
||||||
|
|
||||||
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:
|
|
||||||
self.body.write(line)
|
|
||||||
|
|
||||||
def read_yaml_header(self, stream):
|
|
||||||
contents = ""
|
|
||||||
header = False
|
|
||||||
for line in stream:
|
|
||||||
if line.strip() == "---" and header: # Handle last line
|
|
||||||
break
|
|
||||||
elif line.strip() == "---": # Handle first line
|
|
||||||
header = True
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
contents += line
|
|
||||||
|
|
||||||
config = yaml.safe_load(contents)
|
|
||||||
for key, value in config.items():
|
|
||||||
key = key.lower()
|
|
||||||
self.handle_header_key(key, value)
|
|
||||||
|
|
||||||
def read_moth_header(self, stream):
|
|
||||||
for line in stream:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
break
|
|
||||||
|
|
||||||
key, val = line.split(':', 1)
|
|
||||||
key = key.lower()
|
|
||||||
val = val.strip()
|
|
||||||
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':
|
|
||||||
if not isinstance(val, list):
|
|
||||||
raise ValueError("Authors must be a list, got %s, instead" & (type(val),))
|
|
||||||
self.authors = list(val)
|
|
||||||
elif key == 'summary':
|
|
||||||
self.summary = val
|
|
||||||
elif key == 'answer':
|
|
||||||
if not isinstance(val, str):
|
|
||||||
raise ValueError("Answers must be strings, got %s, instead" % (type(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":
|
|
||||||
for answer in val:
|
|
||||||
if not isinstance(answer, str):
|
|
||||||
raise ValueError("Answers must be strings, got %s, instead" % (type(answer),))
|
|
||||||
self.answers.append(answer)
|
|
||||||
elif key == 'pattern':
|
|
||||||
self.pattern = val
|
|
||||||
elif key == 'hint':
|
|
||||||
self.hint = val
|
|
||||||
elif key == 'name':
|
|
||||||
pass
|
|
||||||
elif key == 'file':
|
|
||||||
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]
|
|
||||||
hidden = (parts[2].lower() == "hidden")
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
self.files[name] = PuzzleFile(stream, name, not hidden)
|
|
||||||
|
|
||||||
elif key == 'files' and isinstance(val, dict):
|
|
||||||
for filename, options in val.items():
|
|
||||||
if not options:
|
|
||||||
options = {}
|
|
||||||
source = options.get("source", filename)
|
|
||||||
hidden = options.get("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':
|
|
||||||
stream = open(val, 'rb')
|
|
||||||
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":
|
|
||||||
self.objective = val
|
|
||||||
elif key == "success":
|
|
||||||
# Force success dictionary keys to be lower-case
|
|
||||||
self.success = dict((x.lower(), y) for x,y in val.items())
|
|
||||||
elif key == "success.acceptable":
|
|
||||||
self.success.acceptable = val
|
|
||||||
elif key == "success.mastery":
|
|
||||||
self.success.mastery = val
|
|
||||||
elif key == "solution":
|
|
||||||
self.solution = val
|
|
||||||
elif key == "ksas":
|
|
||||||
if not isinstance(val, list):
|
|
||||||
raise ValueError("KSAs must be a list, got %s, instead" & (type(val),))
|
|
||||||
self.ksas = val
|
|
||||||
elif key == "ksa":
|
|
||||||
self.ksas.append(val)
|
|
||||||
else:
|
|
||||||
raise ValueError("Unrecognized header field: {}".format(key))
|
|
||||||
|
|
||||||
|
|
||||||
def read_directory(self, path):
|
|
||||||
try:
|
|
||||||
puzzle_mod = loadmod("puzzle", os.path.join(path, "puzzle.py"))
|
|
||||||
except FileNotFoundError:
|
|
||||||
puzzle_mod = None
|
|
||||||
|
|
||||||
with pushd(path):
|
|
||||||
if puzzle_mod:
|
|
||||||
puzzle_mod.make(self)
|
|
||||||
elif os.path.exists('puzzle.moth'):
|
|
||||||
with open('puzzle.moth') as f:
|
|
||||||
self.read_stream(f)
|
|
||||||
else:
|
|
||||||
self.authors = ["boggarts"]
|
|
||||||
self.body.write("This puzzle is broken! It has no puzzle.py or puzzle.moth.")
|
|
||||||
|
|
||||||
def random_hash(self):
|
|
||||||
"""Create a file basename (no extension) with our number generator."""
|
|
||||||
return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8))
|
|
||||||
|
|
||||||
def make_temp_file(self, name=None, visible=True):
|
|
||||||
"""Get a file object for adding dynamically generated data to the puzzle. When you're
|
|
||||||
done with this file, flush it, but don't close it.
|
|
||||||
|
|
||||||
:param name: The name of the file for links within the puzzle. If this is None, a name
|
|
||||||
will be generated for you.
|
|
||||||
:param visible: Whether or not the file will be visible to the user.
|
|
||||||
:return: A file object for writing
|
|
||||||
"""
|
|
||||||
|
|
||||||
stream = tempfile.TemporaryFile()
|
|
||||||
self.add_stream(stream, name, visible)
|
|
||||||
return stream
|
|
||||||
|
|
||||||
def add_script_stream(self, stream, name):
|
|
||||||
# Make sure this shows up in the header block of the HTML output.
|
|
||||||
self.files[name] = PuzzleFile(stream, name, visible=False)
|
|
||||||
self.scripts.append(name)
|
|
||||||
|
|
||||||
def add_stream(self, stream, name=None, visible=True):
|
|
||||||
if name is None:
|
|
||||||
name = self.random_hash()
|
|
||||||
self.files[name] = PuzzleFile(stream, name, visible)
|
|
||||||
|
|
||||||
def create_stream(self, name=None, visible=True):
|
|
||||||
stream = io.BytesIO()
|
|
||||||
self.add_stream(stream, name, visible)
|
|
||||||
return stream
|
|
||||||
|
|
||||||
def add_file(self, filename, visible=True):
|
|
||||||
fd = open(filename, 'rb')
|
|
||||||
name = os.path.basename(filename)
|
|
||||||
self.add_stream(fd, name=name, visible=visible)
|
|
||||||
|
|
||||||
def randword(self):
|
|
||||||
"""Return a randomly-chosen word"""
|
|
||||||
|
|
||||||
return self.rand.choice(ANSWER_WORDS)
|
|
||||||
|
|
||||||
def make_answer(self, word_count=4, sep=' '):
|
|
||||||
"""Generate and return a new answer. It's automatically added to the puzzle answer list.
|
|
||||||
:param int word_count: The number of words to include in the answer.
|
|
||||||
:param str|bytes sep: The word separator.
|
|
||||||
:returns: The answer string
|
|
||||||
"""
|
|
||||||
|
|
||||||
words = [self.randword() for i in range(word_count)]
|
|
||||||
answer = sep.join(words)
|
|
||||||
self.answers.append(answer)
|
|
||||||
return answer
|
|
||||||
|
|
||||||
hexdump_stdch = stdch = (
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
' !"#$%&\'()*+,-./'
|
|
||||||
'0123456789:;<=>?'
|
|
||||||
'@ABCDEFGHIJKLMNO'
|
|
||||||
'PQRSTUVWXYZ[\]^_'
|
|
||||||
'`abcdefghijklmno'
|
|
||||||
'pqrstuvwxyz{|}~·'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
)
|
|
||||||
|
|
||||||
def hexdump(self, buf, charset=hexdump_stdch, gap=('<EFBFBD>', '⌷')):
|
|
||||||
hexes, chars = [], []
|
|
||||||
out = []
|
|
||||||
|
|
||||||
for b in buf:
|
|
||||||
if len(chars) == 16:
|
|
||||||
out.append((hexes, chars))
|
|
||||||
hexes, chars = [], []
|
|
||||||
|
|
||||||
if b is None:
|
|
||||||
h, c = gap
|
|
||||||
else:
|
|
||||||
h = '{:02x}'.format(b)
|
|
||||||
c = charset[b]
|
|
||||||
chars.append(c)
|
|
||||||
hexes.append(h)
|
|
||||||
|
|
||||||
out.append((hexes, chars))
|
|
||||||
|
|
||||||
offset = 0
|
|
||||||
elided = False
|
|
||||||
lastchars = None
|
|
||||||
self.body.write('<pre>')
|
|
||||||
for hexes, chars in out:
|
|
||||||
if chars == lastchars:
|
|
||||||
offset += len(chars)
|
|
||||||
if not elided:
|
|
||||||
self.body.write('*\n')
|
|
||||||
elided = True
|
|
||||||
continue
|
|
||||||
lastchars = chars[:]
|
|
||||||
elided = False
|
|
||||||
|
|
||||||
pad = 16 - len(chars)
|
|
||||||
hexes += [' '] * pad
|
|
||||||
|
|
||||||
self.body.write('{:08x} '.format(offset))
|
|
||||||
self.body.write(' '.join(hexes[:8]))
|
|
||||||
self.body.write(' ')
|
|
||||||
self.body.write(' '.join(hexes[8:]))
|
|
||||||
self.body.write(' |')
|
|
||||||
self.body.write(html.escape(''.join(chars)))
|
|
||||||
self.body.write('|\n')
|
|
||||||
offset += len(chars)
|
|
||||||
self.body.write('{:08x}\n'.format(offset))
|
|
||||||
self.body.write('</pre>')
|
|
||||||
|
|
||||||
def get_authors(self):
|
|
||||||
if len(self.authors) > 0:
|
|
||||||
return self.authors
|
|
||||||
elif hasattr(self, "author"):
|
|
||||||
return [self.author]
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_body(self):
|
|
||||||
return self.body.getvalue()
|
|
||||||
|
|
||||||
def html_body(self):
|
|
||||||
"""Format and return the markdown for the puzzle body."""
|
|
||||||
return mistune.markdown(self.get_body(), escape=False)
|
|
||||||
|
|
||||||
def package(self, answers=False):
|
|
||||||
"""Return a dict packaging of the puzzle."""
|
|
||||||
|
|
||||||
files = sorted([fn for fn,f in self.files.items() if f.visible])
|
|
||||||
hidden = [fn for fn,f in self.files.items() if not f.visible]
|
|
||||||
return {
|
|
||||||
'authors': self.get_authors(),
|
|
||||||
'hashes': self.hashes(),
|
|
||||||
'files': files,
|
|
||||||
'hidden': hidden,
|
|
||||||
'scripts': self.scripts,
|
|
||||||
'pattern': self.pattern,
|
|
||||||
'body': self.html_body(),
|
|
||||||
'objective': self.objective,
|
|
||||||
'success': self.success,
|
|
||||||
'solution': self.solution,
|
|
||||||
'ksas': self.ksas,
|
|
||||||
'xAnchors': list(self.xAnchors),
|
|
||||||
}
|
|
||||||
|
|
||||||
def hashes(self):
|
|
||||||
"Return a list of answer hashes"
|
|
||||||
|
|
||||||
return [sha256hash(a) for a in self.answers]
|
|
||||||
|
|
||||||
|
|
||||||
class Category:
|
|
||||||
def __init__(self, path, seed):
|
|
||||||
self.path = path
|
|
||||||
self.seed = seed
|
|
||||||
self.catmod = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.catmod = loadmod('category', os.path.join(path, 'category.py'))
|
|
||||||
except FileNotFoundError:
|
|
||||||
self.catmod = None
|
|
||||||
|
|
||||||
def pointvals(self):
|
|
||||||
if self.catmod:
|
|
||||||
with pushd(self.path):
|
|
||||||
pointvals = self.catmod.pointvals()
|
|
||||||
else:
|
|
||||||
pointvals = []
|
|
||||||
for fpath in glob.glob(os.path.join(self.path, "[0-9]*")):
|
|
||||||
pn = os.path.basename(fpath)
|
|
||||||
points = int(pn)
|
|
||||||
pointvals.append(points)
|
|
||||||
return sorted(pointvals)
|
|
||||||
|
|
||||||
def puzzle(self, points):
|
|
||||||
puzzle = Puzzle(self.seed, points)
|
|
||||||
path = os.path.join(self.path, str(points))
|
|
||||||
if self.catmod:
|
|
||||||
with pushd(self.path):
|
|
||||||
self.catmod.make(points, puzzle)
|
|
||||||
else:
|
|
||||||
puzzle.read_directory(path)
|
|
||||||
return puzzle
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
for points in self.pointvals():
|
|
||||||
yield self.puzzle(points)
|
|
|
@ -1,132 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import binascii
|
|
||||||
import datetime
|
|
||||||
import hashlib
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import moth
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import zipfile
|
|
||||||
import random
|
|
||||||
|
|
||||||
SEEDFN = "SEED"
|
|
||||||
|
|
||||||
|
|
||||||
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 isinstance(kv[key], list):
|
|
||||||
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('&', '&').replace('<', '<').replace('>', '>')
|
|
||||||
|
|
||||||
|
|
||||||
def build_category(categorydir, outdir):
|
|
||||||
category_seed = random.getrandbits(32)
|
|
||||||
|
|
||||||
categoryname = os.path.basename(categorydir.strip(os.sep))
|
|
||||||
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 = int(existing.open(SEEDFN).read().strip())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
existing.close()
|
|
||||||
logging.debug("Using PRNG seed {}".format(category_seed))
|
|
||||||
|
|
||||||
zipfileraw = tempfile.NamedTemporaryFile(delete=False)
|
|
||||||
mothball = package(categoryname, categorydir, category_seed)
|
|
||||||
shutil.copyfileobj(mothball, zipfileraw)
|
|
||||||
zipfileraw.close()
|
|
||||||
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
|
|
||||||
def package(categoryname, categorydir, seed):
|
|
||||||
zfraw = io.BytesIO()
|
|
||||||
zf = zipfile.ZipFile(zfraw, 'x')
|
|
||||||
zf.writestr("category_seed.txt", str(seed))
|
|
||||||
|
|
||||||
cat = moth.Category(categorydir, seed)
|
|
||||||
answers = {}
|
|
||||||
summary = {}
|
|
||||||
puzzles = []
|
|
||||||
for puzzle in cat:
|
|
||||||
logging.info("Processing point value {}".format(puzzle.points))
|
|
||||||
|
|
||||||
puzzles.append(puzzle.points)
|
|
||||||
answers[puzzle.points] = puzzle.answers
|
|
||||||
summary[puzzle.points] = puzzle.summary
|
|
||||||
|
|
||||||
puzzledir = os.path.join("content", str(puzzle.points))
|
|
||||||
for fn, f in puzzle.files.items():
|
|
||||||
payload = f.stream.read()
|
|
||||||
zf.writestr(os.path.join(puzzledir, fn), payload)
|
|
||||||
|
|
||||||
obj = puzzle.package()
|
|
||||||
zf.writestr(os.path.join(puzzledir, 'puzzle.json'), json.dumps(obj))
|
|
||||||
|
|
||||||
zf.writestr("puzzles.txt", "\n".join(str(p) for p in puzzles) + "\n")
|
|
||||||
write_kv_pairs(zf, 'answers.txt', answers)
|
|
||||||
write_kv_pairs(zf, 'summaries.txt', summary)
|
|
||||||
write_metadata(zf, cat)
|
|
||||||
|
|
||||||
# clean up
|
|
||||||
zf.close()
|
|
||||||
zfraw.seek(0)
|
|
||||||
return zfraw
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
outdir = os.path.abspath(args.outdir)
|
|
||||||
for categorydir in args.categorydirs:
|
|
||||||
categorydir = os.path.abspath(categorydir)
|
|
||||||
build_category(categorydir, outdir)
|
|
|
@ -1,18 +0,0 @@
|
||||||
# To install:
|
|
||||||
# sudo cp mothd.service /etc/systemd/system/moth.service
|
|
||||||
# sudo systemctl enable mothd
|
|
||||||
# sudo systemctl start mothd
|
|
||||||
|
|
||||||
[Unit]
|
|
||||||
Description=Monarch Of The Hill server
|
|
||||||
After=network.target auditd.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
WorkingDirectory=/srv/moth
|
|
||||||
User=www-data
|
|
||||||
ExecStart=/srv/moth/mothd
|
|
||||||
KillMode=process
|
|
||||||
Restart=on-failure
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
1335
devel/parse.py
1335
devel/parse.py
File diff suppressed because it is too large
Load Diff
|
@ -1,8 +0,0 @@
|
||||||
[flake8]
|
|
||||||
# flake8 is an automated code formatting pedant.
|
|
||||||
# Use it, please.
|
|
||||||
#
|
|
||||||
# python3 -m flake8 .
|
|
||||||
#
|
|
||||||
ignore = E501
|
|
||||||
exclude = .git
|
|
|
@ -1,19 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
set +e
|
|
||||||
|
|
||||||
url='https://rawgit.com/first20hours/google-10000-english/master/google-10000-english-no-swears.txt'
|
|
||||||
getter="curl -sL"
|
|
||||||
fn="answer_words.txt"
|
|
||||||
|
|
||||||
filterer() {
|
|
||||||
grep '......*'
|
|
||||||
}
|
|
||||||
|
|
||||||
if ! curl -h >/dev/null 2>/dev/null; then
|
|
||||||
getter="wget -q -O -"
|
|
||||||
elif ! wget -h >/dev/null 2>/dev/null; then
|
|
||||||
echo "[!] I don't know how to download. I need curl or wget."
|
|
||||||
fi
|
|
||||||
|
|
||||||
$getter "${url}" | filterer > ${fn}.tmp \
|
|
||||||
&& mv -f ${fn}.tmp ${fn}
|
|
|
@ -1,229 +0,0 @@
|
||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
"""A validator for MOTH puzzles"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import os.path
|
|
||||||
import re
|
|
||||||
|
|
||||||
import moth
|
|
||||||
|
|
||||||
# pylint: disable=len-as-condition, line-too-long
|
|
||||||
|
|
||||||
DEFAULT_REQUIRED_FIELDS = ["answers", "authors", "summary"]
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MothValidationError(Exception):
|
|
||||||
|
|
||||||
"""An exception for encapsulating MOTH puzzle validation errors"""
|
|
||||||
|
|
||||||
|
|
||||||
class MothValidator:
|
|
||||||
|
|
||||||
"""A class which validates MOTH categories"""
|
|
||||||
|
|
||||||
def __init__(self, fields):
|
|
||||||
self.required_fields = fields
|
|
||||||
self.results = {"category": {}, "checks": []}
|
|
||||||
|
|
||||||
def validate(self, categorydir, only_errors=False):
|
|
||||||
"""Run validation checks against a category"""
|
|
||||||
LOGGER.debug("Loading category from %s", categorydir)
|
|
||||||
try:
|
|
||||||
category = moth.Category(categorydir, 0)
|
|
||||||
except NotADirectoryError:
|
|
||||||
return
|
|
||||||
|
|
||||||
LOGGER.debug("Found %d puzzles in %s", len(category.pointvals()), categorydir)
|
|
||||||
|
|
||||||
self.results["category"][categorydir] = {
|
|
||||||
"puzzles": {},
|
|
||||||
"name": os.path.basename(categorydir.strip(os.sep)),
|
|
||||||
}
|
|
||||||
curr_category = self.results["category"][categorydir]
|
|
||||||
|
|
||||||
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
|
|
||||||
if check_function_name not in self.results["checks"]:
|
|
||||||
self.results["checks"].append(check_function_name)
|
|
||||||
|
|
||||||
for puzzle in category:
|
|
||||||
LOGGER.info("Processing %s: %s", categorydir, puzzle.points)
|
|
||||||
|
|
||||||
curr_category["puzzles"][puzzle.points] = {}
|
|
||||||
curr_puzzle = curr_category["puzzles"][puzzle.points]
|
|
||||||
curr_puzzle["failures"] = []
|
|
||||||
|
|
||||||
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
|
|
||||||
check_function = getattr(self, check_function_name)
|
|
||||||
LOGGER.debug("Running %s on %d", check_function_name, puzzle.points)
|
|
||||||
|
|
||||||
try:
|
|
||||||
check_function(puzzle)
|
|
||||||
except MothValidationError as ex:
|
|
||||||
curr_puzzle["failures"].append(str(ex))
|
|
||||||
|
|
||||||
if only_errors and len(curr_puzzle["failures"]) == 0:
|
|
||||||
del curr_category["puzzles"][puzzle.points]
|
|
||||||
|
|
||||||
def check_fields(self, puzzle):
|
|
||||||
"""Check if the puzzle has the requested fields"""
|
|
||||||
for field in self.required_fields:
|
|
||||||
if not hasattr(puzzle, field) or \
|
|
||||||
getattr(puzzle,field) is None or \
|
|
||||||
getattr(puzzle,field) == "":
|
|
||||||
raise MothValidationError("Missing field %s" % (field,))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_has_answers(puzzle):
|
|
||||||
"""Check if the puzle has answers defined"""
|
|
||||||
if len(puzzle.answers) == 0:
|
|
||||||
raise MothValidationError("No answers provided")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_unique_answers(puzzle):
|
|
||||||
"""Check if puzzle answers are unique"""
|
|
||||||
known_answers = []
|
|
||||||
duplicate_answers = []
|
|
||||||
|
|
||||||
for answer in puzzle.answers:
|
|
||||||
if answer not in known_answers:
|
|
||||||
known_answers.append(answer)
|
|
||||||
else:
|
|
||||||
duplicate_answers.append(answer)
|
|
||||||
|
|
||||||
if len(duplicate_answers) > 0:
|
|
||||||
raise MothValidationError("Duplicate answer(s) %s" % ", ".join(duplicate_answers))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_has_authors(puzzle):
|
|
||||||
"""Check if the puzzle has authors defined"""
|
|
||||||
if len(puzzle.authors) == 0:
|
|
||||||
raise MothValidationError("No authors provided")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_unique_authors(puzzle):
|
|
||||||
"""Check if puzzle authors are unique"""
|
|
||||||
known_authors = []
|
|
||||||
duplicate_authors = []
|
|
||||||
|
|
||||||
for author in puzzle.authors:
|
|
||||||
if author not in known_authors:
|
|
||||||
known_authors.append(author)
|
|
||||||
else:
|
|
||||||
duplicate_authors.append(author)
|
|
||||||
|
|
||||||
if len(duplicate_authors) > 0:
|
|
||||||
raise MothValidationError("Duplicate author(s) %s" % ", ".join(duplicate_authors))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_has_summary(puzzle):
|
|
||||||
"""Check if the puzzle has a summary"""
|
|
||||||
if puzzle.summary is None:
|
|
||||||
raise MothValidationError("Summary has not been provided")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_has_body(puzzle):
|
|
||||||
"""Check if the puzzle has a body defined"""
|
|
||||||
old_pos = puzzle.body.tell()
|
|
||||||
puzzle.body.seek(0)
|
|
||||||
if len(puzzle.body.read()) == 0:
|
|
||||||
puzzle.body.seek(old_pos)
|
|
||||||
raise MothValidationError("No body provided")
|
|
||||||
|
|
||||||
puzzle.body.seek(old_pos)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_ksa_format(puzzle):
|
|
||||||
"""Check if KSAs are properly formatted"""
|
|
||||||
|
|
||||||
ksa_re = re.compile("^[KSA]\d{4}$")
|
|
||||||
|
|
||||||
if hasattr(puzzle, "ksa"):
|
|
||||||
for ksa in puzzle.ksa:
|
|
||||||
if ksa_re.match(ksa) is None:
|
|
||||||
raise MothValidationError("Unrecognized KSA format (%s)" % (ksa,))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_success(puzzle):
|
|
||||||
"""Check if success criteria are defined"""
|
|
||||||
|
|
||||||
if not hasattr(puzzle, "success"):
|
|
||||||
raise MothValidationError("Success not defined")
|
|
||||||
|
|
||||||
criteria = ["acceptable", "mastery"]
|
|
||||||
missing_criteria = []
|
|
||||||
for criterion in criteria:
|
|
||||||
if criterion not in puzzle.success.keys() or \
|
|
||||||
puzzle.success[criterion] is None or \
|
|
||||||
len(puzzle.success[criterion]) == 0:
|
|
||||||
missing_criteria.append(criterion)
|
|
||||||
|
|
||||||
if len(missing_criteria) > 0:
|
|
||||||
raise MothValidationError("Missing success criteria (%s)" % (", ".join(missing_criteria)))
|
|
||||||
|
|
||||||
|
|
||||||
def output_json(data):
|
|
||||||
"""Output results in JSON format"""
|
|
||||||
import json
|
|
||||||
print(json.dumps(data))
|
|
||||||
|
|
||||||
|
|
||||||
def output_text(data):
|
|
||||||
"""Output results in a text-based tabular format"""
|
|
||||||
|
|
||||||
longest_category = max([len(y["name"]) for x, y in data["category"].items()])
|
|
||||||
longest_category = max([longest_category, len("Category")])
|
|
||||||
longest_failure = len("Failures")
|
|
||||||
for category_data in data["category"].values():
|
|
||||||
for points, puzzle_data in category_data["puzzles"].items():
|
|
||||||
longest_failure = max([longest_failure, len(", ".join(puzzle_data["failures"]))])
|
|
||||||
|
|
||||||
formatstr = "| %%%ds | %%6s | %%%ds |" % (longest_category, longest_failure)
|
|
||||||
headerfmt = formatstr % ("Category", "Points", "Failures")
|
|
||||||
|
|
||||||
print(headerfmt)
|
|
||||||
for cat_data in data["category"].values():
|
|
||||||
for points, puzzle_data in sorted(cat_data["puzzles"].items()):
|
|
||||||
print(formatstr % (cat_data["name"], points, ", ".join([str(x) for x in puzzle_data["failures"]])))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main function"""
|
|
||||||
# pylint: disable=invalid-name
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
LOGGER.addHandler(logging.StreamHandler())
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Validate MOTH puzzle field compliance")
|
|
||||||
parser.add_argument("category", nargs="+", help="Categories to validate")
|
|
||||||
parser.add_argument("-f", "--fields", help="Comma-separated list of fields to check for", default=",".join(DEFAULT_REQUIRED_FIELDS))
|
|
||||||
|
|
||||||
parser.add_argument("-o", "--output-format", choices=["text", "json"], default="text", help="Output format (default: text)")
|
|
||||||
parser.add_argument("-e", "--only-errors", action="store_true", default=False, help="Only output errors")
|
|
||||||
parser.add_argument("-v", "--verbose", action="count", default=0, help="Increase verbosity of output, repeat to increase")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.verbose == 1:
|
|
||||||
LOGGER.setLevel("INFO")
|
|
||||||
elif args.verbose > 1:
|
|
||||||
LOGGER.setLevel("DEBUG")
|
|
||||||
|
|
||||||
LOGGER.debug(args)
|
|
||||||
validator = MothValidator(args.fields.split(","))
|
|
||||||
|
|
||||||
for category in args.category:
|
|
||||||
LOGGER.info("Validating %s", category)
|
|
||||||
validator.validate(category, only_errors=args.only_errors)
|
|
||||||
|
|
||||||
if args.output_format == "text":
|
|
||||||
output_text(validator.results)
|
|
||||||
elif args.output_format == "json":
|
|
||||||
output_json(validator.results)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
|
@ -0,0 +1,125 @@
|
||||||
|
Administration
|
||||||
|
=========
|
||||||
|
|
||||||
|
Everything you need to do happens through the filesystem.
|
||||||
|
Usually, in `/srv/moth/state`.
|
||||||
|
|
||||||
|
The server doesn't cache anything in memory,
|
||||||
|
so the `state` directory always contains the current state.
|
||||||
|
|
||||||
|
|
||||||
|
Backing up current state
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
tar czf backup.tar.gz /srv/moth/state # Full backup
|
||||||
|
curl http://localhost:8080/state > state.json # Pull anonymized event log and team names (scoreboard)
|
||||||
|
|
||||||
|
|
||||||
|
Pausing/resuming scoring
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
rm /srv/moth/state/enabled # Pause scoring
|
||||||
|
touch /srv/moth/state/enabled # Resume scoring
|
||||||
|
|
||||||
|
When scoring is paused,
|
||||||
|
participants can still submit answers,
|
||||||
|
and the system will tell them whether the answer is correct.
|
||||||
|
As soon as you unpause,
|
||||||
|
all correctly-submitted answers will be scored.
|
||||||
|
|
||||||
|
|
||||||
|
Scheduling an automatic pause and resume
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
printf '-'; date --rfc-3339=s -d '10:00 PM' >> /srv/moth/state/hours.txt # Schedule suspend at 10:00 PM
|
||||||
|
printf '+'; date --rfc-3339=s -d '08:00 tomorrow' >> /srv/moth/state/hours.txt # Schedule resume at 08:00 tomorrow
|
||||||
|
|
||||||
|
You might prefer to open `/srv/moth/state/hours.txt` in a text editor.
|
||||||
|
I do.
|
||||||
|
|
||||||
|
|
||||||
|
Re-initalize
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
rm /srv/moth/state/initialized
|
||||||
|
|
||||||
|
This will reset the following:
|
||||||
|
|
||||||
|
* team registrations
|
||||||
|
* points log
|
||||||
|
|
||||||
|
Team tokens stick around, though.
|
||||||
|
|
||||||
|
|
||||||
|
Setting up custom team IDs
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
echo > /srv/moth/state/teamids.txt # Teams must be registered manually
|
||||||
|
seq 9999 > /srv/moth/state/teamids.txt # Allow all 4-digit numbers
|
||||||
|
|
||||||
|
`teamids.txt` is a list of acceptable team IDs,
|
||||||
|
one per line.
|
||||||
|
You can make it anything you want.
|
||||||
|
|
||||||
|
New instances will initialize this with some hex values.
|
||||||
|
|
||||||
|
Remember that team IDs are essentially passwords.
|
||||||
|
|
||||||
|
|
||||||
|
Adjusting scores
|
||||||
|
------------------
|
||||||
|
|
||||||
|
rm /srv/moth/state/enabled # Suspend scoring
|
||||||
|
nano /srv/moth/state/points.log
|
||||||
|
touch /srv/moth/state/enabled # Resume scoring
|
||||||
|
|
||||||
|
We don't warn participants before we do this:
|
||||||
|
any points scored while scoring is suspended are queued up and processed as soon as scoreing is resumed.
|
||||||
|
|
||||||
|
It's very important to suspend scoring before mucking around with the points log.
|
||||||
|
The maintenance loop assumes it is the only thing writing to this file,
|
||||||
|
and any edits you make could blow aware points scored.
|
||||||
|
|
||||||
|
No, I don't use nano.
|
||||||
|
None of us use nano.
|
||||||
|
|
||||||
|
|
||||||
|
Changing a team name
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
grep . /srv/moth/state/teams/* # Show all team IDs and names
|
||||||
|
echo 'exciting new team name' > /srv/moth/state/teams/$teamid
|
||||||
|
|
||||||
|
Please remember, you have to replace `$teamid` with the actual team ID that you want to edit.
|
||||||
|
|
||||||
|
|
||||||
|
Dealing with puzzles
|
||||||
|
===========
|
||||||
|
|
||||||
|
Checking on an answer
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
Mothballs are just zip files.
|
||||||
|
If you need to check something about a running category,
|
||||||
|
just unzip the mothball for that category.
|
||||||
|
|
||||||
|
mkdir /tmp/category
|
||||||
|
cd /tmp/category
|
||||||
|
unzip /srv/moth/mothballs/category.zip
|
||||||
|
cat answers.txt # Show all valid answers for all puzzles. Watch your shoulder!
|
||||||
|
|
||||||
|
|
||||||
|
Installing new categories
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Just drop a new mothball in the `mothballs' directory.
|
||||||
|
|
||||||
|
cp new-category.mb /srv/moth/mothballs
|
||||||
|
|
||||||
|
|
||||||
|
Taking a category offline
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
rm /srv/moth/mothballs/old-category.mb
|
||||||
|
|
||||||
|
Removing a category won't remove points that have been scored in it!
|
|
@ -1,63 +0,0 @@
|
||||||
Using the MOTH Development Server
|
|
||||||
======================
|
|
||||||
|
|
||||||
To make puzzle development easier,
|
|
||||||
MOTH comes with a standalone web server written in Python,
|
|
||||||
which will show you how your puzzles are going to look without making you compile or package anything.
|
|
||||||
|
|
||||||
It even works in Windows,
|
|
||||||
because that is what my career has become.
|
|
||||||
|
|
||||||
|
|
||||||
Getting It Going
|
|
||||||
----------------
|
|
||||||
|
|
||||||
### With Docker
|
|
||||||
|
|
||||||
If you can use docker, you are in luck:
|
|
||||||
|
|
||||||
docker run --rm -t -p 8080:8080 dirtbags/moth-devel
|
|
||||||
|
|
||||||
Gets you a development puzzle server running on port 8080,
|
|
||||||
with the sample puzzle directory set up.
|
|
||||||
|
|
||||||
|
|
||||||
### Without Docker
|
|
||||||
|
|
||||||
If you can't use docker,
|
|
||||||
try this:
|
|
||||||
|
|
||||||
apt install python3
|
|
||||||
pip3 install scapy pillow PyYAML
|
|
||||||
git clone https://github.com/dirtbags/moth/
|
|
||||||
cd moth
|
|
||||||
python3 devel/devel-server.py --puzzles example-puzzles
|
|
||||||
|
|
||||||
|
|
||||||
Installing New Puzzles
|
|
||||||
-----------------------------
|
|
||||||
|
|
||||||
The development server wants to see category directories under `puzzles`,
|
|
||||||
like this:
|
|
||||||
|
|
||||||
$ find puzzles -type d
|
|
||||||
puzzles/
|
|
||||||
puzzles/category1/
|
|
||||||
puzzles/category1/10/
|
|
||||||
puzzles/category1/20/
|
|
||||||
puzzles/category1/30/
|
|
||||||
puzzles/category2/
|
|
||||||
puzzles/category2/100/
|
|
||||||
puzzles/category2/200/
|
|
||||||
puzzles/category2/300/
|
|
||||||
|
|
||||||
|
|
||||||
### With Docker
|
|
||||||
|
|
||||||
docker run --rm -t -v /path/to/my/puzzles:/puzzles:ro -p 8080:8080 dirtbags/moth-devel
|
|
||||||
|
|
||||||
|
|
||||||
### Without Docker
|
|
||||||
|
|
||||||
You can use the `--puzzles` argument to `devel-server.py`
|
|
||||||
to specify a path to your puzzles directory.
|
|
|
@ -0,0 +1,105 @@
|
||||||
|
Developing Content
|
||||||
|
============================
|
||||||
|
|
||||||
|
The development server shows debugging for each puzzle,
|
||||||
|
and will compile puzzles on the fly.
|
||||||
|
|
||||||
|
Use it along with a text editor and shell to create new puzzles and categories.
|
||||||
|
|
||||||
|
|
||||||
|
Set up some example puzzles
|
||||||
|
---------
|
||||||
|
|
||||||
|
If you don't have puzzles of your own to start with,
|
||||||
|
you can copy the example puzzles that come with the source:
|
||||||
|
|
||||||
|
cp -r /path/to/src/moth/example-puzzles /srv/moth/puzzles
|
||||||
|
|
||||||
|
|
||||||
|
Run the server in development mode
|
||||||
|
---------------
|
||||||
|
|
||||||
|
These recipes run the server in the foreground,
|
||||||
|
so you can watch the access log and any error messages.
|
||||||
|
|
||||||
|
|
||||||
|
### Podman
|
||||||
|
|
||||||
|
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth -puzzles /puzzles
|
||||||
|
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
docker run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth -puzzles /puzzles
|
||||||
|
|
||||||
|
### Native
|
||||||
|
|
||||||
|
I assume you've built and installed the `moth` command from the source tree.
|
||||||
|
|
||||||
|
If you don't know how to build Go packages,
|
||||||
|
please consider using Podman or Docker.
|
||||||
|
Building Go software is not a skill related to running MOTH or puzzle events,
|
||||||
|
unless you plan on hacking on the source code.
|
||||||
|
|
||||||
|
mkdir -p /srv/moth/state
|
||||||
|
cp -r /path/to/src/moth/theme /srv/moth/theme
|
||||||
|
cd /srv/moth
|
||||||
|
moth -puzzles puzzles
|
||||||
|
|
||||||
|
|
||||||
|
Log In
|
||||||
|
-----
|
||||||
|
|
||||||
|
Point a browser to http://localhost:8080/ (or whatever host is running the server).
|
||||||
|
You will be logged in automatically.
|
||||||
|
|
||||||
|
|
||||||
|
Browse the example puzzles
|
||||||
|
------------
|
||||||
|
|
||||||
|
|
||||||
|
The example puzzles are written to demonstrate various features of MOTH,
|
||||||
|
and serve as documentation of the puzzle format.
|
||||||
|
|
||||||
|
|
||||||
|
Make your own puzzle category
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
cp -r /srv/moth/puzzles/example /srv/moth/puzzles/my-category
|
||||||
|
|
||||||
|
|
||||||
|
Edit the one point puzzle
|
||||||
|
--------
|
||||||
|
|
||||||
|
nano /srv/moth/puzzles/my-category/1/puzzle.md
|
||||||
|
|
||||||
|
I don't use nano, personally,
|
||||||
|
but if you're advanced enough to have an opinion about nano,
|
||||||
|
you're advanced enough to know how to use a different editor.
|
||||||
|
|
||||||
|
|
||||||
|
Read our advice
|
||||||
|
---------------
|
||||||
|
|
||||||
|
The [Writing Puzzles](writing-puzzles.md) document
|
||||||
|
has some tips on how we approach puzzle writing.
|
||||||
|
There may be something in here that will help you out!
|
||||||
|
|
||||||
|
|
||||||
|
Stop the server
|
||||||
|
-------
|
||||||
|
|
||||||
|
You can hit Control-C in the terminal where you started the server,
|
||||||
|
and it will exit.
|
||||||
|
|
||||||
|
|
||||||
|
Mothballs
|
||||||
|
=======
|
||||||
|
|
||||||
|
In the list of puzzle categories and puzzles,
|
||||||
|
there will be a button to download a mothball.
|
||||||
|
|
||||||
|
Once your category is set up the way you like it,
|
||||||
|
download a mothball for it,
|
||||||
|
and you're ready to [get started](getting-started.md)
|
||||||
|
with the production server.
|
|
@ -0,0 +1,77 @@
|
||||||
|
Getting Started
|
||||||
|
===============
|
||||||
|
|
||||||
|
Compile Mothballs
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Mothballs are compiled, static-content versions of a puzzle category.
|
||||||
|
You need a mothball for every category you want to run.
|
||||||
|
|
||||||
|
To get some mothballs, you'll need to run a development server, which includes the category compiler.
|
||||||
|
See [development](development.md) for details.
|
||||||
|
|
||||||
|
|
||||||
|
Set up directories
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
mkdir -p /srv/moth/state
|
||||||
|
mkdir -p /srv/moth/mothballs
|
||||||
|
cp -r /path/to/src/moth/theme /srv/moth/theme # Skip if using Docker/Podman/Kubernetes
|
||||||
|
|
||||||
|
MOTH needs three directories. We recommend putting them all in `/srv/moth`.
|
||||||
|
|
||||||
|
* `/srv/moth/state`: (read-write) an empty directory for the server to record its state
|
||||||
|
* `/srv/moth/mothballs`: (read-only) drop your mothballs here
|
||||||
|
* `/srv/moth/theme`: (read-only) The HTML5 MOTH client: static content served to web browsers
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Run the server
|
||||||
|
----------------
|
||||||
|
|
||||||
|
We're going to assume you put everything in `/srv/moth`, like we suggested.
|
||||||
|
|
||||||
|
### Podman
|
||||||
|
|
||||||
|
podman run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
docker run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth
|
||||||
|
|
||||||
|
### Native
|
||||||
|
|
||||||
|
cd /srv/moth
|
||||||
|
moth
|
||||||
|
|
||||||
|
|
||||||
|
Copy in some mothballs
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
cp category1.mb category2.mb /srv/moth/mothballs
|
||||||
|
|
||||||
|
You can add and remove mothballs at any time while the server is running.
|
||||||
|
|
||||||
|
|
||||||
|
Get a list of valid team tokens
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
cat /srv/moth/state/tokens.txt
|
||||||
|
|
||||||
|
You can edit or replace this file if you want to use different tokens than the pre-generated ones.
|
||||||
|
|
||||||
|
|
||||||
|
Connect to the server
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Open http://localhost:8080/
|
||||||
|
|
||||||
|
Substitute the hostname appropriately if you're a fancypants with a cloud.
|
||||||
|
|
||||||
|
|
||||||
|
Yay!
|
||||||
|
-------
|
||||||
|
|
||||||
|
You should be all set now!
|
||||||
|
|
||||||
|
See [administration](administration.md) for how to keep your new MOTH server running the way you want.
|
|
@ -1,7 +1,10 @@
|
||||||
Philosophy
|
Philosophy
|
||||||
==========
|
==========
|
||||||
|
|
||||||
This is just some scattered thoughts by the architect, Neale.
|
Some scattered thoughts by the architect, Neale.
|
||||||
|
|
||||||
|
Hardening
|
||||||
|
-----------
|
||||||
|
|
||||||
People are going to try to break this thing.
|
People are going to try to break this thing.
|
||||||
It needs to be bulletproof.
|
It needs to be bulletproof.
|
||||||
|
@ -10,21 +13,23 @@ This pretty much set the entire design:
|
||||||
* As much as possible is done client-side
|
* As much as possible is done client-side
|
||||||
* Participants can attack their own web browsers as much as they feel like
|
* Participants can attack their own web browsers as much as they feel like
|
||||||
* Also reduces server load
|
* Also reduces server load
|
||||||
* We will help you create brute-force attacks!
|
* We even made a puzzle category to walk people through creating brute-force attacks!
|
||||||
* Your laptop is faster than our server
|
* Your laptop is faster than our server
|
||||||
* We give you the carrot of hashed answers and the hashing function
|
* We give you the carrot of hashed answers and the hashing function
|
||||||
* This removes one incentive to DoS the server
|
* This removes one incentive to DoS the server
|
||||||
* Generate static content whenever possible
|
* Generate static content whenever possible
|
||||||
* Puzzles are statically compiled before the event even starts
|
* Puzzles must be statically compiled before the event even starts
|
||||||
* `points.json` and `puzzles.json` are generated and cached by a maintenance loop
|
* As much content as possible is generated by a maintenance loop
|
||||||
* Minimize dynamic handling
|
* Minimize dynamic handling
|
||||||
* There are only two (2) dynamic handlers
|
* There are only three (3) dynamic handlers
|
||||||
* team registration
|
* team registration
|
||||||
* answer validation
|
* answer validation
|
||||||
|
* server state (open puzzles + event log)
|
||||||
* You can disable team registration if you want, just remove `teamids.txt`
|
* You can disable team registration if you want, just remove `teamids.txt`
|
||||||
* I even removed token handling once I realized we replicate the user experience with the `answer` handler and some client-side JavaScript
|
* I even removed token handling once I realized we replicate the user experience with the `answer` handler and some client-side JavaScript
|
||||||
* As much as possible is read-only
|
* As much as possible is read-only
|
||||||
* The only rw directory is `state`
|
* The only read-write directory is `state`
|
||||||
|
* This plays very well with Docker, which didn't exist when we designed MOTH
|
||||||
* Server code should be as tiny as possible
|
* Server code should be as tiny as possible
|
||||||
* Server should provide highly limited functionality
|
* Server should provide highly limited functionality
|
||||||
* It should be easy to remember in your head everything it does
|
* It should be easy to remember in your head everything it does
|
||||||
|
@ -35,3 +40,21 @@ This pretty much set the entire design:
|
||||||
* Want to provide a time bonus for quick answers? I don't, but if you do, you can just modify the scoreboard to do so.
|
* Want to provide a time bonus for quick answers? I don't, but if you do, you can just modify the scoreboard to do so.
|
||||||
* Maybe you want to show a graph of team rankings over time: just replay the event log.
|
* Maybe you want to show a graph of team rankings over time: just replay the event log.
|
||||||
* Want to do some analysis of what puzzles take the longest to answer? It's all there.
|
* Want to do some analysis of what puzzles take the longest to answer? It's all there.
|
||||||
|
|
||||||
|
Fairness
|
||||||
|
---------
|
||||||
|
|
||||||
|
We spend a lot of time thinking about whether new content is going to feel fair.
|
||||||
|
Or, more importantly, if there's a possibility for it to be viewed as unfair.
|
||||||
|
|
||||||
|
It's possible to run fun events that don't focus so much on fairness,
|
||||||
|
but those aren't the type of events we run.
|
||||||
|
|
||||||
|
* People generally don't mind discovering that they could improve
|
||||||
|
* People can get furious if they feel like some system is unfairly targeting them
|
||||||
|
* Every team that does the same amount of work should have the same score
|
||||||
|
* No time bonuses / decaying points
|
||||||
|
* No penalties for trying things that don't work out
|
||||||
|
* No one should ever feel like it's impossible to catch up
|
||||||
|
* Achievements ("cheevos") work well here
|
||||||
|
* Time-based awards (flags) don't mesh with this idea
|
||||||
|
|
|
@ -1,12 +1,42 @@
|
||||||
Tokens
|
Tokens
|
||||||
======
|
======
|
||||||
|
|
||||||
Tokens are good for a single point in a single category. They are
|
We used to use tokens extensively for categories outside of MOTH
|
||||||
formed by prepending the category and a colon to the bubblebabble digest
|
(like scavenger hunts, Dirtbags Tanks, and other standalone stuff).
|
||||||
of 3 random octets. A token for the "merfing" category might look like
|
|
||||||
this:
|
|
||||||
|
|
||||||
merfing:xunap-motex
|
We still occasionally pull out tokens to deal with oddball categories
|
||||||
|
that we want to score alongside MOTH categories.
|
||||||
|
|
||||||
|
Here's how they work.
|
||||||
|
|
||||||
|
Description
|
||||||
|
------------
|
||||||
|
|
||||||
|
Tokens are a 3-tuple:
|
||||||
|
|
||||||
|
> (category, points, nonce)
|
||||||
|
|
||||||
|
We build a mothball with nothing but `answers.txt`,
|
||||||
|
and a special 1-point puzzle that uses JavaScript to parse and submit tokens.
|
||||||
|
|
||||||
|
Generally, tokens use colon separators, so they look like this:
|
||||||
|
|
||||||
|
category:12:xunap-motex
|
||||||
|
|
||||||
|
Uniqueness
|
||||||
|
--------
|
||||||
|
|
||||||
|
Because they work just like normal categories,
|
||||||
|
you can't have two distinct tokens worth the same number of points.
|
||||||
|
|
||||||
|
When we need two or more tokens worth the same amount,
|
||||||
|
we make the point values very high,
|
||||||
|
so the least significant digit doesn't have much impact on the overall value.
|
||||||
|
For instance:
|
||||||
|
|
||||||
|
category:1000001:xylep-nanox
|
||||||
|
category:1000002:xenod-relix
|
||||||
|
category:1000003:xoter-darox
|
||||||
|
|
||||||
|
|
||||||
Entropy
|
Entropy
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,287 +0,0 @@
|
||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import cgitb
|
|
||||||
import html
|
|
||||||
import cgi
|
|
||||||
import http.server
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import mimetypes
|
|
||||||
import moth
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import random
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
import traceback
|
|
||||||
import mothballer
|
|
||||||
import parse
|
|
||||||
import urllib.parse
|
|
||||||
import posixpath
|
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
|
|
||||||
sys.dont_write_bytecode = True # Don't write .pyc files
|
|
||||||
|
|
||||||
try:
|
|
||||||
ThreadingHTTPServer = http.server.ThreadingHTTPServer
|
|
||||||
except AttributeError:
|
|
||||||
import socketserver
|
|
||||||
class ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
||||||
daemon_threads = True
|
|
||||||
|
|
||||||
class MothServer(ThreadingHTTPServer):
|
|
||||||
def __init__(self, server_address, RequestHandlerClass):
|
|
||||||
super().__init__(server_address, RequestHandlerClass)
|
|
||||||
self.args = {}
|
|
||||||
|
|
||||||
|
|
||||||
class MothRequestHandler(http.server.SimpleHTTPRequestHandler):
|
|
||||||
endpoints = []
|
|
||||||
|
|
||||||
def __init__(self, request, client_address, server):
|
|
||||||
self.directory = str(server.args["theme_dir"])
|
|
||||||
try:
|
|
||||||
super().__init__(request, client_address, server, directory=server.args["theme_dir"])
|
|
||||||
except TypeError:
|
|
||||||
super().__init__(request, client_address, server)
|
|
||||||
|
|
||||||
|
|
||||||
# Backport from Python 3.7
|
|
||||||
def translate_path(self, path):
|
|
||||||
# I guess we just hope that some other thread doesn't call getcwd
|
|
||||||
getcwd = os.getcwd
|
|
||||||
os.getcwd = lambda: self.directory
|
|
||||||
ret = super().translate_path(path)
|
|
||||||
os.getcwd = getcwd
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def get_puzzle(self):
|
|
||||||
category = self.req.get("cat")
|
|
||||||
points = int(self.req.get("points"))
|
|
||||||
catpath = str(self.server.args["puzzles_dir"].joinpath(category))
|
|
||||||
cat = moth.Category(catpath, self.seed)
|
|
||||||
puzzle = cat.puzzle(points)
|
|
||||||
return puzzle
|
|
||||||
|
|
||||||
|
|
||||||
def handle_answer(self):
|
|
||||||
for f in ("cat", "points", "answer"):
|
|
||||||
self.req[f] = self.fields.getfirst(f)
|
|
||||||
puzzle = self.get_puzzle()
|
|
||||||
ret = {
|
|
||||||
"status": "success",
|
|
||||||
"data": {
|
|
||||||
"short": "",
|
|
||||||
"description": "Provided answer was not in list of answers"
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.req.get("answer") in puzzle.answers:
|
|
||||||
ret["data"]["description"] = "Answer is correct"
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_puzzlelist(self):
|
|
||||||
puzzles = {
|
|
||||||
"__devel__": [[0, ""]],
|
|
||||||
}
|
|
||||||
for p in self.server.args["puzzles_dir"].glob("*"):
|
|
||||||
if not p.is_dir() or p.match(".*"):
|
|
||||||
continue
|
|
||||||
catName = p.parts[-1]
|
|
||||||
cat = moth.Category(str(p), self.seed)
|
|
||||||
puzzles[catName] = [[i, str(i)] for i in cat.pointvals()]
|
|
||||||
puzzles[catName].append([0, ""])
|
|
||||||
if len(puzzles) <= 1:
|
|
||||||
logging.warning("No directories found matching {}/*".format(self.server.args["puzzles_dir"]))
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps(puzzles).encode("utf-8"))
|
|
||||||
endpoints.append(('/{seed}/puzzles.json', handle_puzzlelist))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_puzzle(self):
|
|
||||||
puzzle = self.get_puzzle()
|
|
||||||
|
|
||||||
obj = puzzle.package()
|
|
||||||
obj["answers"] = puzzle.answers
|
|
||||||
obj["hint"] = puzzle.hint
|
|
||||||
obj["summary"] = puzzle.summary
|
|
||||||
obj["logs"] = puzzle.logs
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps(obj).encode("utf-8"))
|
|
||||||
endpoints.append(('/{seed}/content/{cat}/{points}/puzzle.json', handle_puzzle))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_puzzlefile(self):
|
|
||||||
puzzle = self.get_puzzle()
|
|
||||||
|
|
||||||
try:
|
|
||||||
file = puzzle.files[self.req["filename"]]
|
|
||||||
except KeyError:
|
|
||||||
self.send_error(
|
|
||||||
HTTPStatus.NOT_FOUND,
|
|
||||||
"File Not Found",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", mimetypes.guess_type(file.name))
|
|
||||||
self.end_headers()
|
|
||||||
shutil.copyfileobj(file.stream, self.wfile)
|
|
||||||
endpoints.append(("/{seed}/content/{cat}/{points}/{filename}", handle_puzzlefile))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_mothballer(self):
|
|
||||||
category = self.req.get("cat")
|
|
||||||
|
|
||||||
try:
|
|
||||||
catdir = self.server.args["puzzles_dir"].joinpath(category)
|
|
||||||
mb = mothballer.package(category, catdir, self.seed)
|
|
||||||
except Exception as ex:
|
|
||||||
logging.exception(ex)
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "text/html; charset=\"utf-8\"")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(cgitb.html(sys.exc_info()))
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "application/octet_stream")
|
|
||||||
self.end_headers()
|
|
||||||
shutil.copyfileobj(mb, self.wfile)
|
|
||||||
endpoints.append(("/{seed}/mothballer/{cat}.mb", handle_mothballer))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_index(self):
|
|
||||||
seed = random.getrandbits(32)
|
|
||||||
body = """<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Dev Server</title>
|
|
||||||
<script>
|
|
||||||
// Skip trying to log in
|
|
||||||
sessionStorage.setItem("id", "devel-server")
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Dev Server</h1>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Pick a seed:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li><a href="{seed}/">{seed}</a>: a special seed I made just for you!</li>
|
|
||||||
<li><a href="random/">random</a>: will use a different seed every time you load a page (could be useful for debugging)</li>
|
|
||||||
<li>You can also hack your own seed into the URL, if you want to.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Puzzles can be generated from Python code: these puzzles can use a random number generator if they want.
|
|
||||||
The seed is used to create these random numbers.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
We like to make a new seed for every contest,
|
|
||||||
and re-use that seed whenever we regenerate a category during an event
|
|
||||||
(say to fix a bug).
|
|
||||||
By using the same seed,
|
|
||||||
we make sure that all the dynamically-generated puzzles have the same values
|
|
||||||
in any new packages we build.
|
|
||||||
</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""".format(seed=seed)
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body.encode('utf-8'))
|
|
||||||
endpoints.append((r"/", handle_index))
|
|
||||||
endpoints.append((r"/{ignored}", handle_index))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_theme_file(self):
|
|
||||||
self.path = "/" + self.req.get("path", "")
|
|
||||||
super().do_GET()
|
|
||||||
endpoints.append(("/{seed}/", handle_theme_file))
|
|
||||||
endpoints.append(("/{seed}/{path}", handle_theme_file))
|
|
||||||
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
self.fields = cgi.FieldStorage(
|
|
||||||
fp=self.rfile,
|
|
||||||
headers=self.headers,
|
|
||||||
environ={
|
|
||||||
"REQUEST_METHOD": self.command,
|
|
||||||
"CONTENT_TYPE": self.headers["Content-Type"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
for pattern, function in self.endpoints:
|
|
||||||
result = parse.parse(pattern, self.path)
|
|
||||||
if result:
|
|
||||||
self.req = result.named
|
|
||||||
seed = self.req.get("seed", "random")
|
|
||||||
if seed == "random":
|
|
||||||
self.seed = random.getrandbits(32)
|
|
||||||
else:
|
|
||||||
self.seed = int(seed)
|
|
||||||
return function(self)
|
|
||||||
super().do_GET()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
self.do_GET()
|
|
||||||
|
|
||||||
def do_HEAD(self):
|
|
||||||
self.send_error(
|
|
||||||
HTTPStatus.NOT_IMPLEMENTED,
|
|
||||||
"Unsupported method (%r)" % self.command,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="MOTH puzzle development server")
|
|
||||||
parser.add_argument(
|
|
||||||
'--puzzles', default='puzzles',
|
|
||||||
help="Directory containing your puzzles"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--theme', default='theme',
|
|
||||||
help="Directory containing theme files")
|
|
||||||
parser.add_argument(
|
|
||||||
'--bind', default="127.0.0.1:8080",
|
|
||||||
help="Bind to ip:port"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--base', default="",
|
|
||||||
help="Base URL to this server, for reverse proxy setup"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
parts = args.bind.split(":")
|
|
||||||
addr = parts[0] or "0.0.0.0"
|
|
||||||
port = int(parts[1])
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
|
|
||||||
server = MothServer((addr, port), MothRequestHandler)
|
|
||||||
server.args["base_url"] = args.base
|
|
||||||
server.args["puzzles_dir"] = pathlib.Path(args.puzzles)
|
|
||||||
server.args["theme_dir"] = args.theme
|
|
||||||
|
|
||||||
logging.info("Listening on %s:%d", addr, port)
|
|
||||||
server.serve_forever()
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,368 +0,0 @@
|
||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import contextlib
|
|
||||||
import glob
|
|
||||||
import hashlib
|
|
||||||
import html
|
|
||||||
import io
|
|
||||||
import importlib.machinery
|
|
||||||
import mistune
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import tempfile
|
|
||||||
import shlex
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
||||||
|
|
||||||
def djb2hash(str):
|
|
||||||
h = 5381
|
|
||||||
for c in str.encode("utf-8"):
|
|
||||||
h = ((h * 33) + c) & 0xffffffff
|
|
||||||
return h
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def pushd(newdir):
|
|
||||||
curdir = os.getcwd()
|
|
||||||
os.chdir(newdir)
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
os.chdir(curdir)
|
|
||||||
|
|
||||||
|
|
||||||
def loadmod(name, path):
|
|
||||||
abspath = os.path.abspath(path)
|
|
||||||
loader = importlib.machinery.SourceFileLoader(name, abspath)
|
|
||||||
return loader.load_module()
|
|
||||||
|
|
||||||
|
|
||||||
# Get a big list of clean words for our answer file.
|
|
||||||
ANSWER_WORDS = [w.strip() for w in open(os.path.join(os.path.dirname(__file__),
|
|
||||||
'answer_words.txt'))]
|
|
||||||
|
|
||||||
class PuzzleFile:
|
|
||||||
"""A file associated with a puzzle.
|
|
||||||
|
|
||||||
path: The path to the original input file. May be None (when this is created from a file handle
|
|
||||||
and there is no original input.
|
|
||||||
handle: A File-like object set to read the file from. You should be able to read straight
|
|
||||||
from it without having to seek to the beginning of the file.
|
|
||||||
name: The name of the output file.
|
|
||||||
visible: A boolean indicating whether this file should visible to the user. If False,
|
|
||||||
the file is still expected to be accessible, but it's path must be known
|
|
||||||
(or figured out) to retrieve it."""
|
|
||||||
|
|
||||||
def __init__(self, stream, name, visible=True):
|
|
||||||
self.stream = stream
|
|
||||||
self.name = name
|
|
||||||
self.visible = visible
|
|
||||||
|
|
||||||
|
|
||||||
class Puzzle:
|
|
||||||
def __init__(self, category_seed, points):
|
|
||||||
"""A MOTH Puzzle.
|
|
||||||
|
|
||||||
:param category_seed: A byte string to use as a seed for random numbers for this puzzle.
|
|
||||||
It is combined with the puzzle points.
|
|
||||||
:param points: The point value of the puzzle.
|
|
||||||
"""
|
|
||||||
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
self.points = points
|
|
||||||
self.summary = None
|
|
||||||
self.authors = []
|
|
||||||
self.answers = []
|
|
||||||
self.scripts = []
|
|
||||||
self.pattern = None
|
|
||||||
self.hint = None
|
|
||||||
self.files = {}
|
|
||||||
self.body = io.StringIO()
|
|
||||||
self.logs = []
|
|
||||||
self.randseed = category_seed * self.points
|
|
||||||
self.rand = random.Random(self.randseed)
|
|
||||||
|
|
||||||
def log(self, *vals):
|
|
||||||
"""Add a new log message to this puzzle."""
|
|
||||||
msg = ' '.join(str(v) for v in vals)
|
|
||||||
self.logs.append(msg)
|
|
||||||
|
|
||||||
def read_stream(self, stream):
|
|
||||||
header = True
|
|
||||||
line = ""
|
|
||||||
if stream.read(3) == "---":
|
|
||||||
header = "yaml"
|
|
||||||
else:
|
|
||||||
header = "moth"
|
|
||||||
|
|
||||||
stream.seek(0)
|
|
||||||
|
|
||||||
if header == "yaml":
|
|
||||||
self.read_yaml_header(stream)
|
|
||||||
elif header == "moth":
|
|
||||||
self.read_moth_header(stream)
|
|
||||||
|
|
||||||
for line in stream:
|
|
||||||
self.body.write(line)
|
|
||||||
|
|
||||||
def read_yaml_header(self, stream):
|
|
||||||
contents = ""
|
|
||||||
header = False
|
|
||||||
for line in stream:
|
|
||||||
if line.strip() == "---" and header: # Handle last line
|
|
||||||
break
|
|
||||||
elif line.strip() == "---": # Handle first line
|
|
||||||
header = True
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
contents += line
|
|
||||||
|
|
||||||
config = yaml.safe_load(contents)
|
|
||||||
for key, value in config.items():
|
|
||||||
key = key.lower()
|
|
||||||
self.handle_header_key(key, value)
|
|
||||||
|
|
||||||
|
|
||||||
def read_moth_header(self, stream):
|
|
||||||
for line in stream:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
break
|
|
||||||
|
|
||||||
key, val = line.split(':', 1)
|
|
||||||
key = key.lower()
|
|
||||||
val = val.strip()
|
|
||||||
self.handle_header_key(key, val)
|
|
||||||
|
|
||||||
def handle_header_key(self, key, val):
|
|
||||||
if key == 'author':
|
|
||||||
self.authors.append(val)
|
|
||||||
elif key == 'summary':
|
|
||||||
self.summary = val
|
|
||||||
elif key == 'answer':
|
|
||||||
if not isinstance(val, str):
|
|
||||||
raise ValueError("Answers must be strings, got %s, instead" % (type(val),))
|
|
||||||
self.answers.append(val)
|
|
||||||
elif key == "answers":
|
|
||||||
for answer in val:
|
|
||||||
if not isinstance(answer, str):
|
|
||||||
raise ValueError("Answers must be strings, got %s, instead" % (type(answer),))
|
|
||||||
self.answers.append(answer)
|
|
||||||
elif key == 'pattern':
|
|
||||||
self.pattern = val
|
|
||||||
elif key == 'hint':
|
|
||||||
self.hint = val
|
|
||||||
elif key == 'name':
|
|
||||||
pass
|
|
||||||
elif key == 'file':
|
|
||||||
parts = shlex.split(val)
|
|
||||||
name = parts[0]
|
|
||||||
hidden = False
|
|
||||||
stream = open(name, 'rb')
|
|
||||||
try:
|
|
||||||
name = parts[1]
|
|
||||||
hidden = (parts[2].lower() == "hidden")
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
self.files[name] = PuzzleFile(stream, name, not hidden)
|
|
||||||
elif key == 'script':
|
|
||||||
stream = open(val, 'rb')
|
|
||||||
# Make sure this shows up in the header block of the HTML output.
|
|
||||||
self.files[val] = PuzzleFile(stream, val, visible=False)
|
|
||||||
self.scripts.append(val)
|
|
||||||
else:
|
|
||||||
raise ValueError("Unrecognized header field: {}".format(key))
|
|
||||||
|
|
||||||
|
|
||||||
def read_directory(self, path):
|
|
||||||
try:
|
|
||||||
puzzle_mod = loadmod("puzzle", os.path.join(path, "puzzle.py"))
|
|
||||||
except FileNotFoundError:
|
|
||||||
puzzle_mod = None
|
|
||||||
|
|
||||||
with pushd(path):
|
|
||||||
if puzzle_mod:
|
|
||||||
puzzle_mod.make(self)
|
|
||||||
else:
|
|
||||||
with open('puzzle.moth') as f:
|
|
||||||
self.read_stream(f)
|
|
||||||
|
|
||||||
def random_hash(self):
|
|
||||||
"""Create a file basename (no extension) with our number generator."""
|
|
||||||
return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8))
|
|
||||||
|
|
||||||
def make_temp_file(self, name=None, visible=True):
|
|
||||||
"""Get a file object for adding dynamically generated data to the puzzle. When you're
|
|
||||||
done with this file, flush it, but don't close it.
|
|
||||||
|
|
||||||
:param name: The name of the file for links within the puzzle. If this is None, a name
|
|
||||||
will be generated for you.
|
|
||||||
:param visible: Whether or not the file will be visible to the user.
|
|
||||||
:return: A file object for writing
|
|
||||||
"""
|
|
||||||
|
|
||||||
stream = tempfile.TemporaryFile()
|
|
||||||
self.add_stream(stream, name, visible)
|
|
||||||
return stream
|
|
||||||
|
|
||||||
def add_stream(self, stream, name=None, visible=True):
|
|
||||||
if name is None:
|
|
||||||
name = self.random_hash()
|
|
||||||
self.files[name] = PuzzleFile(stream, name, visible)
|
|
||||||
|
|
||||||
def add_file(self, filename, visible=True):
|
|
||||||
fd = open(filename, 'rb')
|
|
||||||
name = os.path.basename(filename)
|
|
||||||
self.add_stream(fd, name=name, visible=visible)
|
|
||||||
|
|
||||||
def randword(self):
|
|
||||||
"""Return a randomly-chosen word"""
|
|
||||||
|
|
||||||
return self.rand.choice(ANSWER_WORDS)
|
|
||||||
|
|
||||||
def make_answer(self, word_count=4, sep=' '):
|
|
||||||
"""Generate and return a new answer. It's automatically added to the puzzle answer list.
|
|
||||||
:param int word_count: The number of words to include in the answer.
|
|
||||||
:param str|bytes sep: The word separator.
|
|
||||||
:returns: The answer string
|
|
||||||
"""
|
|
||||||
|
|
||||||
words = [self.randword() for i in range(word_count)]
|
|
||||||
answer = sep.join(words)
|
|
||||||
self.answers.append(answer)
|
|
||||||
return answer
|
|
||||||
|
|
||||||
hexdump_stdch = stdch = (
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
' !"#$%&\'()*+,-./'
|
|
||||||
'0123456789:;<=>?'
|
|
||||||
'@ABCDEFGHIJKLMNO'
|
|
||||||
'PQRSTUVWXYZ[\]^_'
|
|
||||||
'`abcdefghijklmno'
|
|
||||||
'pqrstuvwxyz{|}~·'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
'················'
|
|
||||||
)
|
|
||||||
|
|
||||||
def hexdump(self, buf, charset=hexdump_stdch, gap=('<EFBFBD>', '⌷')):
|
|
||||||
hexes, chars = [], []
|
|
||||||
out = []
|
|
||||||
|
|
||||||
for b in buf:
|
|
||||||
if len(chars) == 16:
|
|
||||||
out.append((hexes, chars))
|
|
||||||
hexes, chars = [], []
|
|
||||||
|
|
||||||
if b is None:
|
|
||||||
h, c = gap
|
|
||||||
else:
|
|
||||||
h = '{:02x}'.format(b)
|
|
||||||
c = charset[b]
|
|
||||||
chars.append(c)
|
|
||||||
hexes.append(h)
|
|
||||||
|
|
||||||
out.append((hexes, chars))
|
|
||||||
|
|
||||||
offset = 0
|
|
||||||
elided = False
|
|
||||||
lastchars = None
|
|
||||||
self.body.write('<pre>')
|
|
||||||
for hexes, chars in out:
|
|
||||||
if chars == lastchars:
|
|
||||||
offset += len(chars)
|
|
||||||
if not elided:
|
|
||||||
self.body.write('*\n')
|
|
||||||
elided = True
|
|
||||||
continue
|
|
||||||
lastchars = chars[:]
|
|
||||||
elided = False
|
|
||||||
|
|
||||||
pad = 16 - len(chars)
|
|
||||||
hexes += [' '] * pad
|
|
||||||
|
|
||||||
self.body.write('{:08x} '.format(offset))
|
|
||||||
self.body.write(' '.join(hexes[:8]))
|
|
||||||
self.body.write(' ')
|
|
||||||
self.body.write(' '.join(hexes[8:]))
|
|
||||||
self.body.write(' |')
|
|
||||||
self.body.write(html.escape(''.join(chars)))
|
|
||||||
self.body.write('|\n')
|
|
||||||
offset += len(chars)
|
|
||||||
self.body.write('{:08x}\n'.format(offset))
|
|
||||||
self.body.write('</pre>')
|
|
||||||
|
|
||||||
def get_authors(self):
|
|
||||||
return self.authors or [self.author]
|
|
||||||
|
|
||||||
def get_body(self):
|
|
||||||
return self.body.getvalue()
|
|
||||||
|
|
||||||
def html_body(self):
|
|
||||||
"""Format and return the markdown for the puzzle body."""
|
|
||||||
return mistune.markdown(self.get_body(), escape=False)
|
|
||||||
|
|
||||||
def package(self, answers=False):
|
|
||||||
"""Return a dict packaging of the puzzle."""
|
|
||||||
|
|
||||||
files = [fn for fn,f in self.files.items() if f.visible]
|
|
||||||
return {
|
|
||||||
'authors': self.authors,
|
|
||||||
'hashes': self.hashes(),
|
|
||||||
'files': files,
|
|
||||||
'scripts': self.scripts,
|
|
||||||
'pattern': self.pattern,
|
|
||||||
'body': self.html_body(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def hashes(self):
|
|
||||||
"Return a list of answer hashes"
|
|
||||||
|
|
||||||
return [djb2hash(a) for a in self.answers]
|
|
||||||
|
|
||||||
|
|
||||||
class Category:
|
|
||||||
def __init__(self, path, seed):
|
|
||||||
self.path = path
|
|
||||||
self.seed = seed
|
|
||||||
self.catmod = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.catmod = loadmod('category', os.path.join(path, 'category.py'))
|
|
||||||
except FileNotFoundError:
|
|
||||||
self.catmod = None
|
|
||||||
|
|
||||||
def pointvals(self):
|
|
||||||
if self.catmod:
|
|
||||||
with pushd(self.path):
|
|
||||||
pointvals = self.catmod.pointvals()
|
|
||||||
else:
|
|
||||||
pointvals = []
|
|
||||||
for fpath in glob.glob(os.path.join(self.path, "[0-9]*")):
|
|
||||||
pn = os.path.basename(fpath)
|
|
||||||
points = int(pn)
|
|
||||||
pointvals.append(points)
|
|
||||||
return sorted(pointvals)
|
|
||||||
|
|
||||||
def puzzle(self, points):
|
|
||||||
puzzle = Puzzle(self.seed, points)
|
|
||||||
path = os.path.join(self.path, str(points))
|
|
||||||
if self.catmod:
|
|
||||||
with pushd(self.path):
|
|
||||||
self.catmod.make(points, puzzle)
|
|
||||||
else:
|
|
||||||
puzzle.read_directory(path)
|
|
||||||
return puzzle
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
for points in self.pointvals():
|
|
||||||
yield self.puzzle(points)
|
|
|
@ -1,115 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import binascii
|
|
||||||
import hashlib
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import moth
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import zipfile
|
|
||||||
import random
|
|
||||||
|
|
||||||
SEEDFN = "SEED"
|
|
||||||
|
|
||||||
|
|
||||||
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 isinstance(kv[key], list):
|
|
||||||
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('&', '&').replace('<', '<').replace('>', '>')
|
|
||||||
|
|
||||||
|
|
||||||
def build_category(categorydir, outdir):
|
|
||||||
category_seed = random.getrandbits(32)
|
|
||||||
|
|
||||||
categoryname = os.path.basename(categorydir.strip(os.sep))
|
|
||||||
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 = int(existing.open(SEEDFN).read().strip())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
existing.close()
|
|
||||||
logging.debug("Using PRNG seed {}".format(category_seed))
|
|
||||||
|
|
||||||
zipfileraw = tempfile.NamedTemporaryFile(delete=False)
|
|
||||||
mothball = package(categoryname, categorydir, category_seed)
|
|
||||||
shutil.copyfileobj(mothball, zipfileraw)
|
|
||||||
zipfileraw.close()
|
|
||||||
shutil.move(zipfileraw.name, zipfilename)
|
|
||||||
|
|
||||||
|
|
||||||
# Returns a file-like object containing the contents of the new zip file
|
|
||||||
def package(categoryname, categorydir, seed):
|
|
||||||
zfraw = io.BytesIO()
|
|
||||||
zf = zipfile.ZipFile(zfraw, 'x')
|
|
||||||
zf.writestr("category_seed.txt", str(seed))
|
|
||||||
|
|
||||||
cat = moth.Category(categorydir, seed)
|
|
||||||
mapping = {}
|
|
||||||
answers = {}
|
|
||||||
summary = {}
|
|
||||||
for puzzle in cat:
|
|
||||||
logging.info("Processing point value {}".format(puzzle.points))
|
|
||||||
|
|
||||||
hashmap = hashlib.sha1(str(seed).encode('utf-8'))
|
|
||||||
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)
|
|
||||||
for fn, f in puzzle.files.items():
|
|
||||||
payload = f.stream.read()
|
|
||||||
zf.writestr(os.path.join(puzzledir, fn), payload)
|
|
||||||
|
|
||||||
obj = puzzle.package()
|
|
||||||
zf.writestr(os.path.join(puzzledir, 'puzzle.json'), json.dumps(obj))
|
|
||||||
|
|
||||||
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()
|
|
||||||
zfraw.seek(0)
|
|
||||||
return zfraw
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
outdir = os.path.abspath(args.outdir)
|
|
||||||
for categorydir in args.categorydirs:
|
|
||||||
categorydir = os.path.abspath(categorydir)
|
|
||||||
build_category(categorydir, outdir)
|
|
|
@ -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('&', '&').replace('<', '<').replace('>', '>')
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
1335
lib/python/parse.py
1335
lib/python/parse.py
File diff suppressed because it is too large
Load Diff
|
@ -1,8 +0,0 @@
|
||||||
[flake8]
|
|
||||||
# flake8 is an automated code formatting pedant.
|
|
||||||
# Use it, please.
|
|
||||||
#
|
|
||||||
# python3 -m flake8 .
|
|
||||||
#
|
|
||||||
ignore = E501
|
|
||||||
exclude = .git
|
|
|
@ -1,19 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
set +e
|
|
||||||
|
|
||||||
url='https://rawgit.com/first20hours/google-10000-english/master/google-10000-english-no-swears.txt'
|
|
||||||
getter="curl -sL"
|
|
||||||
fn="answer_words.txt"
|
|
||||||
|
|
||||||
filterer() {
|
|
||||||
grep '......*'
|
|
||||||
}
|
|
||||||
|
|
||||||
if ! curl -h >/dev/null 2>/dev/null; then
|
|
||||||
getter="wget -q -O -"
|
|
||||||
elif ! wget -h >/dev/null 2>/dev/null; then
|
|
||||||
echo "[!] I don't know how to download. I need curl or wget."
|
|
||||||
fi
|
|
||||||
|
|
||||||
$getter "${url}" | filterer > ${fn}.tmp \
|
|
||||||
&& mv -f ${fn}.tmp ${fn}
|
|
|
@ -1,206 +0,0 @@
|
||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
"""A validator for MOTH puzzles"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import os.path
|
|
||||||
|
|
||||||
import moth
|
|
||||||
|
|
||||||
# pylint: disable=len-as-condition, line-too-long
|
|
||||||
|
|
||||||
DEFAULT_REQUIRED_FIELDS = ["answers", "authors", "summary"]
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MothValidationError(Exception):
|
|
||||||
|
|
||||||
"""An exception for encapsulating MOTH puzzle validation errors"""
|
|
||||||
|
|
||||||
|
|
||||||
class MothValidator:
|
|
||||||
|
|
||||||
"""A class which validates MOTH categories"""
|
|
||||||
|
|
||||||
def __init__(self, fields):
|
|
||||||
self.required_fields = fields
|
|
||||||
self.results = {"category": {}, "checks": []}
|
|
||||||
|
|
||||||
def validate(self, categorydir, only_errors=False):
|
|
||||||
"""Run validation checks against a category"""
|
|
||||||
LOGGER.debug("Loading category from %s", categorydir)
|
|
||||||
try:
|
|
||||||
category = moth.Category(categorydir, 0)
|
|
||||||
except NotADirectoryError:
|
|
||||||
return
|
|
||||||
|
|
||||||
LOGGER.debug("Found %d puzzles in %s", len(category.pointvals()), categorydir)
|
|
||||||
|
|
||||||
self.results["category"][categorydir] = {
|
|
||||||
"puzzles": {},
|
|
||||||
"name": os.path.basename(categorydir.strip(os.sep)),
|
|
||||||
}
|
|
||||||
curr_category = self.results["category"][categorydir]
|
|
||||||
|
|
||||||
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
|
|
||||||
if check_function_name not in self.results["checks"]:
|
|
||||||
self.results["checks"].append(check_function_name)
|
|
||||||
|
|
||||||
for puzzle in category:
|
|
||||||
LOGGER.info("Processing %s: %s", categorydir, puzzle.points)
|
|
||||||
|
|
||||||
curr_category["puzzles"][puzzle.points] = {}
|
|
||||||
curr_puzzle = curr_category["puzzles"][puzzle.points]
|
|
||||||
curr_puzzle["failures"] = []
|
|
||||||
|
|
||||||
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
|
|
||||||
check_function = getattr(self, check_function_name)
|
|
||||||
LOGGER.debug("Running %s on %d", check_function_name, puzzle.points)
|
|
||||||
|
|
||||||
try:
|
|
||||||
check_function(puzzle)
|
|
||||||
except MothValidationError as ex:
|
|
||||||
curr_puzzle["failures"].append(str(ex))
|
|
||||||
|
|
||||||
if only_errors and len(curr_puzzle["failures"]) == 0:
|
|
||||||
del curr_category["puzzles"][puzzle.points]
|
|
||||||
|
|
||||||
def check_fields(self, puzzle):
|
|
||||||
"""Check if the puzzle has the requested fields"""
|
|
||||||
for field in self.required_fields:
|
|
||||||
if not hasattr(puzzle, field):
|
|
||||||
raise MothValidationError("Missing field %s" % (field,))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_has_answers(puzzle):
|
|
||||||
"""Check if the puzle has answers defined"""
|
|
||||||
if len(puzzle.answers) == 0:
|
|
||||||
raise MothValidationError("No answers provided")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_unique_answers(puzzle):
|
|
||||||
"""Check if puzzle answers are unique"""
|
|
||||||
known_answers = []
|
|
||||||
duplicate_answers = []
|
|
||||||
|
|
||||||
for answer in puzzle.answers:
|
|
||||||
if answer not in known_answers:
|
|
||||||
known_answers.append(answer)
|
|
||||||
else:
|
|
||||||
duplicate_answers.append(answer)
|
|
||||||
|
|
||||||
if len(duplicate_answers) > 0:
|
|
||||||
raise MothValidationError("Duplicate answer(s) %s" % ", ".join(duplicate_answers))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_has_authors(puzzle):
|
|
||||||
"""Check if the puzzle has authors defined"""
|
|
||||||
if len(puzzle.authors) == 0:
|
|
||||||
raise MothValidationError("No authors provided")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_unique_authors(puzzle):
|
|
||||||
"""Check if puzzle authors are unique"""
|
|
||||||
known_authors = []
|
|
||||||
duplicate_authors = []
|
|
||||||
|
|
||||||
for author in puzzle.authors:
|
|
||||||
if author not in known_authors:
|
|
||||||
known_authors.append(author)
|
|
||||||
else:
|
|
||||||
duplicate_authors.append(author)
|
|
||||||
|
|
||||||
if len(duplicate_authors) > 0:
|
|
||||||
raise MothValidationError("Duplicate author(s) %s" % ", ".join(duplicate_authors))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_has_summary(puzzle):
|
|
||||||
"""Check if the puzzle has a summary"""
|
|
||||||
if puzzle.summary is None:
|
|
||||||
raise MothValidationError("Summary has not been provided")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_has_body(puzzle):
|
|
||||||
"""Check if the puzzle has a body defined"""
|
|
||||||
old_pos = puzzle.body.tell()
|
|
||||||
puzzle.body.seek(0)
|
|
||||||
if len(puzzle.body.read()) == 0:
|
|
||||||
puzzle.body.seek(old_pos)
|
|
||||||
raise MothValidationError("No body provided")
|
|
||||||
|
|
||||||
puzzle.body.seek(old_pos)
|
|
||||||
|
|
||||||
# Leaving this as a placeholder until KSAs are formally supported
|
|
||||||
@staticmethod
|
|
||||||
def check_ksa_format(puzzle):
|
|
||||||
"""Check if KSAs are properly formatted"""
|
|
||||||
if hasattr(puzzle, "ksa"):
|
|
||||||
for ksa in puzzle.ksa:
|
|
||||||
if not ksa.startswith("K"):
|
|
||||||
raise MothValidationError("Unrecognized KSA format")
|
|
||||||
|
|
||||||
|
|
||||||
def output_json(data):
|
|
||||||
"""Output results in JSON format"""
|
|
||||||
import json
|
|
||||||
print(json.dumps(data))
|
|
||||||
|
|
||||||
|
|
||||||
def output_text(data):
|
|
||||||
"""Output results in a text-based tabular format"""
|
|
||||||
|
|
||||||
longest_category = max([len(y["name"]) for x, y in data["category"].items()])
|
|
||||||
longest_category = max([longest_category, len("Category")])
|
|
||||||
longest_failure = len("Failures")
|
|
||||||
for category_data in data["category"].values():
|
|
||||||
for points, puzzle_data in category_data["puzzles"].items():
|
|
||||||
longest_failure = max([longest_failure, len(", ".join(puzzle_data["failures"]))])
|
|
||||||
|
|
||||||
formatstr = "| %%%ds | %%6s | %%%ds |" % (longest_category, longest_failure)
|
|
||||||
headerfmt = formatstr % ("Category", "Points", "Failures")
|
|
||||||
|
|
||||||
print(headerfmt)
|
|
||||||
for cat_data in data["category"].values():
|
|
||||||
for points, puzzle_data in sorted(cat_data["puzzles"].items()):
|
|
||||||
print(formatstr % (cat_data["name"], points, ", ".join([str(x) for x in puzzle_data["failures"]])))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main function"""
|
|
||||||
# pylint: disable=invalid-name
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
LOGGER.addHandler(logging.StreamHandler())
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Validate MOTH puzzle field compliance")
|
|
||||||
parser.add_argument("category", nargs="+", help="Categories to validate")
|
|
||||||
parser.add_argument("-f", "--fields", help="Comma-separated list of fields to check for", default=",".join(DEFAULT_REQUIRED_FIELDS))
|
|
||||||
|
|
||||||
parser.add_argument("-o", "--output-format", choices=["text", "json"], default="text", help="Output format (default: text)")
|
|
||||||
parser.add_argument("-e", "--only-errors", action="store_true", default=False, help="Only output errors")
|
|
||||||
parser.add_argument("-v", "--verbose", action="count", default=0, help="Increase verbosity of output, repeat to increase")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.verbose == 1:
|
|
||||||
LOGGER.setLevel("INFO")
|
|
||||||
elif args.verbose > 1:
|
|
||||||
LOGGER.setLevel("DEBUG")
|
|
||||||
|
|
||||||
LOGGER.debug(args)
|
|
||||||
validator = MothValidator(args.fields.split(","))
|
|
||||||
|
|
||||||
for category in args.category:
|
|
||||||
LOGGER.info("Validating %s", category)
|
|
||||||
validator.validate(category, only_errors=args.only_errors)
|
|
||||||
|
|
||||||
if args.output_format == "text":
|
|
||||||
output_text(validator.results)
|
|
||||||
elif args.output_format == "json":
|
|
||||||
output_json(validator.results)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
Loading…
Reference in New Issue