Mostly documentation in Go files

This commit is contained in:
Neale Pickett 2020-08-17 17:43:57 -06:00
parent a46ae95cb6
commit 2a3826e2b3
21 changed files with 255 additions and 295 deletions

View File

@ -1,77 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"strings"
)
type Award struct {
// Unix epoch time of this event
When int64
TeamID string
Category string
Points int
}
type AwardList []*Award
// Implement sort.Interface on AwardList
func (awards AwardList) Len() int {
return len(awards)
}
func (awards AwardList) Less(i, j int) bool {
return awards[i].When < awards[j].When
}
func (awards AwardList) Swap(i, j int) {
tmp := awards[i]
awards[i] = awards[j]
awards[j] = tmp
}
func ParseAward(s string) (*Award, error) {
ret := Award{}
s = strings.TrimSpace(s)
n, err := fmt.Sscanf(s, "%d %s %s %d", &ret.When, &ret.TeamID, &ret.Category, &ret.Points)
if err != nil {
return nil, err
} else if n != 4 {
return nil, fmt.Errorf("Malformed award string: only parsed %d fields", n)
}
return &ret, nil
}
func (a *Award) String() string {
return fmt.Sprintf("%d %s %s %d", a.When, a.TeamID, a.Category, a.Points)
}
func (a *Award) MarshalJSON() ([]byte, error) {
if a == nil {
return []byte("null"), nil
}
ao := []interface{}{
a.When,
a.TeamID,
a.Category,
a.Points,
}
return json.Marshal(ao)
}
func (a *Award) Same(o *Award) bool {
switch {
case a.TeamID != o.TeamID:
return false
case a.Category != o.Category:
return false
case a.Points != o.Points:
return false
}
return true
}

View File

@ -1,61 +0,0 @@
package main
import (
"sort"
"testing"
)
func TestAward(t *testing.T) {
entry := "1536958399 1a2b3c4d counting 1"
a, err := ParseAward(entry)
if err != nil {
t.Error(err)
return
}
if a.TeamID != "1a2b3c4d" {
t.Error("TeamID parsed wrong")
}
if a.Category != "counting" {
t.Error("Category parsed wrong")
}
if a.Points != 1 {
t.Error("Points parsed wrong")
}
if a.String() != entry {
t.Error("String conversion wonky")
}
if ja, err := a.MarshalJSON(); err != nil {
t.Error(err)
} else if string(ja) != `[1536958399,"1a2b3c4d","counting",1]` {
t.Error("JSON wrong")
}
if _, err := ParseAward("bad bad bad 1"); err == nil {
t.Error("Not throwing error on bad timestamp")
}
if _, err := ParseAward("1 bad bad bad"); err == nil {
t.Error("Not throwing error on bad points")
}
}
func TestAwardList(t *testing.T) {
a, _ := ParseAward("1536958399 1a2b3c4d counting 1")
b, _ := ParseAward("1536958400 1a2b3c4d counting 1")
c, _ := ParseAward("1536958300 1a2b3c4d counting 1")
list := AwardList{a, b, c}
if sort.IsSorted(list) {
t.Error("Unsorted list thinks it's sorted")
}
sort.Stable(list)
if (list[0] != c) || (list[1] != a) || (list[2] != b) {
t.Error("Sorting didn't")
}
if !sort.IsSorted(list) {
t.Error("Sorted list thinks it isn't")
}
}

View File

@ -5,6 +5,8 @@ import (
"net/http"
"strconv"
"strings"
"github.com/dirtbags/moth/pkg/jsend"
)
// HTTPServer is a MOTH HTTP server
@ -44,7 +46,7 @@ func (h *HTTPServer) HandleMothFunc(
// ServeHTTP provides the http.Handler interface
func (h *HTTPServer) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
w := MothResponseWriter{
w := StatusResponseWriter{
statusCode: new(int),
ResponseWriter: wOrig,
}
@ -58,14 +60,14 @@ func (h *HTTPServer) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
)
}
// MothResponseWriter provides a ResponseWriter that remembers what the status code was
type MothResponseWriter struct {
// StatusResponseWriter provides a ResponseWriter that remembers what the status code was
type StatusResponseWriter struct {
statusCode *int
http.ResponseWriter
}
// WriteHeader sends an HTTP response header with the provided status code
func (w MothResponseWriter) WriteHeader(statusCode int) {
func (w StatusResponseWriter) WriteHeader(statusCode int) {
*w.statusCode = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
@ -94,16 +96,16 @@ func (h *HTTPServer) ThemeHandler(mh MothRequestHandler, w http.ResponseWriter,
// StateHandler returns the full JSON-encoded state of the event
func (h *HTTPServer) StateHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
JSONWrite(w, mh.ExportState())
jsend.JSONWrite(w, mh.ExportState())
}
// RegisterHandler handles attempts to register a team
func (h *HTTPServer) RegisterHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
teamName := req.FormValue("name")
if err := mh.Register(teamName); err != nil {
JSendf(w, JSendFail, "not registered", err.Error())
jsend.Sendf(w, jsend.Fail, "not registered", err.Error())
} else {
JSendf(w, JSendSuccess, "registered", "Team ID registered")
jsend.Sendf(w, jsend.Success, "registered", "Team ID registered")
}
}
@ -116,9 +118,9 @@ func (h *HTTPServer) AnswerHandler(mh MothRequestHandler, w http.ResponseWriter,
points, _ := strconv.Atoi(pointstr)
if err := mh.CheckAnswer(cat, points, answer); err != nil {
JSendf(w, JSendFail, "not accepted", err.Error())
jsend.Sendf(w, jsend.Fail, "not accepted", err.Error())
} else {
JSendf(w, JSendSuccess, "accepted", "%d points awarded in %s", points, cat)
jsend.Sendf(w, jsend.Success, "accepted", "%d points awarded in %s", points, cat)
}
}

View File

@ -9,7 +9,7 @@ import (
"github.com/spf13/afero"
)
func custodian(updateInterval time.Duration, components []Component) {
func custodian(updateInterval time.Duration, components []Provider) {
update := func() {
for _, c := range components {
c.Update()
@ -39,7 +39,7 @@ func main() {
mothballPath := flag.String(
"mothballs",
"mothballs",
"Path to mothballs to host",
"Path to mothball files",
)
refreshInterval := flag.Duration(
"refresh",
@ -67,7 +67,7 @@ func main() {
mime.AddExtensionType(".json", "application/json")
mime.AddExtensionType(".zip", "application/zip")
go custodian(*refreshInterval, []Component{theme, state, puzzles})
go custodian(*refreshInterval, []Provider{theme, state, puzzles})
server := NewMothServer(puzzles, theme, state)
httpd := NewHTTPServer(*base, server)

12
cmd/mothd/main_test.go Normal file
View File

@ -0,0 +1,12 @@
package main
import (
"testing"
)
func TestEverything(t *testing.T) {
state := NewTestState()
t.Error("No test")
state.Update()
}

View File

@ -1,20 +1,23 @@
package main
import (
"github.com/spf13/afero"
"log"
"strings"
"bufio"
"strconv"
"time"
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/spf13/afero"
)
// Mothballs provides a collection of active mothball files (puzzle categories)
type Mothballs struct {
categories map[string]*Zipfs
afero.Fs
}
// NewMothballs returns a new Mothballs structure backed by the provided directory
func NewMothballs(fs afero.Fs) *Mothballs {
return &Mothballs{
Fs: fs,
@ -22,9 +25,10 @@ func NewMothballs(fs afero.Fs) *Mothballs {
}
}
// Open returns a ReadSeekCloser corresponding to the filename in a puzzle's category and points
func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) {
mb, ok := m.categories[cat]
if ! ok {
if !ok {
return nil, time.Time{}, fmt.Errorf("No such category: %s", cat)
}
@ -32,6 +36,7 @@ func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekClose
return f, mb.ModTime(), err
}
// Inventory returns the list of current categories
func (m *Mothballs) Inventory() []Category {
categories := make([]Category, 0, 20)
for cat, zfs := range m.categories {
@ -55,18 +60,19 @@ func (m *Mothballs) Inventory() []Category {
return categories
}
// CheckAnswer returns an error if the provided answer is in any way incorrect for the given category and points
func (m *Mothballs) CheckAnswer(cat string, points int, answer string) error {
zfs, ok := m.categories[cat]
if ! ok {
if !ok {
return fmt.Errorf("No such category: %s", cat)
}
af, err := zfs.Open("answers.txt")
if err != nil {
return fmt.Errorf("No answers.txt file")
}
defer af.Close()
needle := fmt.Sprintf("%d %s", points, answer)
scanner := bufio.NewScanner(af)
for scanner.Scan() {
@ -78,6 +84,8 @@ func (m *Mothballs) CheckAnswer(cat string, points int, answer string) error {
return fmt.Errorf("Invalid answer")
}
// Update refreshes internal state.
// It looks for changes to the directory listing, and caches any new mothballs.
func (m *Mothballs) Update() {
// Any new categories?
files, err := afero.ReadDir(m.Fs, "/")

View File

@ -0,0 +1,9 @@
package main
import (
"testing"
)
func TestMothballs(t *testing.T) {
t.Error("moo")
}

View File

@ -5,60 +5,71 @@ import (
"io"
"strconv"
"time"
"github.com/dirtbags/moth/pkg/award"
)
// Category represents a puzzle category.
type Category struct {
Name string
Puzzles []int
}
// ReadSeekCloser defines a struct that can read, seek, and close.
type ReadSeekCloser interface {
io.Reader
io.Seeker
io.Closer
}
// StateExport is given to clients requesting the current state.
type StateExport struct {
Config struct {
Devel bool
}
Messages string
TeamNames map[string]string
PointsLog []Award
PointsLog award.List
Puzzles map[string][]int
}
// PuzzleProvider defines what's required to provide puzzles.
type PuzzleProvider interface {
Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error)
Inventory() []Category
CheckAnswer(cat string, points int, answer string) error
Component
Provider
}
// ThemeProvider defines what's required to provide a theme.
type ThemeProvider interface {
Open(path string) (ReadSeekCloser, time.Time, error)
Component
Provider
}
// StateProvider defines what's required to provide MOTH state.
type StateProvider interface {
Messages() string
PointsLog() []*Award
TeamName(teamId string) (string, error)
SetTeamName(teamId, teamName string) error
AwardPoints(teamId string, cat string, points int) error
Component
PointsLog() award.List
TeamName(teamID string) (string, error)
SetTeamName(teamID, teamName string) error
AwardPoints(teamID string, cat string, points int) error
Provider
}
type Component interface {
// Provider defines providers that can be updated.
type Provider interface {
Update()
}
// MothServer gathers together the providers that make up a MOTH server.
type MothServer struct {
Puzzles PuzzleProvider
Theme ThemeProvider
State StateProvider
}
// NewMothServer returns a new MothServer.
func NewMothServer(puzzles PuzzleProvider, theme ThemeProvider, state StateProvider) *MothServer {
return &MothServer{
Puzzles: puzzles,
@ -67,21 +78,23 @@ func NewMothServer(puzzles PuzzleProvider, theme ThemeProvider, state StateProvi
}
}
func (s *MothServer) NewHandler(teamId string) MothRequestHandler {
// NewHandler returns a new http.RequestHandler for the provided teamID.
func (s *MothServer) NewHandler(teamID string) MothRequestHandler {
return MothRequestHandler{
MothServer: s,
teamId: teamId,
teamID: teamID,
}
}
// XXX: Come up with a better name for this.
// MothRequestHandler provides http.RequestHandler for a MothServer.
type MothRequestHandler struct {
*MothServer
teamId string
teamID string
}
// PuzzlesOpen opens a file associated with a puzzle.
func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (ReadSeekCloser, time.Time, error) {
export := mh.ExportAllState()
export := mh.ExportState()
fmt.Println(export.Puzzles)
for _, p := range export.Puzzles[cat] {
fmt.Println(points, p)
@ -93,93 +106,91 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (
return nil, time.Time{}, fmt.Errorf("Puzzle locked")
}
// ThemeOpen opens a file from a theme.
func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, error) {
return mh.Theme.Open(path)
}
// Register associates a team name with a team ID.
func (mh *MothRequestHandler) Register(teamName string) error {
// XXX: Should we just return success if the team is already registered?
// XXX: Should this function be renamed to Login?
if teamName == "" {
return fmt.Errorf("Empty team name")
}
return mh.State.SetTeamName(mh.teamId, teamName)
return mh.State.SetTeamName(mh.teamID, teamName)
}
// CheckAnswer returns an error if answer is not a correct answer for puzzle points in category cat
func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string) error {
if err := mh.Puzzles.CheckAnswer(cat, points, answer); err != nil {
return err
}
if err := mh.State.AwardPoints(mh.teamId, cat, points); err != nil {
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
return err
}
return nil
}
func (mh *MothRequestHandler) ExportAllState() *StateExport {
// ExportState anonymizes team IDs and returns StateExport.
// If a teamID has been specified for this MothRequestHandler,
// the anonymized team name for this teamID has the special value "self".
// If not, the puzzles list is empty.
func (mh *MothRequestHandler) ExportState() *StateExport {
export := StateExport{}
teamName, _ := mh.State.TeamName(mh.teamId)
teamName, _ := mh.State.TeamName(mh.teamID)
export.Messages = mh.State.Messages()
export.TeamNames = map[string]string{"self": teamName}
// Anonymize team IDs in points log, and write out team names
pointsLog := mh.State.PointsLog()
exportIds := map[string]string{mh.teamId: "self"}
exportIDs := map[string]string{mh.teamID: "self"}
maxSolved := map[string]int{}
export.PointsLog = make([]Award, len(pointsLog))
for logno, award := range pointsLog {
exportAward := *award
if id, ok := exportIds[award.TeamID]; ok {
exportAward.TeamID = id
export.PointsLog = make(award.List, len(pointsLog))
for logno, awd := range pointsLog {
if id, ok := exportIDs[awd.TeamID]; ok {
awd.TeamID = id
} else {
exportId := strconv.Itoa(logno)
name, _ := mh.State.TeamName(award.TeamID)
exportAward.TeamID = exportId
exportIds[award.TeamID] = exportAward.TeamID
export.TeamNames[exportId] = name
exportID := strconv.Itoa(logno)
name, _ := mh.State.TeamName(awd.TeamID)
awd.TeamID = exportID
exportIDs[awd.TeamID] = awd.TeamID
export.TeamNames[exportID] = name
}
export.PointsLog[logno] = exportAward
export.PointsLog[logno] = awd
// Record the highest-value unlocked puzzle in each category
if award.Points > maxSolved[award.Category] {
maxSolved[award.Category] = award.Points
if awd.Points > maxSolved[awd.Category] {
maxSolved[awd.Category] = awd.Points
}
}
export.Puzzles = make(map[string][]int)
for _, category := range mh.Puzzles.Inventory() {
// Append sentry (end of puzzles)
allPuzzles := append(category.Puzzles, 0)
if _, ok := export.TeamNames["self"]; ok {
// We used to hand this out to everyone,
// but then we got a bad reputation on some secretive blacklist,
// and now the Navy can't register for events.
max := maxSolved[category.Name]
for _, category := range mh.Puzzles.Inventory() {
// Append sentry (end of puzzles)
allPuzzles := append(category.Puzzles, 0)
puzzles := make([]int, 0, len(allPuzzles))
for i, val := range allPuzzles {
puzzles = allPuzzles[:i+1]
if val > max {
break
max := maxSolved[category.Name]
puzzles := make([]int, 0, len(allPuzzles))
for i, val := range allPuzzles {
puzzles = allPuzzles[:i+1]
if val > max {
break
}
}
export.Puzzles[category.Name] = puzzles
}
export.Puzzles[category.Name] = puzzles
}
return &export
}
func (mh *MothRequestHandler) ExportState() *StateExport {
export := mh.ExportAllState()
// We don't give this out to just anybody,
// because back when we did,
// we got a bad reputation on some secretive blacklist,
// and now the Navy can't register for events.
if export.TeamNames["self"] == "" {
export.Puzzles = map[string][]int{}
}
return export
}

View File

@ -10,12 +10,16 @@ import (
"strings"
"time"
"github.com/dirtbags/moth/pkg/award"
"github.com/spf13/afero"
)
// Stuff people with mediocre handwriting could write down unambiguously, and can be entered without holding down shift
// DistinguishableChars are visually unambiguous glyphs.
// People with mediocre handwriting could write these down unambiguously,
// and they can be entered without holding down shift.
const DistinguishableChars = "234678abcdefhikmnpqrtwxyz="
// State defines the current state of a MOTH instance.
// We use the filesystem for synchronization between threads.
// The only thing State methods need to know is the path to the state directory.
type State struct {
@ -23,6 +27,7 @@ type State struct {
Enabled bool
}
// NewState returns a new State struct backed by the given Fs
func NewState(fs afero.Fs) *State {
return &State{
Fs: fs,
@ -30,7 +35,7 @@ func NewState(fs afero.Fs) *State {
}
}
// Check 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() {
if _, err := s.Stat("enabled"); os.IsNotExist(err) {
s.Enabled = false
@ -81,7 +86,7 @@ func (s *State) UpdateEnabled() {
}
}
// Returns team name given a team ID.
// TeamName returns team name given a team ID.
func (s *State) TeamName(teamID string) (string, error) {
// XXX: directory traversal
teamFile := filepath.Join("teams", teamID)
@ -97,35 +102,35 @@ func (s *State) TeamName(teamID string) (string, error) {
return teamName, nil
}
// Write out team name. This can only be done once.
// SetTeamName writes out team name.
// This can only be done once.
func (s *State) SetTeamName(teamID, teamName string) error {
if f, err := s.Open("teamids.txt"); err != nil {
f, err := s.Open("teamids.txt")
if err != nil {
return fmt.Errorf("Team IDs file does not exist")
} else {
found := false
scanner := bufio.NewScanner(f)
for scanner.Scan() {
if scanner.Text() == teamID {
found = true
break
}
}
f.Close()
if !found {
return fmt.Errorf("Team ID not found in list of valid Team IDs")
}
found := false
scanner := bufio.NewScanner(f)
for scanner.Scan() {
if scanner.Text() == teamID {
found = true
break
}
}
f.Close()
if !found {
return fmt.Errorf("Team ID not found in list of valid Team IDs")
}
teamFile := filepath.Join("teams", teamID)
err := afero.WriteFile(s, teamFile, []byte(teamName), os.ModeExclusive|0644)
if os.IsExist(err) {
if err := afero.WriteFile(s, teamFile, []byte(teamName), os.ModeExclusive|0644); os.IsExist(err) {
return fmt.Errorf("Team ID is already registered")
}
return err
}
// Retrieve the current points log
func (s *State) PointsLog() []*Award {
// PointsLog retrieves the current points log.
func (s *State) PointsLog() award.List {
f, err := s.Open("points.log")
if err != nil {
log.Println(err)
@ -133,11 +138,11 @@ func (s *State) PointsLog() []*Award {
}
defer f.Close()
pointsLog := make([]*Award, 0, 200)
pointsLog := make(award.List, 0, 200)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
cur, err := ParseAward(line)
cur, err := award.Parse(line)
if err != nil {
log.Printf("Skipping malformed award line %s: %s", line, err)
continue
@ -147,7 +152,7 @@ func (s *State) PointsLog() []*Award {
return pointsLog
}
// Retrieve current messages
// Messages retrieves the current messages.
func (s *State) Messages() string {
bMessages, _ := afero.ReadFile(s, "messages.html")
return string(bMessages)
@ -159,7 +164,7 @@ func (s *State) Messages() string {
// It's just a courtesy to the user.
// The update task makes sure we never have duplicate points in the log.
func (s *State) AwardPoints(teamID, category string, points int) error {
a := Award{
a := award.T{
When: time.Now().Unix(),
TeamID: teamID,
Category: category,
@ -172,7 +177,7 @@ func (s *State) AwardPoints(teamID, category string, points int) error {
}
for _, e := range s.PointsLog() {
if a.Same(e) {
if a.Equal(e) {
return fmt.Errorf("Points already awarded to this team in this category")
}
}
@ -208,7 +213,7 @@ func (s *State) collectPoints() {
log.Print("Opening new points: ", err)
continue
}
award, err := ParseAward(string(awardstr))
awd, err := award.Parse(string(awardstr))
if err != nil {
log.Print("Can't parse award file ", filename, ": ", err)
continue
@ -216,23 +221,23 @@ func (s *State) collectPoints() {
duplicate := false
for _, e := range s.PointsLog() {
if award.Same(e) {
if awd.Equal(e) {
duplicate = true
break
}
}
if duplicate {
log.Print("Skipping duplicate points: ", award.String())
log.Print("Skipping duplicate points: ", awd.String())
} else {
log.Print("Award: ", award.String())
log.Print("Award: ", awd.String())
logf, err := s.OpenFile("points.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Print("Can't append to points log: ", err)
return
}
fmt.Fprintln(logf, award.String())
fmt.Fprintln(logf, awd.String())
logf.Close()
}
@ -268,7 +273,7 @@ func (s *State) maybeInitialize() {
// Preseed available team ids if file doesn't exist
if f, err := s.OpenFile("teamids.txt", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
id := make([]byte, 8)
for i := 0; i < 100; i += 1 {
for i := 0; i < 100; i++ {
for i := range id {
char := rand.Intn(len(DistinguishableChars))
id[i] = DistinguishableChars[char]
@ -317,6 +322,7 @@ func (s *State) maybeInitialize() {
}
// Update performs housekeeping on a State struct.
func (s *State) Update() {
s.maybeInitialize()
s.UpdateEnabled()

View File

@ -8,19 +8,22 @@ import (
"github.com/spf13/afero"
)
func NewTestState() *State {
s := NewState(new(afero.MemMapFs))
s.Update()
return s
}
func TestState(t *testing.T) {
fs := new(afero.MemMapFs)
s := NewTestState()
mustExist := func(path string) {
_, err := fs.Stat(path)
_, err := s.Fs.Stat(path)
if os.IsNotExist(err) {
t.Errorf("File %s does not exist", path)
}
}
s := NewState(fs)
s.Update()
pl := s.PointsLog()
if len(pl) != 0 {
t.Errorf("Empty points log is not empty")
@ -30,38 +33,38 @@ func TestState(t *testing.T) {
mustExist("enabled")
mustExist("hours")
teamidsBuf, err := afero.ReadFile(fs, "teamids.txt")
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
if err != nil {
t.Errorf("Reading teamids.txt: %v", err)
}
teamids := bytes.Split(teamidsBuf, []byte("\n"))
if (len(teamids) != 101) || (len(teamids[100]) > 0) {
t.Errorf("There weren't 100 teamids, there were %d", len(teamids))
teamIDs := bytes.Split(teamIDsBuf, []byte("\n"))
if (len(teamIDs) != 101) || (len(teamIDs[100]) > 0) {
t.Errorf("There weren't 100 teamIDs, there were %d", len(teamIDs))
}
teamId := string(teamids[0])
teamID := string(teamIDs[0])
if err := s.SetTeamName("bad team ID", "bad team name"); err == nil {
t.Errorf("Setting bad team ID didn't raise an error")
}
if err := s.SetTeamName(teamId, "My Team"); err != nil {
if err := s.SetTeamName(teamID, "My Team"); err != nil {
t.Errorf("Setting team name: %v", err)
}
category := "poot"
points := 3928
s.AwardPoints(teamId, category, points)
s.AwardPoints(teamID, category, points)
s.Update()
pl = s.PointsLog()
if len(pl) != 1 {
t.Errorf("After awarding points, points log has length %d", len(pl))
} else if (pl[0].TeamID != teamId) || (pl[0].Category != category) || (pl[0].Points != points) {
} else if (pl[0].TeamID != teamID) || (pl[0].Category != category) || (pl[0].Points != points) {
t.Errorf("Incorrect logged award %v", pl)
}
fs.Remove("initialized")
s.Fs.Remove("initialized")
s.Update()
pl = s.PointsLog()

View File

@ -1,36 +1,40 @@
package main
import (
"github.com/spf13/afero"
"time"
"github.com/spf13/afero"
)
// Theme defines a filesystem-backed ThemeProvider.
type Theme struct {
afero.Fs
}
// NewTheme returns a new Theme, backed by Fs.
func NewTheme(fs afero.Fs) *Theme {
return &Theme{
Fs: fs,
}
}
// I don't understand why I need this. The type checking system is weird here.
// Open returns a new opened file.
func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) {
f, err := t.Fs.Open(name)
if err != nil {
return nil, time.Time{}, err
}
fi, err := f.Stat()
if err != nil {
f.Close()
return nil, time.Time{}, err
}
return f, fi.ModTime(), nil
}
// Update performs housekeeping for a Theme.
func (t *Theme) Update() {
// No periodic tasks for a theme
}

View File

@ -7,18 +7,21 @@ import (
"github.com/spf13/afero"
)
func NewTestTheme() *Theme {
return NewTheme(new(afero.MemMapFs))
}
func TestTheme(t *testing.T) {
s := NewTestTheme()
filename := "/index.html"
fs := new(afero.MemMapFs)
index := "this is the index"
afero.WriteFile(fs, filename, []byte(index), 0644)
fileInfo, err := fs.Stat(filename)
afero.WriteFile(s.Fs, filename, []byte(index), 0644)
fileInfo, err := s.Fs.Stat(filename)
if err != nil {
t.Error(err)
}
s := NewTheme(fs)
if f, timestamp, err := s.Open("/index.html"); err != nil {
t.Error(err)
} else if buf, err := ioutil.ReadAll(f); err != nil {

View File

@ -3,13 +3,15 @@ package main
import (
"archive/zip"
"fmt"
"github.com/spf13/afero"
"io"
"io/ioutil"
"strings"
"time"
"github.com/spf13/afero"
)
// Zipfs defines a Zip Filesystem structure
type Zipfs struct {
f io.Closer
zf *zip.Reader
@ -164,7 +166,7 @@ func (zfs *Zipfs) Refresh() error {
return nil
}
func (zfs *Zipfs) ModTime() (time.Time) {
func (zfs *Zipfs) ModTime() time.Time {
return zfs.mtime
}

View File

@ -30,3 +30,8 @@ This pretty much set the entire design:
* It should be easy to remember in your head everything it does
* Server is also compiled
* Static type-checking helps assure no run-time errors
* Server only tracks who scored how many points at what time
* This means the scoreboard program determines rankings
* 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.
* Want to do some analysis of what puzzles take the longest to answer? It's all there.

8
go.mod
View File

@ -3,11 +3,11 @@ module github.com/dirtbags/moth
go 1.13
require (
github.com/namsral/flag v1.7.4-pre
github.com/namsral/flag v1.7.4-pre // indirect
github.com/pkg/sftp v1.11.0 // indirect
github.com/russross/blackfriday v2.0.0+incompatible // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/spf13/afero v1.2.2
golang.org/x/tools v0.0.0-20200814172026-c4923e618c08 // indirect
gopkg.in/yaml.v2 v2.3.0
github.com/spf13/afero v1.3.4
golang.org/x/tools v0.0.0-20200817190302-118ac038d721 // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
)

10
go.sum
View File

@ -6,6 +6,7 @@ github.com/namsral/flag v1.7.4-pre h1:b2ScHhoCUkbsq0d2C15Mv+VU8bl8hAXV8arnWiOHNZ
github.com/namsral/flag v1.7.4-pre/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pkg/sftp v1.11.0 h1:4Zv0OGbpkg4yNuUtH0s8rvoYxRCNyT29NVUo6pgPmxI=
github.com/pkg/sftp v1.11.0/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -16,6 +17,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5I
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/spf13/afero v1.3.4 h1:8q6vk3hthlpb2SouZcnBVKboxWQWMDNF38bwholZrJc=
github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
@ -42,9 +45,16 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20u
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200814172026-c4923e618c08 h1:sfBQLM20fzeXhOixVQirwEbuW4PGStP773EXQpsBB6E=
golang.org/x/tools v0.0.0-20200814172026-c4923e618c08/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200817023811-d00afeaade8f h1:33yHANSyO/TeglgY9rBhUpX43wtonTXoFOsMRtNB6qE=
golang.org/x/tools v0.0.0-20200817023811-d00afeaade8f/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200817190302-118ac038d721 h1:ZMR6guGpa1BJujpPXaMYw3au+XbIfCsRWe68e6KqBKo=
golang.org/x/tools v0.0.0-20200817190302-118ac038d721/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=

View File

@ -17,7 +17,7 @@ type T struct {
}
// List is a collection of award events.
type List []*T
type List []T
// Len implements sort.Interface.
func (awards List) Len() int {
@ -37,31 +37,28 @@ func (awards List) Swap(i, j int) {
}
// Parse parses a string log entry into an award.T.
func Parse(s string) (*T, error) {
func Parse(s string) (T, error) {
ret := T{}
s = strings.TrimSpace(s)
n, err := fmt.Sscanf(s, "%d %s %s %d", &ret.When, &ret.TeamID, &ret.Category, &ret.Points)
if err != nil {
return nil, err
return ret, err
} else if n != 4 {
return nil, fmt.Errorf("Malformed award string: only parsed %d fields", n)
return ret, fmt.Errorf("Malformed award string: only parsed %d fields", n)
}
return &ret, nil
return ret, nil
}
// String returns a log entry string for an award.T.
func (a *T) String() string {
func (a T) String() string {
return fmt.Sprintf("%d %s %s %d", a.When, a.TeamID, a.Category, a.Points)
}
// MarshalJSON returns the award event, encoded as a list.
func (a *T) MarshalJSON() ([]byte, error) {
if a == nil {
return []byte("null"), nil
}
func (a T) MarshalJSON() ([]byte, error) {
ao := []interface{}{
a.When,
a.TeamID,
@ -74,7 +71,7 @@ func (a *T) MarshalJSON() ([]byte, error) {
// Equal returns true if two award events represent the same award.
// Timestamps are ignored in this comparison!
func (a *T) Equal(o *T) bool {
func (a T) Equal(o T) bool {
switch {
case a.TeamID != o.TeamID:
return false

View File

@ -1,4 +1,4 @@
package main
package jsend
import (
"encoding/json"
@ -10,11 +10,17 @@ import (
// https://github.com/omniti-labs/jsend
const (
JSendSuccess = "success"
JSendFail = "fail"
JSendError = "error"
// Success is the return code indicating "All went well, and (usually) some data was returned".
Success = "success"
// Fail is the return code indicating "There was a problem with the data submitted, or some pre-condition of the API call wasn't satisfied".
Fail = "fail"
// Error is the return code indicating "An error occurred in processing the request, i.e. an exception was thrown".
Error = "error"
)
// JSONWrite writes out data as JSON, sending headers and content length
func JSONWrite(w http.ResponseWriter, data interface{}) {
respBytes, err := json.Marshal(data)
if err != nil {
@ -28,7 +34,8 @@ func JSONWrite(w http.ResponseWriter, data interface{}) {
w.Write(respBytes)
}
func JSend(w http.ResponseWriter, status string, data interface{}) {
// Send sends arbitrary data as a JSend response
func Send(w http.ResponseWriter, status string, data interface{}) {
resp := struct {
Status string `json:"status"`
Data interface{} `json:"data"`
@ -39,7 +46,8 @@ func JSend(w http.ResponseWriter, status string, data interface{}) {
JSONWrite(w, resp)
}
func JSendf(w http.ResponseWriter, status, short string, format string, a ...interface{}) {
// Sendf sends a Sprintf()-formatted string as a JSend response
func Sendf(w http.ResponseWriter, status, short string, format string, a ...interface{}) {
data := struct {
Short string `json:"short"`
Description string `json:"description"`
@ -47,5 +55,5 @@ func JSendf(w http.ResponseWriter, status, short string, format string, a ...int
data.Short = short
data.Description = fmt.Sprintf(format, a...)
JSend(w, status, data)
Send(w, status, data)
}

18
pkg/jsend/jsend_test.go Normal file
View File

@ -0,0 +1,18 @@
package jsend
import (
"net/http/httptest"
"testing"
)
func TestEverything(t *testing.T) {
w := httptest.NewRecorder()
Sendf(w, Success, "You have cows", "You have %d cows", 12)
if w.Result().StatusCode != 200 {
t.Errorf("HTTP Status code: %d", w.Result().StatusCode)
}
if w.Body.String() != `{"status":"success","data":{"short":"You have cows","description":"You have 12 cows"}}` {
t.Errorf("HTTP Body %s", w.Body.Bytes())
}
}

View File

@ -176,7 +176,7 @@ function login(e) {
let name = document.querySelector("[name=name]").value
let teamId = document.querySelector("[name=id]").value
let pide = document.querySelector("[name=pid]")
let participantId = pide?pide.value:""
let participantId = pide?pide.value:Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
fetch("register", {
method: "POST",