mirror of https://github.com/dirtbags/moth.git
Mostly documentation in Go files
This commit is contained in:
parent
a46ae95cb6
commit
2a3826e2b3
|
@ -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
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dirtbags/moth/pkg/jsend"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTPServer is a MOTH HTTP server
|
// HTTPServer is a MOTH HTTP server
|
||||||
|
@ -44,7 +46,7 @@ func (h *HTTPServer) HandleMothFunc(
|
||||||
|
|
||||||
// ServeHTTP provides the http.Handler interface
|
// ServeHTTP provides the http.Handler interface
|
||||||
func (h *HTTPServer) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
|
func (h *HTTPServer) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
|
||||||
w := MothResponseWriter{
|
w := StatusResponseWriter{
|
||||||
statusCode: new(int),
|
statusCode: new(int),
|
||||||
ResponseWriter: wOrig,
|
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
|
// StatusResponseWriter provides a ResponseWriter that remembers what the status code was
|
||||||
type MothResponseWriter struct {
|
type StatusResponseWriter struct {
|
||||||
statusCode *int
|
statusCode *int
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteHeader sends an HTTP response header with the provided status code
|
// 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.statusCode = statusCode
|
||||||
w.ResponseWriter.WriteHeader(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
|
// StateHandler returns the full JSON-encoded state of the event
|
||||||
func (h *HTTPServer) StateHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
|
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
|
// RegisterHandler handles attempts to register a team
|
||||||
func (h *HTTPServer) RegisterHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
|
func (h *HTTPServer) RegisterHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
|
||||||
teamName := req.FormValue("name")
|
teamName := req.FormValue("name")
|
||||||
if err := mh.Register(teamName); err != nil {
|
if err := mh.Register(teamName); err != nil {
|
||||||
JSendf(w, JSendFail, "not registered", err.Error())
|
jsend.Sendf(w, jsend.Fail, "not registered", err.Error())
|
||||||
} else {
|
} 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)
|
points, _ := strconv.Atoi(pointstr)
|
||||||
|
|
||||||
if err := mh.CheckAnswer(cat, points, answer); err != nil {
|
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 {
|
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
func custodian(updateInterval time.Duration, components []Component) {
|
func custodian(updateInterval time.Duration, components []Provider) {
|
||||||
update := func() {
|
update := func() {
|
||||||
for _, c := range components {
|
for _, c := range components {
|
||||||
c.Update()
|
c.Update()
|
||||||
|
@ -39,7 +39,7 @@ func main() {
|
||||||
mothballPath := flag.String(
|
mothballPath := flag.String(
|
||||||
"mothballs",
|
"mothballs",
|
||||||
"mothballs",
|
"mothballs",
|
||||||
"Path to mothballs to host",
|
"Path to mothball files",
|
||||||
)
|
)
|
||||||
refreshInterval := flag.Duration(
|
refreshInterval := flag.Duration(
|
||||||
"refresh",
|
"refresh",
|
||||||
|
@ -67,7 +67,7 @@ func main() {
|
||||||
mime.AddExtensionType(".json", "application/json")
|
mime.AddExtensionType(".json", "application/json")
|
||||||
mime.AddExtensionType(".zip", "application/zip")
|
mime.AddExtensionType(".zip", "application/zip")
|
||||||
|
|
||||||
go custodian(*refreshInterval, []Component{theme, state, puzzles})
|
go custodian(*refreshInterval, []Provider{theme, state, puzzles})
|
||||||
|
|
||||||
server := NewMothServer(puzzles, theme, state)
|
server := NewMothServer(puzzles, theme, state)
|
||||||
httpd := NewHTTPServer(*base, server)
|
httpd := NewHTTPServer(*base, server)
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEverything(t *testing.T) {
|
||||||
|
state := NewTestState()
|
||||||
|
t.Error("No test")
|
||||||
|
|
||||||
|
state.Update()
|
||||||
|
}
|
|
@ -1,20 +1,23 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/afero"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"bufio"
|
"bufio"
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Mothballs provides a collection of active mothball files (puzzle categories)
|
||||||
type Mothballs struct {
|
type Mothballs struct {
|
||||||
categories map[string]*Zipfs
|
categories map[string]*Zipfs
|
||||||
afero.Fs
|
afero.Fs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewMothballs returns a new Mothballs structure backed by the provided directory
|
||||||
func NewMothballs(fs afero.Fs) *Mothballs {
|
func NewMothballs(fs afero.Fs) *Mothballs {
|
||||||
return &Mothballs{
|
return &Mothballs{
|
||||||
Fs: fs,
|
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) {
|
func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) {
|
||||||
mb, ok := m.categories[cat]
|
mb, ok := m.categories[cat]
|
||||||
if ! ok {
|
if !ok {
|
||||||
return nil, time.Time{}, fmt.Errorf("No such category: %s", cat)
|
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
|
return f, mb.ModTime(), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inventory returns the list of current categories
|
||||||
func (m *Mothballs) Inventory() []Category {
|
func (m *Mothballs) Inventory() []Category {
|
||||||
categories := make([]Category, 0, 20)
|
categories := make([]Category, 0, 20)
|
||||||
for cat, zfs := range m.categories {
|
for cat, zfs := range m.categories {
|
||||||
|
@ -55,18 +60,19 @@ func (m *Mothballs) Inventory() []Category {
|
||||||
return categories
|
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 {
|
func (m *Mothballs) CheckAnswer(cat string, points int, answer string) error {
|
||||||
zfs, ok := m.categories[cat]
|
zfs, ok := m.categories[cat]
|
||||||
if ! ok {
|
if !ok {
|
||||||
return fmt.Errorf("No such category: %s", cat)
|
return fmt.Errorf("No such category: %s", cat)
|
||||||
}
|
}
|
||||||
|
|
||||||
af, err := zfs.Open("answers.txt")
|
af, err := zfs.Open("answers.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("No answers.txt file")
|
return fmt.Errorf("No answers.txt file")
|
||||||
}
|
}
|
||||||
defer af.Close()
|
defer af.Close()
|
||||||
|
|
||||||
needle := fmt.Sprintf("%d %s", points, answer)
|
needle := fmt.Sprintf("%d %s", points, answer)
|
||||||
scanner := bufio.NewScanner(af)
|
scanner := bufio.NewScanner(af)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
|
@ -78,6 +84,8 @@ func (m *Mothballs) CheckAnswer(cat string, points int, answer string) error {
|
||||||
return fmt.Errorf("Invalid answer")
|
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() {
|
func (m *Mothballs) Update() {
|
||||||
// Any new categories?
|
// Any new categories?
|
||||||
files, err := afero.ReadDir(m.Fs, "/")
|
files, err := afero.ReadDir(m.Fs, "/")
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMothballs(t *testing.T) {
|
||||||
|
t.Error("moo")
|
||||||
|
}
|
|
@ -5,60 +5,71 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dirtbags/moth/pkg/award"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Category represents a puzzle category.
|
||||||
type Category struct {
|
type Category struct {
|
||||||
Name string
|
Name string
|
||||||
Puzzles []int
|
Puzzles []int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadSeekCloser defines a struct that can read, seek, and close.
|
||||||
type ReadSeekCloser interface {
|
type ReadSeekCloser interface {
|
||||||
io.Reader
|
io.Reader
|
||||||
io.Seeker
|
io.Seeker
|
||||||
io.Closer
|
io.Closer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StateExport is given to clients requesting the current state.
|
||||||
type StateExport struct {
|
type StateExport struct {
|
||||||
Config struct {
|
Config struct {
|
||||||
Devel bool
|
Devel bool
|
||||||
}
|
}
|
||||||
Messages string
|
Messages string
|
||||||
TeamNames map[string]string
|
TeamNames map[string]string
|
||||||
PointsLog []Award
|
PointsLog award.List
|
||||||
Puzzles map[string][]int
|
Puzzles map[string][]int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PuzzleProvider defines what's required to provide puzzles.
|
||||||
type PuzzleProvider interface {
|
type PuzzleProvider interface {
|
||||||
Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error)
|
Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error)
|
||||||
Inventory() []Category
|
Inventory() []Category
|
||||||
CheckAnswer(cat string, points int, answer string) error
|
CheckAnswer(cat string, points int, answer string) error
|
||||||
Component
|
Provider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThemeProvider defines what's required to provide a theme.
|
||||||
type ThemeProvider interface {
|
type ThemeProvider interface {
|
||||||
Open(path string) (ReadSeekCloser, time.Time, error)
|
Open(path string) (ReadSeekCloser, time.Time, error)
|
||||||
Component
|
Provider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StateProvider defines what's required to provide MOTH state.
|
||||||
type StateProvider interface {
|
type StateProvider interface {
|
||||||
Messages() string
|
Messages() string
|
||||||
PointsLog() []*Award
|
PointsLog() award.List
|
||||||
TeamName(teamId string) (string, error)
|
TeamName(teamID string) (string, error)
|
||||||
SetTeamName(teamId, teamName string) error
|
SetTeamName(teamID, teamName string) error
|
||||||
AwardPoints(teamId string, cat string, points int) error
|
AwardPoints(teamID string, cat string, points int) error
|
||||||
Component
|
Provider
|
||||||
}
|
}
|
||||||
|
|
||||||
type Component interface {
|
// Provider defines providers that can be updated.
|
||||||
|
type Provider interface {
|
||||||
Update()
|
Update()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MothServer gathers together the providers that make up a MOTH server.
|
||||||
type MothServer struct {
|
type MothServer struct {
|
||||||
Puzzles PuzzleProvider
|
Puzzles PuzzleProvider
|
||||||
Theme ThemeProvider
|
Theme ThemeProvider
|
||||||
State StateProvider
|
State StateProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewMothServer returns a new MothServer.
|
||||||
func NewMothServer(puzzles PuzzleProvider, theme ThemeProvider, state StateProvider) *MothServer {
|
func NewMothServer(puzzles PuzzleProvider, theme ThemeProvider, state StateProvider) *MothServer {
|
||||||
return &MothServer{
|
return &MothServer{
|
||||||
Puzzles: puzzles,
|
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{
|
return MothRequestHandler{
|
||||||
MothServer: s,
|
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 {
|
type MothRequestHandler struct {
|
||||||
*MothServer
|
*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) {
|
func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (ReadSeekCloser, time.Time, error) {
|
||||||
export := mh.ExportAllState()
|
export := mh.ExportState()
|
||||||
fmt.Println(export.Puzzles)
|
fmt.Println(export.Puzzles)
|
||||||
for _, p := range export.Puzzles[cat] {
|
for _, p := range export.Puzzles[cat] {
|
||||||
fmt.Println(points, p)
|
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")
|
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) {
|
func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, error) {
|
||||||
return mh.Theme.Open(path)
|
return mh.Theme.Open(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register associates a team name with a team ID.
|
||||||
func (mh *MothRequestHandler) Register(teamName string) error {
|
func (mh *MothRequestHandler) Register(teamName string) error {
|
||||||
// XXX: Should we just return success if the team is already registered?
|
// XXX: Should we just return success if the team is already registered?
|
||||||
// XXX: Should this function be renamed to Login?
|
// XXX: Should this function be renamed to Login?
|
||||||
if teamName == "" {
|
if teamName == "" {
|
||||||
return fmt.Errorf("Empty team name")
|
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 {
|
func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string) error {
|
||||||
if err := mh.Puzzles.CheckAnswer(cat, points, answer); err != nil {
|
if err := mh.Puzzles.CheckAnswer(cat, points, answer); err != nil {
|
||||||
return err
|
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 err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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{}
|
export := StateExport{}
|
||||||
|
|
||||||
teamName, _ := mh.State.TeamName(mh.teamId)
|
teamName, _ := mh.State.TeamName(mh.teamID)
|
||||||
|
|
||||||
export.Messages = mh.State.Messages()
|
export.Messages = mh.State.Messages()
|
||||||
export.TeamNames = map[string]string{"self": teamName}
|
export.TeamNames = map[string]string{"self": teamName}
|
||||||
|
|
||||||
// Anonymize team IDs in points log, and write out team names
|
// Anonymize team IDs in points log, and write out team names
|
||||||
pointsLog := mh.State.PointsLog()
|
pointsLog := mh.State.PointsLog()
|
||||||
exportIds := map[string]string{mh.teamId: "self"}
|
exportIDs := map[string]string{mh.teamID: "self"}
|
||||||
maxSolved := map[string]int{}
|
maxSolved := map[string]int{}
|
||||||
export.PointsLog = make([]Award, len(pointsLog))
|
export.PointsLog = make(award.List, len(pointsLog))
|
||||||
for logno, award := range pointsLog {
|
for logno, awd := range pointsLog {
|
||||||
exportAward := *award
|
if id, ok := exportIDs[awd.TeamID]; ok {
|
||||||
if id, ok := exportIds[award.TeamID]; ok {
|
awd.TeamID = id
|
||||||
exportAward.TeamID = id
|
|
||||||
} else {
|
} else {
|
||||||
exportId := strconv.Itoa(logno)
|
exportID := strconv.Itoa(logno)
|
||||||
name, _ := mh.State.TeamName(award.TeamID)
|
name, _ := mh.State.TeamName(awd.TeamID)
|
||||||
exportAward.TeamID = exportId
|
awd.TeamID = exportID
|
||||||
exportIds[award.TeamID] = exportAward.TeamID
|
exportIDs[awd.TeamID] = awd.TeamID
|
||||||
export.TeamNames[exportId] = name
|
export.TeamNames[exportID] = name
|
||||||
}
|
}
|
||||||
export.PointsLog[logno] = exportAward
|
export.PointsLog[logno] = awd
|
||||||
|
|
||||||
// Record the highest-value unlocked puzzle in each category
|
// Record the highest-value unlocked puzzle in each category
|
||||||
if award.Points > maxSolved[award.Category] {
|
if awd.Points > maxSolved[awd.Category] {
|
||||||
maxSolved[award.Category] = award.Points
|
maxSolved[awd.Category] = awd.Points
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export.Puzzles = make(map[string][]int)
|
export.Puzzles = make(map[string][]int)
|
||||||
for _, category := range mh.Puzzles.Inventory() {
|
if _, ok := export.TeamNames["self"]; ok {
|
||||||
// Append sentry (end of puzzles)
|
// We used to hand this out to everyone,
|
||||||
allPuzzles := append(category.Puzzles, 0)
|
// 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))
|
max := maxSolved[category.Name]
|
||||||
for i, val := range allPuzzles {
|
|
||||||
puzzles = allPuzzles[:i+1]
|
puzzles := make([]int, 0, len(allPuzzles))
|
||||||
if val > max {
|
for i, val := range allPuzzles {
|
||||||
break
|
puzzles = allPuzzles[:i+1]
|
||||||
|
if val > max {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
export.Puzzles[category.Name] = puzzles
|
||||||
}
|
}
|
||||||
export.Puzzles[category.Name] = puzzles
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &export
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -10,12 +10,16 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dirtbags/moth/pkg/award"
|
||||||
"github.com/spf13/afero"
|
"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="
|
const DistinguishableChars = "234678abcdefhikmnpqrtwxyz="
|
||||||
|
|
||||||
|
// 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.
|
||||||
// The only thing State methods need to know is the path to the state directory.
|
// The only thing State methods need to know is the path to the state directory.
|
||||||
type State struct {
|
type State struct {
|
||||||
|
@ -23,6 +27,7 @@ type State struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewState returns a new State struct backed by the given Fs
|
||||||
func NewState(fs afero.Fs) *State {
|
func NewState(fs afero.Fs) *State {
|
||||||
return &State{
|
return &State{
|
||||||
Fs: fs,
|
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() {
|
func (s *State) UpdateEnabled() {
|
||||||
if _, err := s.Stat("enabled"); os.IsNotExist(err) {
|
if _, err := s.Stat("enabled"); os.IsNotExist(err) {
|
||||||
s.Enabled = false
|
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) {
|
func (s *State) TeamName(teamID string) (string, error) {
|
||||||
// XXX: directory traversal
|
// XXX: directory traversal
|
||||||
teamFile := filepath.Join("teams", teamID)
|
teamFile := filepath.Join("teams", teamID)
|
||||||
|
@ -97,35 +102,35 @@ func (s *State) TeamName(teamID string) (string, error) {
|
||||||
return teamName, nil
|
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 {
|
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")
|
return fmt.Errorf("Team IDs file does not exist")
|
||||||
} else {
|
}
|
||||||
found := false
|
found := false
|
||||||
scanner := bufio.NewScanner(f)
|
scanner := bufio.NewScanner(f)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
if scanner.Text() == teamID {
|
if scanner.Text() == teamID {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
if !found {
|
|
||||||
return fmt.Errorf("Team ID not found in list of valid Team IDs")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
f.Close()
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("Team ID not found in list of valid Team IDs")
|
||||||
|
}
|
||||||
|
|
||||||
teamFile := filepath.Join("teams", teamID)
|
teamFile := filepath.Join("teams", teamID)
|
||||||
err := afero.WriteFile(s, teamFile, []byte(teamName), os.ModeExclusive|0644)
|
if err := afero.WriteFile(s, teamFile, []byte(teamName), os.ModeExclusive|0644); os.IsExist(err) {
|
||||||
if os.IsExist(err) {
|
|
||||||
return fmt.Errorf("Team ID is already registered")
|
return fmt.Errorf("Team ID is already registered")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve the current points log
|
// PointsLog retrieves the current points log.
|
||||||
func (s *State) PointsLog() []*Award {
|
func (s *State) PointsLog() award.List {
|
||||||
f, err := s.Open("points.log")
|
f, err := s.Open("points.log")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
|
@ -133,11 +138,11 @@ func (s *State) PointsLog() []*Award {
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
pointsLog := make([]*Award, 0, 200)
|
pointsLog := make(award.List, 0, 200)
|
||||||
scanner := bufio.NewScanner(f)
|
scanner := bufio.NewScanner(f)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
cur, err := ParseAward(line)
|
cur, err := award.Parse(line)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Skipping malformed award line %s: %s", line, err)
|
log.Printf("Skipping malformed award line %s: %s", line, err)
|
||||||
continue
|
continue
|
||||||
|
@ -147,7 +152,7 @@ func (s *State) PointsLog() []*Award {
|
||||||
return pointsLog
|
return pointsLog
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve current messages
|
// Messages retrieves the current messages.
|
||||||
func (s *State) Messages() string {
|
func (s *State) Messages() string {
|
||||||
bMessages, _ := afero.ReadFile(s, "messages.html")
|
bMessages, _ := afero.ReadFile(s, "messages.html")
|
||||||
return string(bMessages)
|
return string(bMessages)
|
||||||
|
@ -159,7 +164,7 @@ func (s *State) Messages() string {
|
||||||
// It's just a courtesy to the user.
|
// It's just a courtesy to the user.
|
||||||
// The update task makes sure we never have duplicate points in the log.
|
// The update task makes sure we never have duplicate points in the log.
|
||||||
func (s *State) AwardPoints(teamID, category string, points int) error {
|
func (s *State) AwardPoints(teamID, category string, points int) error {
|
||||||
a := Award{
|
a := award.T{
|
||||||
When: time.Now().Unix(),
|
When: time.Now().Unix(),
|
||||||
TeamID: teamID,
|
TeamID: teamID,
|
||||||
Category: category,
|
Category: category,
|
||||||
|
@ -172,7 +177,7 @@ func (s *State) AwardPoints(teamID, category string, points int) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, e := range s.PointsLog() {
|
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")
|
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)
|
log.Print("Opening new points: ", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
award, err := ParseAward(string(awardstr))
|
awd, err := award.Parse(string(awardstr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print("Can't parse award file ", filename, ": ", err)
|
log.Print("Can't parse award file ", filename, ": ", err)
|
||||||
continue
|
continue
|
||||||
|
@ -216,23 +221,23 @@ func (s *State) collectPoints() {
|
||||||
|
|
||||||
duplicate := false
|
duplicate := false
|
||||||
for _, e := range s.PointsLog() {
|
for _, e := range s.PointsLog() {
|
||||||
if award.Same(e) {
|
if awd.Equal(e) {
|
||||||
duplicate = true
|
duplicate = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if duplicate {
|
if duplicate {
|
||||||
log.Print("Skipping duplicate points: ", award.String())
|
log.Print("Skipping duplicate points: ", awd.String())
|
||||||
} else {
|
} 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)
|
logf, err := s.OpenFile("points.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print("Can't append to points log: ", err)
|
log.Print("Can't append to points log: ", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintln(logf, award.String())
|
fmt.Fprintln(logf, awd.String())
|
||||||
logf.Close()
|
logf.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -268,7 +273,7 @@ func (s *State) maybeInitialize() {
|
||||||
// Preseed available team ids if file doesn't exist
|
// 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 {
|
if f, err := s.OpenFile("teamids.txt", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
|
||||||
id := make([]byte, 8)
|
id := make([]byte, 8)
|
||||||
for i := 0; i < 100; i += 1 {
|
for i := 0; i < 100; i++ {
|
||||||
for i := range id {
|
for i := range id {
|
||||||
char := rand.Intn(len(DistinguishableChars))
|
char := rand.Intn(len(DistinguishableChars))
|
||||||
id[i] = DistinguishableChars[char]
|
id[i] = DistinguishableChars[char]
|
||||||
|
@ -317,6 +322,7 @@ func (s *State) maybeInitialize() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update performs housekeeping on a State struct.
|
||||||
func (s *State) Update() {
|
func (s *State) Update() {
|
||||||
s.maybeInitialize()
|
s.maybeInitialize()
|
||||||
s.UpdateEnabled()
|
s.UpdateEnabled()
|
||||||
|
|
|
@ -8,19 +8,22 @@ import (
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func NewTestState() *State {
|
||||||
|
s := NewState(new(afero.MemMapFs))
|
||||||
|
s.Update()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func TestState(t *testing.T) {
|
func TestState(t *testing.T) {
|
||||||
fs := new(afero.MemMapFs)
|
s := NewTestState()
|
||||||
|
|
||||||
mustExist := func(path string) {
|
mustExist := func(path string) {
|
||||||
_, err := fs.Stat(path)
|
_, err := s.Fs.Stat(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
t.Errorf("File %s does not exist", path)
|
t.Errorf("File %s does not exist", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s := NewState(fs)
|
|
||||||
s.Update()
|
|
||||||
|
|
||||||
pl := s.PointsLog()
|
pl := s.PointsLog()
|
||||||
if len(pl) != 0 {
|
if len(pl) != 0 {
|
||||||
t.Errorf("Empty points log is not empty")
|
t.Errorf("Empty points log is not empty")
|
||||||
|
@ -30,38 +33,38 @@ func TestState(t *testing.T) {
|
||||||
mustExist("enabled")
|
mustExist("enabled")
|
||||||
mustExist("hours")
|
mustExist("hours")
|
||||||
|
|
||||||
teamidsBuf, err := afero.ReadFile(fs, "teamids.txt")
|
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Reading teamids.txt: %v", err)
|
t.Errorf("Reading teamids.txt: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
teamids := bytes.Split(teamidsBuf, []byte("\n"))
|
teamIDs := bytes.Split(teamIDsBuf, []byte("\n"))
|
||||||
if (len(teamids) != 101) || (len(teamids[100]) > 0) {
|
if (len(teamIDs) != 101) || (len(teamIDs[100]) > 0) {
|
||||||
t.Errorf("There weren't 100 teamids, there were %d", len(teamids))
|
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 {
|
if err := s.SetTeamName("bad team ID", "bad team name"); err == nil {
|
||||||
t.Errorf("Setting bad team ID didn't raise an error")
|
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)
|
t.Errorf("Setting team name: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
category := "poot"
|
category := "poot"
|
||||||
points := 3928
|
points := 3928
|
||||||
s.AwardPoints(teamId, category, points)
|
s.AwardPoints(teamID, category, points)
|
||||||
s.Update()
|
s.Update()
|
||||||
|
|
||||||
pl = s.PointsLog()
|
pl = s.PointsLog()
|
||||||
if len(pl) != 1 {
|
if len(pl) != 1 {
|
||||||
t.Errorf("After awarding points, points log has length %d", len(pl))
|
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)
|
t.Errorf("Incorrect logged award %v", pl)
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.Remove("initialized")
|
s.Fs.Remove("initialized")
|
||||||
s.Update()
|
s.Update()
|
||||||
|
|
||||||
pl = s.PointsLog()
|
pl = s.PointsLog()
|
||||||
|
|
|
@ -1,36 +1,40 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/afero"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Theme defines a filesystem-backed ThemeProvider.
|
||||||
type Theme struct {
|
type Theme struct {
|
||||||
afero.Fs
|
afero.Fs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTheme returns a new Theme, backed by Fs.
|
||||||
func NewTheme(fs afero.Fs) *Theme {
|
func NewTheme(fs afero.Fs) *Theme {
|
||||||
return &Theme{
|
return &Theme{
|
||||||
Fs: fs,
|
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) {
|
func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) {
|
||||||
f, err := t.Fs.Open(name)
|
f, err := t.Fs.Open(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, time.Time{}, err
|
return nil, time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fi, err := f.Stat()
|
fi, err := f.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
return nil, time.Time{}, err
|
return nil, time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return f, fi.ModTime(), nil
|
return f, fi.ModTime(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update performs housekeeping for a Theme.
|
||||||
func (t *Theme) Update() {
|
func (t *Theme) Update() {
|
||||||
// No periodic tasks for a theme
|
// No periodic tasks for a theme
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,18 +7,21 @@ import (
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func NewTestTheme() *Theme {
|
||||||
|
return NewTheme(new(afero.MemMapFs))
|
||||||
|
}
|
||||||
|
|
||||||
func TestTheme(t *testing.T) {
|
func TestTheme(t *testing.T) {
|
||||||
|
s := NewTestTheme()
|
||||||
|
|
||||||
filename := "/index.html"
|
filename := "/index.html"
|
||||||
fs := new(afero.MemMapFs)
|
|
||||||
index := "this is the index"
|
index := "this is the index"
|
||||||
afero.WriteFile(fs, filename, []byte(index), 0644)
|
afero.WriteFile(s.Fs, filename, []byte(index), 0644)
|
||||||
fileInfo, err := fs.Stat(filename)
|
fileInfo, err := s.Fs.Stat(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s := NewTheme(fs)
|
|
||||||
|
|
||||||
if f, timestamp, err := s.Open("/index.html"); err != nil {
|
if f, timestamp, err := s.Open("/index.html"); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
} else if buf, err := ioutil.ReadAll(f); err != nil {
|
} else if buf, err := ioutil.ReadAll(f); err != nil {
|
||||||
|
|
|
@ -3,13 +3,15 @@ package main
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/afero"
|
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Zipfs defines a Zip Filesystem structure
|
||||||
type Zipfs struct {
|
type Zipfs struct {
|
||||||
f io.Closer
|
f io.Closer
|
||||||
zf *zip.Reader
|
zf *zip.Reader
|
||||||
|
@ -164,7 +166,7 @@ func (zfs *Zipfs) Refresh() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (zfs *Zipfs) ModTime() (time.Time) {
|
func (zfs *Zipfs) ModTime() time.Time {
|
||||||
return zfs.mtime
|
return zfs.mtime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,3 +30,8 @@ This pretty much set the entire design:
|
||||||
* It should be easy to remember in your head everything it does
|
* It should be easy to remember in your head everything it does
|
||||||
* Server is also compiled
|
* Server is also compiled
|
||||||
* Static type-checking helps assure no run-time errors
|
* 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
8
go.mod
|
@ -3,11 +3,11 @@ module github.com/dirtbags/moth
|
||||||
go 1.13
|
go 1.13
|
||||||
|
|
||||||
require (
|
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/pkg/sftp v1.11.0 // indirect
|
||||||
github.com/russross/blackfriday v2.0.0+incompatible // indirect
|
github.com/russross/blackfriday v2.0.0+incompatible // indirect
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
github.com/spf13/afero v1.2.2
|
github.com/spf13/afero v1.3.4
|
||||||
golang.org/x/tools v0.0.0-20200814172026-c4923e618c08 // indirect
|
golang.org/x/tools v0.0.0-20200817190302-118ac038d721 // indirect
|
||||||
gopkg.in/yaml.v2 v2.3.0
|
gopkg.in/yaml.v2 v2.3.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
10
go.sum
10
go.sum
|
@ -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/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 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:4Zv0OGbpkg4yNuUtH0s8rvoYxRCNyT29NVUo6pgPmxI=
|
||||||
github.com/pkg/sftp v1.11.0/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
github.com/pkg/sftp v1.11.0/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/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 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
|
||||||
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
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 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
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/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 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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-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 h1:sfBQLM20fzeXhOixVQirwEbuW4PGStP773EXQpsBB6E=
|
||||||
golang.org/x/tools v0.0.0-20200814172026-c4923e618c08/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
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-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
|
|
|
@ -17,7 +17,7 @@ type T struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// List is a collection of award events.
|
// List is a collection of award events.
|
||||||
type List []*T
|
type List []T
|
||||||
|
|
||||||
// Len implements sort.Interface.
|
// Len implements sort.Interface.
|
||||||
func (awards List) Len() int {
|
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.
|
// Parse parses a string log entry into an award.T.
|
||||||
func Parse(s string) (*T, error) {
|
func Parse(s string) (T, error) {
|
||||||
ret := T{}
|
ret := T{}
|
||||||
|
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
|
|
||||||
n, err := fmt.Sscanf(s, "%d %s %s %d", &ret.When, &ret.TeamID, &ret.Category, &ret.Points)
|
n, err := fmt.Sscanf(s, "%d %s %s %d", &ret.When, &ret.TeamID, &ret.Category, &ret.Points)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return ret, err
|
||||||
} else if n != 4 {
|
} 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.
|
// 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)
|
return fmt.Sprintf("%d %s %s %d", a.When, a.TeamID, a.Category, a.Points)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON returns the award event, encoded as a list.
|
// MarshalJSON returns the award event, encoded as a list.
|
||||||
func (a *T) MarshalJSON() ([]byte, error) {
|
func (a T) MarshalJSON() ([]byte, error) {
|
||||||
if a == nil {
|
|
||||||
return []byte("null"), nil
|
|
||||||
}
|
|
||||||
ao := []interface{}{
|
ao := []interface{}{
|
||||||
a.When,
|
a.When,
|
||||||
a.TeamID,
|
a.TeamID,
|
||||||
|
@ -74,7 +71,7 @@ func (a *T) MarshalJSON() ([]byte, error) {
|
||||||
|
|
||||||
// Equal returns true if two award events represent the same award.
|
// Equal returns true if two award events represent the same award.
|
||||||
// Timestamps are ignored in this comparison!
|
// Timestamps are ignored in this comparison!
|
||||||
func (a *T) Equal(o *T) bool {
|
func (a T) Equal(o T) bool {
|
||||||
switch {
|
switch {
|
||||||
case a.TeamID != o.TeamID:
|
case a.TeamID != o.TeamID:
|
||||||
return false
|
return false
|
|
@ -1,4 +1,4 @@
|
||||||
package main
|
package jsend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
@ -10,11 +10,17 @@ import (
|
||||||
// https://github.com/omniti-labs/jsend
|
// https://github.com/omniti-labs/jsend
|
||||||
|
|
||||||
const (
|
const (
|
||||||
JSendSuccess = "success"
|
// Success is the return code indicating "All went well, and (usually) some data was returned".
|
||||||
JSendFail = "fail"
|
Success = "success"
|
||||||
JSendError = "error"
|
|
||||||
|
// 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{}) {
|
func JSONWrite(w http.ResponseWriter, data interface{}) {
|
||||||
respBytes, err := json.Marshal(data)
|
respBytes, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -28,7 +34,8 @@ func JSONWrite(w http.ResponseWriter, data interface{}) {
|
||||||
w.Write(respBytes)
|
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 {
|
resp := struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Data interface{} `json:"data"`
|
Data interface{} `json:"data"`
|
||||||
|
@ -39,7 +46,8 @@ func JSend(w http.ResponseWriter, status string, data interface{}) {
|
||||||
JSONWrite(w, resp)
|
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 {
|
data := struct {
|
||||||
Short string `json:"short"`
|
Short string `json:"short"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
@ -47,5 +55,5 @@ func JSendf(w http.ResponseWriter, status, short string, format string, a ...int
|
||||||
data.Short = short
|
data.Short = short
|
||||||
data.Description = fmt.Sprintf(format, a...)
|
data.Description = fmt.Sprintf(format, a...)
|
||||||
|
|
||||||
JSend(w, status, data)
|
Send(w, status, data)
|
||||||
}
|
}
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -176,7 +176,7 @@ function login(e) {
|
||||||
let name = document.querySelector("[name=name]").value
|
let name = document.querySelector("[name=name]").value
|
||||||
let teamId = document.querySelector("[name=id]").value
|
let teamId = document.querySelector("[name=id]").value
|
||||||
let pide = document.querySelector("[name=pid]")
|
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", {
|
fetch("register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
Loading…
Reference in New Issue