mirror of https://github.com/dirtbags/moth.git
Remove v3 code
This commit is contained in:
parent
ab0488ba14
commit
7b353131e4
|
@ -1,342 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// hasLine returns true if line appears in r.
|
|
||||||
// The entire line must match.
|
|
||||||
func hasLine(r io.Reader, line string) bool {
|
|
||||||
scanner := bufio.NewScanner(r)
|
|
||||||
for scanner.Scan() {
|
|
||||||
if scanner.Text() == line {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
|
|
||||||
teamName := req.FormValue("name")
|
|
||||||
teamId := req.FormValue("id")
|
|
||||||
|
|
||||||
if !ctx.ValidTeamId(teamId) {
|
|
||||||
respond(
|
|
||||||
w, req, JSendFail,
|
|
||||||
"Invalid Team ID",
|
|
||||||
"I don't have a record of that team ID. Maybe you used capital letters accidentally?",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.OpenFile(ctx.StatePath("teams", teamId), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsExist(err) {
|
|
||||||
respond(
|
|
||||||
w, req, JSendFail,
|
|
||||||
"Already registered",
|
|
||||||
"This team ID has already been registered.",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
log.Print(err)
|
|
||||||
respond(
|
|
||||||
w, req, JSendFail,
|
|
||||||
"Registration failed",
|
|
||||||
"Unable to register. Perhaps a teammate has already registered?",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
fmt.Fprintln(f, teamName)
|
|
||||||
respond(
|
|
||||||
w, req, JSendSuccess,
|
|
||||||
"Team registered",
|
|
||||||
"Your team has been named and you may begin using your team ID!",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
|
|
||||||
teamId := req.FormValue("id")
|
|
||||||
category := req.FormValue("cat")
|
|
||||||
pointstr := req.FormValue("points")
|
|
||||||
answer := req.FormValue("answer")
|
|
||||||
|
|
||||||
if !ctx.ValidTeamId(teamId) {
|
|
||||||
respond(
|
|
||||||
w, req, JSendFail,
|
|
||||||
"Invalid team ID",
|
|
||||||
"That team ID is not valid for this event.",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ctx.TooFast(teamId) {
|
|
||||||
respond(
|
|
||||||
w, req, JSendFail,
|
|
||||||
"Submitting too quickly",
|
|
||||||
"Your team can only submit one answer every %v", ctx.AttemptInterval,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
respond(
|
|
||||||
points, err := strconv.Atoi(pointstr)
|
|
||||||
w, req, JSendFail,
|
|
||||||
"Cannot parse point value",
|
|
||||||
"This doesn't look like an integer: %s", pointstr,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
haystack, err := ctx.OpenCategoryFile(category, "answers.txt")
|
|
||||||
if err != nil {
|
|
||||||
respond(
|
|
||||||
w, req, JSendFail,
|
|
||||||
"Cannot list answers",
|
|
||||||
"Unable to read the list of answers for this category.",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer haystack.Close()
|
|
||||||
|
|
||||||
// Look for the answer
|
|
||||||
needle := fmt.Sprintf("%d %s", points, answer)
|
|
||||||
if !hasLine(haystack, needle) {
|
|
||||||
respond(
|
|
||||||
w, req, JSendFail,
|
|
||||||
"Wrong answer",
|
|
||||||
"That is not the correct answer for %s %d.", category, points,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctx.AwardPoints(teamId, category, points); err != nil {
|
|
||||||
respond(
|
|
||||||
w, req, JSendError,
|
|
||||||
"Cannot award points",
|
|
||||||
"The answer is correct, but there was an error awarding points: %v", err.Error(),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respond(
|
|
||||||
w, req, JSendSuccess,
|
|
||||||
"Points awarded",
|
|
||||||
fmt.Sprintf("%d points for %s!", points, teamId),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) {
|
|
||||||
teamId := req.FormValue("id")
|
|
||||||
if _, err := ctx.TeamName(teamId); err != nil {
|
|
||||||
http.Error(w, "Must provide team ID", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write(ctx.jPuzzleList)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) pointsHandler(w http.ResponseWriter, req *http.Request) {
|
|
||||||
teamId, ok := req.URL.Query()["id"]
|
|
||||||
pointsLog := ctx.jPointsLog
|
|
||||||
if ok && len(teamId[0]) > 0 {
|
|
||||||
pointsLog = ctx.generatePointsLog(teamId[0])
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write(pointsLog)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) contentHandler(w http.ResponseWriter, req *http.Request) {
|
|
||||||
// Prevent directory traversal
|
|
||||||
if strings.Contains(req.URL.Path, "/.") {
|
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Be clever: use only the last three parts of the path. This may prove to be a bad idea.
|
|
||||||
parts := strings.Split(req.URL.Path, "/")
|
|
||||||
if len(parts) < 3 {
|
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileName := parts[len(parts)-1]
|
|
||||||
puzzleId := parts[len(parts)-2]
|
|
||||||
categoryName := parts[len(parts)-3]
|
|
||||||
|
|
||||||
mb, ok := ctx.categories[categoryName]
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mbFilename := fmt.Sprintf("content/%s/%s", puzzleId, fileName)
|
|
||||||
mf, err := mb.Open(mbFilename)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer mf.Close()
|
|
||||||
|
|
||||||
http.ServeContent(w, req, fileName, mf.ModTime(), mf)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) staticHandler(w http.ResponseWriter, req *http.Request) {
|
|
||||||
path := req.URL.Path
|
|
||||||
if strings.Contains(path, "..") {
|
|
||||||
http.Error(w, "Invalid URL path", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if path == "/" {
|
|
||||||
path = "/index.html"
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Open(ctx.ThemePath(path))
|
|
||||||
if err != nil {
|
|
||||||
http.NotFound(w, req)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
d, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
http.NotFound(w, req)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.ServeContent(w, req, path, d.ModTime(), f)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) manifestHandler(w http.ResponseWriter, req *http.Request) {
|
|
||||||
if !ctx.Runtime.export_manifest {
|
|
||||||
http.Error(w, "Endpoint disabled", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
teamId := req.FormValue("id")
|
|
||||||
if _, err := ctx.TeamName(teamId); err != nil {
|
|
||||||
http.Error(w, "Must provide a valid team ID", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Method == http.MethodHead {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest := make([]string, 0)
|
|
||||||
manifest = append(manifest, "puzzles.json")
|
|
||||||
manifest = append(manifest, "points.json")
|
|
||||||
|
|
||||||
// Pack up the theme files
|
|
||||||
theme_root_re := regexp.MustCompile(fmt.Sprintf("^%s/", ctx.ThemeDir))
|
|
||||||
filepath.Walk(ctx.ThemeDir, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !info.IsDir() { // Only package up files
|
|
||||||
localized_path := theme_root_re.ReplaceAllLiteralString(path, "")
|
|
||||||
manifest = append(manifest, localized_path)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// Package up files for currently-unlocked puzzles in categories
|
|
||||||
for category_name, category := range ctx.categories {
|
|
||||||
if _, ok := ctx.MaxPointsUnlocked[category_name]; ok { // Check that the category is actually unlocked. This should never fail, probably
|
|
||||||
for _, file := range category.zf.File {
|
|
||||||
parts := strings.Split(file.Name, "/")
|
|
||||||
|
|
||||||
if parts[0] == "content" { // Only pick up content files, not thing like map.txt
|
|
||||||
for _, puzzlemap := range category.puzzlemap { // Figure out which puzzles are currently unlocked
|
|
||||||
if puzzlemap.Path == parts[1] && puzzlemap.Points <= ctx.MaxPointsUnlocked[category_name] {
|
|
||||||
|
|
||||||
manifest = append(manifest, path.Join("content", category_name, path.Join(parts[1:]...)))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
manifest_json, _ := json.Marshal(manifest)
|
|
||||||
w.Write(manifest_json)
|
|
||||||
}
|
|
||||||
|
|
||||||
type FurtiveResponseWriter struct {
|
|
||||||
w http.ResponseWriter
|
|
||||||
statusCode *int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w FurtiveResponseWriter) WriteHeader(statusCode int) {
|
|
||||||
*w.statusCode = statusCode
|
|
||||||
w.w.WriteHeader(statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w FurtiveResponseWriter) Write(buf []byte) (n int, err error) {
|
|
||||||
n, err = w.w.Write(buf)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w FurtiveResponseWriter) Header() http.Header {
|
|
||||||
return w.w.Header()
|
|
||||||
}
|
|
||||||
|
|
||||||
// This gives Instances the signature of http.Handler
|
|
||||||
func (ctx *Instance) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
|
|
||||||
w := FurtiveResponseWriter{
|
|
||||||
w: wOrig,
|
|
||||||
statusCode: new(int),
|
|
||||||
}
|
|
||||||
|
|
||||||
clientIP := r.RemoteAddr
|
|
||||||
|
|
||||||
if (ctx.UseXForwarded) {
|
|
||||||
forwardedIP := r.Header.Get("X-Forwarded-For")
|
|
||||||
forwardedIP = strings.Split(forwardedIP, ", ")[0]
|
|
||||||
|
|
||||||
if forwardedIP != "" {
|
|
||||||
clientIP = forwardedIP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.mux.ServeHTTP(w, r)
|
|
||||||
log.Printf(
|
|
||||||
"%s %s %s %d\n",
|
|
||||||
clientIP,
|
|
||||||
r.Method,
|
|
||||||
r.URL,
|
|
||||||
*w.statusCode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) BindHandlers() {
|
|
||||||
ctx.mux.HandleFunc(ctx.Base+"/", ctx.staticHandler)
|
|
||||||
ctx.mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler)
|
|
||||||
ctx.mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler)
|
|
||||||
ctx.mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler)
|
|
||||||
ctx.mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler)
|
|
||||||
ctx.mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler)
|
|
||||||
ctx.mux.HandleFunc(ctx.Base+"/current_manifest.json", ctx.manifestHandler)
|
|
||||||
}
|
|
|
@ -1,250 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RuntimeConfig struct {
|
|
||||||
export_manifest bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type Instance struct {
|
|
||||||
Base string
|
|
||||||
MothballDir string
|
|
||||||
PuzzlesDir string
|
|
||||||
StateDir string
|
|
||||||
ThemeDir string
|
|
||||||
AttemptInterval time.Duration
|
|
||||||
UseXForwarded bool
|
|
||||||
|
|
||||||
Runtime RuntimeConfig
|
|
||||||
|
|
||||||
categories map[string]*Mothball
|
|
||||||
MaxPointsUnlocked map[string]int
|
|
||||||
update chan bool
|
|
||||||
jPuzzleList []byte
|
|
||||||
jPointsLog []byte
|
|
||||||
nextAttempt map[string]time.Time
|
|
||||||
nextAttemptMutex *sync.RWMutex
|
|
||||||
mux *http.ServeMux
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) Initialize() error {
|
|
||||||
// Roll over and die if directories aren't even set up
|
|
||||||
if _, err := os.Stat(ctx.MothballDir); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(ctx.StateDir); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Base = strings.TrimRight(ctx.Base, "/")
|
|
||||||
ctx.categories = map[string]*Zipfs{}
|
|
||||||
ctx.update = make(chan bool, 10)
|
|
||||||
ctx.nextAttempt = map[string]time.Time{}
|
|
||||||
ctx.nextAttemptMutex = new(sync.RWMutex)
|
|
||||||
ctx.mux = http.NewServeMux()
|
|
||||||
|
|
||||||
ctx.BindHandlers()
|
|
||||||
ctx.MaybeInitialize()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stuff people with mediocre handwriting could write down unambiguously, and can be entered without holding down shift
|
|
||||||
const distinguishableChars = "234678abcdefhijkmnpqrtwxyz="
|
|
||||||
|
|
||||||
func mktoken() string {
|
|
||||||
a := make([]byte, 8)
|
|
||||||
for i := range a {
|
|
||||||
char := rand.Intn(len(distinguishableChars))
|
|
||||||
a[i] = distinguishableChars[char]
|
|
||||||
}
|
|
||||||
return string(a)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) MaybeInitialize() {
|
|
||||||
// Only do this if it hasn't already been done
|
|
||||||
if _, err := os.Stat(ctx.StatePath("initialized")); err == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Print("initialized file missing, re-initializing")
|
|
||||||
|
|
||||||
// Remove any extant control and state files
|
|
||||||
os.Remove(ctx.StatePath("until"))
|
|
||||||
os.Remove(ctx.StatePath("disabled"))
|
|
||||||
os.Remove(ctx.StatePath("points.log"))
|
|
||||||
|
|
||||||
os.RemoveAll(ctx.StatePath("points.tmp"))
|
|
||||||
os.RemoveAll(ctx.StatePath("points.new"))
|
|
||||||
os.RemoveAll(ctx.StatePath("teams"))
|
|
||||||
|
|
||||||
// Make sure various subdirectories exist
|
|
||||||
os.Mkdir(ctx.StatePath("points.tmp"), 0755)
|
|
||||||
os.Mkdir(ctx.StatePath("points.new"), 0755)
|
|
||||||
os.Mkdir(ctx.StatePath("teams"), 0755)
|
|
||||||
|
|
||||||
// Preseed available team ids if file doesn't exist
|
|
||||||
if f, err := os.OpenFile(ctx.StatePath("teamids.txt"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
|
|
||||||
defer f.Close()
|
|
||||||
for i := 0; i <= 100; i += 1 {
|
|
||||||
fmt.Fprintln(f, mktoken())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create initialized file that signals whether we're set up
|
|
||||||
f, err := os.OpenFile(ctx.StatePath("initialized"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
fmt.Fprintln(f, "Remove this file to reinitialize the contest")
|
|
||||||
}
|
|
||||||
|
|
||||||
func pathCleanse(parts []string) string {
|
|
||||||
clean := make([]string, len(parts))
|
|
||||||
for i := range parts {
|
|
||||||
part := parts[i]
|
|
||||||
part = strings.TrimLeft(part, ".")
|
|
||||||
if p := strings.LastIndex(part, "/"); p >= 0 {
|
|
||||||
part = part[p+1:]
|
|
||||||
}
|
|
||||||
clean[i] = part
|
|
||||||
}
|
|
||||||
return path.Join(clean...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx Instance) MothballPath(parts ...string) string {
|
|
||||||
tail := pathCleanse(parts)
|
|
||||||
return path.Join(ctx.MothballDir, tail)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) StatePath(parts ...string) string {
|
|
||||||
tail := pathCleanse(parts)
|
|
||||||
return path.Join(ctx.StateDir, tail)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) ThemePath(parts ...string) string {
|
|
||||||
tail := pathCleanse(parts)
|
|
||||||
return path.Join(ctx.ThemeDir, tail)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) TooFast(teamId string) bool {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
ctx.nextAttemptMutex.RLock()
|
|
||||||
next, _ := ctx.nextAttempt[teamId]
|
|
||||||
ctx.nextAttemptMutex.RUnlock()
|
|
||||||
|
|
||||||
ctx.nextAttemptMutex.Lock()
|
|
||||||
ctx.nextAttempt[teamId] = now.Add(ctx.AttemptInterval)
|
|
||||||
ctx.nextAttemptMutex.Unlock()
|
|
||||||
|
|
||||||
return now.Before(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) PointsLog(teamId string) AwardList {
|
|
||||||
awardlist := AwardList{}
|
|
||||||
|
|
||||||
fn := ctx.StatePath("points.log")
|
|
||||||
|
|
||||||
f, err := os.Open(fn)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Unable to open %s: %s", fn, err)
|
|
||||||
return awardlist
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
cur, err := ParseAward(line)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Skipping malformed award line %s: %s", line, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(teamId) > 0 && cur.TeamId != teamId {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
awardlist = append(awardlist, cur)
|
|
||||||
}
|
|
||||||
|
|
||||||
return awardlist
|
|
||||||
}
|
|
||||||
|
|
||||||
// AwardPoints gives points to teamId in category.
|
|
||||||
// It first checks to make sure these are not duplicate points.
|
|
||||||
// This is not a perfect check, you can trigger a race condition here.
|
|
||||||
// It's just a courtesy to the user.
|
|
||||||
// The maintenance task makes sure we never have duplicate points in the log.
|
|
||||||
func (ctx *Instance) AwardPoints(teamId, category string, points int) error {
|
|
||||||
a := Award{
|
|
||||||
When: time.Now(),
|
|
||||||
TeamId: teamId,
|
|
||||||
Category: category,
|
|
||||||
Points: points,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := ctx.TeamName(teamId)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("No registered team with this hash")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, e := range ctx.PointsLog("") {
|
|
||||||
if a.Same(e) {
|
|
||||||
return fmt.Errorf("Points already awarded to this team in this category")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn := fmt.Sprintf("%s-%s-%d", teamId, category, points)
|
|
||||||
tmpfn := ctx.StatePath("points.tmp", fn)
|
|
||||||
newfn := ctx.StatePath("points.new", fn)
|
|
||||||
|
|
||||||
if err := ioutil.WriteFile(tmpfn, []byte(a.String()), 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Rename(tmpfn, newfn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.update <- true
|
|
||||||
log.Printf("Award %s %s %d", teamId, category, points)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) OpenCategoryFile(category string, parts ...string) (io.ReadCloser, error) {
|
|
||||||
mb, ok := ctx.categories[category]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("No such category: %s", category)
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := path.Join(parts...)
|
|
||||||
f, err := mb.Open(filename)
|
|
||||||
return f, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) ValidTeamId(teamId string) bool {
|
|
||||||
ctx.nextAttemptMutex.RLock()
|
|
||||||
_, ok := ctx.nextAttempt[teamId]
|
|
||||||
ctx.nextAttemptMutex.RUnlock()
|
|
||||||
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) TeamName(teamId string) (string, error) {
|
|
||||||
teamNameBytes, err := ioutil.ReadFile(ctx.StatePath("teams", teamId))
|
|
||||||
teamName := strings.TrimSpace(string(teamNameBytes))
|
|
||||||
return teamName, err
|
|
||||||
}
|
|
|
@ -1,333 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (pm *PuzzleMap) MarshalJSON() ([]byte, error) {
|
|
||||||
if pm == nil {
|
|
||||||
return []byte("null"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
jPath, err := json.Marshal(pm.Path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ret := fmt.Sprintf("[%d,%s]", pm.Points, string(jPath))
|
|
||||||
return []byte(ret), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) generatePuzzleList() {
|
|
||||||
maxByCategory := map[string]int{}
|
|
||||||
for _, a := range ctx.PointsLog("") {
|
|
||||||
if a.Points > maxByCategory[a.Category] {
|
|
||||||
maxByCategory[a.Category] = a.Points
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ret := map[string][]PuzzleMap{}
|
|
||||||
for catName, mb := range ctx.categories {
|
|
||||||
filtered_puzzlemap := make([]PuzzleMap, 0, 30)
|
|
||||||
completed := true
|
|
||||||
|
|
||||||
for _, pm := range mb.puzzlemap {
|
|
||||||
filtered_puzzlemap = append(filtered_puzzlemap, pm)
|
|
||||||
|
|
||||||
if pm.Points > maxByCategory[catName] {
|
|
||||||
completed = false
|
|
||||||
maxByCategory[catName] = pm.Points
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if completed {
|
|
||||||
filtered_puzzlemap = append(filtered_puzzlemap, PuzzleMap{0, ""})
|
|
||||||
}
|
|
||||||
|
|
||||||
ret[catName] = filtered_puzzlemap
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the unlocked points for use in other functions
|
|
||||||
ctx.MaxPointsUnlocked = maxByCategory
|
|
||||||
|
|
||||||
jpl, err := json.Marshal(ret)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Marshalling puzzles.js: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.jPuzzleList = jpl
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) generatePointsLog(teamId string) []byte {
|
|
||||||
var ret struct {
|
|
||||||
Teams map[string]string `json:"teams"`
|
|
||||||
Points []*Award `json:"points"`
|
|
||||||
}
|
|
||||||
ret.Teams = map[string]string{}
|
|
||||||
ret.Points = ctx.PointsLog(teamId)
|
|
||||||
|
|
||||||
teamNumbersById := map[string]int{}
|
|
||||||
for nr, a := range ret.Points {
|
|
||||||
teamNumber, ok := teamNumbersById[a.TeamId]
|
|
||||||
if !ok {
|
|
||||||
teamName, err := ctx.TeamName(a.TeamId)
|
|
||||||
if err != nil {
|
|
||||||
teamName = "Rodney" // https://en.wikipedia.org/wiki/Rogue_(video_game)#Gameplay
|
|
||||||
}
|
|
||||||
teamNumber = nr
|
|
||||||
teamNumbersById[a.TeamId] = teamNumber
|
|
||||||
ret.Teams[strconv.FormatInt(int64(teamNumber), 16)] = teamName
|
|
||||||
}
|
|
||||||
a.TeamId = strconv.FormatInt(int64(teamNumber), 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
jpl, err := json.Marshal(ret)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Marshalling points.js: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(teamId) == 0 {
|
|
||||||
ctx.jPointsLog = jpl
|
|
||||||
}
|
|
||||||
return jpl
|
|
||||||
}
|
|
||||||
|
|
||||||
// maintenance runs
|
|
||||||
func (ctx *Instance) tidy() {
|
|
||||||
// Do they want to reset everything?
|
|
||||||
ctx.MaybeInitialize()
|
|
||||||
|
|
||||||
// Check set config
|
|
||||||
ctx.UpdateConfig()
|
|
||||||
|
|
||||||
// Refresh all current categories
|
|
||||||
for categoryName, mb := range ctx.categories {
|
|
||||||
if err := mb.Refresh(); err != nil {
|
|
||||||
// Backing file vanished: remove this category
|
|
||||||
log.Printf("Removing category: %s: %s", categoryName, err)
|
|
||||||
mb.Close()
|
|
||||||
delete(ctx.categories, categoryName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any new categories?
|
|
||||||
files, err := ioutil.ReadDir(ctx.MothballPath())
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error listing mothballs: %s", err)
|
|
||||||
}
|
|
||||||
for _, f := range files {
|
|
||||||
filename := f.Name()
|
|
||||||
filepath := ctx.MothballPath(filename)
|
|
||||||
if !strings.HasSuffix(filename, ".mb") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
categoryName := strings.TrimSuffix(filename, ".mb")
|
|
||||||
|
|
||||||
if _, ok := ctx.categories[categoryName]; !ok {
|
|
||||||
mb, err := OpenZipfs(filepath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error opening %s: %s", filepath, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("New category: %s", filename)
|
|
||||||
ctx.categories[categoryName] = mb
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readTeams reads in the list of team IDs,
|
|
||||||
// so we can quickly validate them.
|
|
||||||
func (ctx *Instance) readTeams() {
|
|
||||||
filepath := ctx.StatePath("teamids.txt")
|
|
||||||
teamids, err := os.Open(filepath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error openining %s: %s", filepath, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer teamids.Close()
|
|
||||||
|
|
||||||
// List out team IDs
|
|
||||||
newList := map[string]bool{}
|
|
||||||
scanner := bufio.NewScanner(teamids)
|
|
||||||
for scanner.Scan() {
|
|
||||||
teamId := scanner.Text()
|
|
||||||
if (teamId == "..") || strings.ContainsAny(teamId, "/") {
|
|
||||||
log.Printf("Dangerous team ID dropped: %s", teamId)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
newList[scanner.Text()] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// For any new team IDs, set their next attempt time to right now
|
|
||||||
now := time.Now()
|
|
||||||
added := 0
|
|
||||||
for k, _ := range newList {
|
|
||||||
ctx.nextAttemptMutex.RLock()
|
|
||||||
_, ok := ctx.nextAttempt[k]
|
|
||||||
ctx.nextAttemptMutex.RUnlock()
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
ctx.nextAttemptMutex.Lock()
|
|
||||||
ctx.nextAttempt[k] = now
|
|
||||||
ctx.nextAttemptMutex.Unlock()
|
|
||||||
|
|
||||||
added += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For any removed team IDs, remove them
|
|
||||||
removed := 0
|
|
||||||
ctx.nextAttemptMutex.Lock() // XXX: This could be less of a cludgel
|
|
||||||
for k, _ := range ctx.nextAttempt {
|
|
||||||
if _, ok := newList[k]; !ok {
|
|
||||||
delete(ctx.nextAttempt, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.nextAttemptMutex.Unlock()
|
|
||||||
|
|
||||||
if (added > 0) || (removed > 0) {
|
|
||||||
log.Printf("Team IDs updated: %d added, %d removed", added, removed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectPoints gathers up files in points.new/ and appends their contents to points.log,
|
|
||||||
// removing each points.new/ file as it goes.
|
|
||||||
func (ctx *Instance) collectPoints() {
|
|
||||||
points := ctx.PointsLog("")
|
|
||||||
|
|
||||||
pointsFilename := ctx.StatePath("points.log")
|
|
||||||
pointsNewFilename := ctx.StatePath("points.log.new")
|
|
||||||
|
|
||||||
// Yo, this is delicate.
|
|
||||||
// If we have to return early, we must remove this file.
|
|
||||||
// If the file's written and we move it successfully,
|
|
||||||
// we need to remove all the little points files that built it.
|
|
||||||
newPoints, err := os.OpenFile(pointsNewFilename, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Can't append to points log: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := ioutil.ReadDir(ctx.StatePath("points.new"))
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error reading packages: %s", err)
|
|
||||||
}
|
|
||||||
removearino := make([]string, 0, len(files))
|
|
||||||
for _, f := range files {
|
|
||||||
filename := ctx.StatePath("points.new", f.Name())
|
|
||||||
s, err := ioutil.ReadFile(filename)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Can't read points file %s: %s", filename, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
award, err := ParseAward(string(s))
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Can't parse award file %s: %s", filename, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
duplicate := false
|
|
||||||
for _, e := range points {
|
|
||||||
if award.Same(e) {
|
|
||||||
duplicate = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if duplicate {
|
|
||||||
log.Printf("Skipping duplicate points: %s", award.String())
|
|
||||||
} else {
|
|
||||||
points = append(points, award)
|
|
||||||
}
|
|
||||||
removearino = append(removearino, filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Stable(points)
|
|
||||||
for _, point := range points {
|
|
||||||
fmt.Fprintln(newPoints, point.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
newPoints.Close()
|
|
||||||
|
|
||||||
if err := os.Rename(pointsNewFilename, pointsFilename); err != nil {
|
|
||||||
log.Printf("Unable to move %s to %s: %s", pointsFilename, pointsNewFilename, err)
|
|
||||||
if err := os.Remove(pointsNewFilename); err != nil {
|
|
||||||
log.Printf("Also couldn't remove %s: %s", pointsNewFilename, err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, filename := range removearino {
|
|
||||||
if err := os.Remove(filename); err != nil {
|
|
||||||
log.Printf("Unable to remove %s: %s", filename, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) isEnabled() bool {
|
|
||||||
// Skip if we've been disabled
|
|
||||||
if _, err := os.Stat(ctx.StatePath("disabled")); err == nil {
|
|
||||||
log.Print("Suspended: disabled file found")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
untilspec, err := ioutil.ReadFile(ctx.StatePath("until"))
|
|
||||||
if err == nil {
|
|
||||||
untilspecs := strings.TrimSpace(string(untilspec))
|
|
||||||
until, err := time.Parse(time.RFC3339, untilspecs)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Suspended: Unparseable until date: %s", untilspec)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if until.Before(time.Now()) {
|
|
||||||
log.Print("Suspended: until time reached, suspending maintenance")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *Instance) UpdateConfig() {
|
|
||||||
// Handle export manifest
|
|
||||||
if _, err := os.Stat(ctx.StatePath("export_manifest")); err == nil {
|
|
||||||
if !ctx.Runtime.export_manifest {
|
|
||||||
log.Print("Enabling manifest export")
|
|
||||||
ctx.Runtime.export_manifest = true
|
|
||||||
}
|
|
||||||
} else if ctx.Runtime.export_manifest {
|
|
||||||
log.Print("Disabling manifest export")
|
|
||||||
ctx.Runtime.export_manifest = false
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// maintenance is the goroutine that runs a periodic maintenance task
|
|
||||||
func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) {
|
|
||||||
for {
|
|
||||||
if ctx.isEnabled() {
|
|
||||||
ctx.tidy()
|
|
||||||
ctx.readTeams()
|
|
||||||
ctx.collectPoints()
|
|
||||||
ctx.generatePuzzleList()
|
|
||||||
ctx.generatePointsLog("")
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.update:
|
|
||||||
// log.Print("Forced update")
|
|
||||||
case <-time.After(maintenanceInterval):
|
|
||||||
// log.Print("Housekeeping...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,92 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"math/rand"
|
|
||||||
"mime"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setup() error {
|
|
||||||
rand.Seed(time.Now().UnixNano())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ctx := &Instance{}
|
|
||||||
|
|
||||||
flag.StringVar(
|
|
||||||
&ctx.Base,
|
|
||||||
"base",
|
|
||||||
"/",
|
|
||||||
"Base URL of this instance",
|
|
||||||
)
|
|
||||||
flag.StringVar(
|
|
||||||
&ctx.MothballDir,
|
|
||||||
"mothballs",
|
|
||||||
"/mothballs",
|
|
||||||
"Path to read mothballs",
|
|
||||||
)
|
|
||||||
flag.StringVar(
|
|
||||||
&ctx.PuzzlesDir,
|
|
||||||
"puzzles",
|
|
||||||
"",
|
|
||||||
"Path to read puzzle source trees",
|
|
||||||
)
|
|
||||||
flag.StringVar(
|
|
||||||
&ctx.StateDir,
|
|
||||||
"state",
|
|
||||||
"/state",
|
|
||||||
"Path to write state",
|
|
||||||
)
|
|
||||||
flag.StringVar(
|
|
||||||
&ctx.ThemeDir,
|
|
||||||
"theme",
|
|
||||||
"/theme",
|
|
||||||
"Path to static theme resources (HTML, images, css, ...)",
|
|
||||||
)
|
|
||||||
flag.DurationVar(
|
|
||||||
&ctx.AttemptInterval,
|
|
||||||
"attempt",
|
|
||||||
500*time.Millisecond,
|
|
||||||
"Per-team time required between answer attempts",
|
|
||||||
)
|
|
||||||
maintenanceInterval := flag.Duration(
|
|
||||||
"maint",
|
|
||||||
20*time.Second,
|
|
||||||
"Time between maintenance tasks",
|
|
||||||
)
|
|
||||||
flag.BoolVar(
|
|
||||||
&ctx.UseXForwarded,
|
|
||||||
"x-forwarded-for",
|
|
||||||
false,
|
|
||||||
"Emit IPs from the X-Forwarded-For header in logs, when available, instead of the source IP. Use this when running behind a load-balancer or proxy",
|
|
||||||
)
|
|
||||||
listen := flag.String(
|
|
||||||
"listen",
|
|
||||||
":8080",
|
|
||||||
"[host]:port to bind and listen",
|
|
||||||
)
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if err := setup(); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := ctx.Initialize()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add some MIME extensions
|
|
||||||
// Doing this avoids decompressing a mothball entry twice per request
|
|
||||||
mime.AddExtensionType(".json", "application/json")
|
|
||||||
mime.AddExtensionType(".zip", "application/zip")
|
|
||||||
|
|
||||||
go ctx.Maintenance(*maintenanceInterval)
|
|
||||||
|
|
||||||
log.Printf("Listening on %s", *listen)
|
|
||||||
log.Fatal(http.ListenAndServe(*listen, ctx))
|
|
||||||
}
|
|
Loading…
Reference in New Issue