From f1f6140eea7c9e3fdb655d932dc8c004d73d5947 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 8 Sep 2020 17:49:02 -0600 Subject: [PATCH] Server can now be a devel server --- cmd/mothd/httpd_test.go | 4 +- cmd/mothd/main.go | 24 +++- cmd/mothd/mothballs.go | 10 +- cmd/mothd/mothballs_test.go | 8 +- .../{puzzlecmd.go => providercommand.go} | 25 ++-- ...zlecmd_test.go => providercommand_test.go} | 10 +- cmd/mothd/server.go | 110 +++++++++++------- cmd/mothd/server_test.go | 2 +- cmd/mothd/state/enabled | 1 + cmd/mothd/state/event.log | 0 cmd/mothd/state/hours | 11 ++ cmd/mothd/state/initialized | 3 + cmd/mothd/state/messages.html | 1 + cmd/mothd/state/points.log | 0 cmd/mothd/state/teamids.txt | 100 ++++++++++++++++ cmd/mothd/testdata/cat0/1/puzzle.md | 3 + cmd/mothd/transpiler.go | 75 ++++++++++++ cmd/mothd/transpiler_test.go | 19 +++ cmd/transpile/main.go | 29 ++--- cmd/transpile/main_test.go | 3 +- {cmd => pkg}/transpile/basepath.go | 2 +- {cmd => pkg}/transpile/category.go | 30 +++-- {cmd => pkg}/transpile/category_test.go | 2 +- pkg/transpile/common_test.go | 53 +++++++++ pkg/transpile/inventory.go | 36 ++++++ pkg/transpile/inventory_test.go | 16 +++ {cmd => pkg}/transpile/mothball.go | 2 +- {cmd => pkg}/transpile/mothball_test.go | 2 +- {cmd => pkg}/transpile/puzzle.go | 30 +++-- {cmd => pkg}/transpile/puzzle_test.go | 2 +- .../transpile/testdata/generated/mkcategory | 0 .../transpile/testdata/static/1/puzzle.md | 0 .../transpile/testdata/static/2/moo.js | 0 .../transpile/testdata/static/2/moo.txt | 0 .../transpile/testdata/static/2/puzzle.md | 0 .../transpile/testdata/static/3/mkpuzzle | 0 36 files changed, 492 insertions(+), 121 deletions(-) rename cmd/mothd/{puzzlecmd.go => providercommand.go} (79%) rename cmd/mothd/{puzzlecmd_test.go => providercommand_test.go} (85%) create mode 100644 cmd/mothd/state/enabled create mode 100644 cmd/mothd/state/event.log create mode 100644 cmd/mothd/state/hours create mode 100644 cmd/mothd/state/initialized create mode 100644 cmd/mothd/state/messages.html create mode 100644 cmd/mothd/state/points.log create mode 100644 cmd/mothd/state/teamids.txt create mode 100644 cmd/mothd/testdata/cat0/1/puzzle.md create mode 100644 cmd/mothd/transpiler.go create mode 100644 cmd/mothd/transpiler_test.go rename {cmd => pkg}/transpile/basepath.go (98%) rename {cmd => pkg}/transpile/category.go (85%) rename {cmd => pkg}/transpile/category_test.go (99%) create mode 100644 pkg/transpile/common_test.go create mode 100644 pkg/transpile/inventory.go create mode 100644 pkg/transpile/inventory_test.go rename {cmd => pkg}/transpile/mothball.go (98%) rename {cmd => pkg}/transpile/mothball_test.go (97%) rename {cmd => pkg}/transpile/puzzle.go (93%) rename {cmd => pkg}/transpile/puzzle_test.go (99%) rename {cmd => pkg}/transpile/testdata/generated/mkcategory (100%) rename {cmd => pkg}/transpile/testdata/static/1/puzzle.md (100%) rename {cmd => pkg}/transpile/testdata/static/2/moo.js (100%) rename {cmd => pkg}/transpile/testdata/static/2/moo.txt (100%) rename {cmd => pkg}/transpile/testdata/static/2/puzzle.md (100%) rename {cmd => pkg}/transpile/testdata/static/3/mkpuzzle (100%) diff --git a/cmd/mothd/httpd_test.go b/cmd/mothd/httpd_test.go index 30f7396..70be3c7 100644 --- a/cmd/mothd/httpd_test.go +++ b/cmd/mothd/httpd_test.go @@ -94,7 +94,7 @@ func TestHttpd(t *testing.T) { if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "moo"}); r.Result().StatusCode != 200 { t.Error(r.Result()) - } else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"Invalid answer"}}` { + } else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"Incorrect answer"}}` { t.Error("Unexpected body", r.Body.String()) } @@ -119,7 +119,7 @@ func TestHttpd(t *testing.T) { if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 { t.Error(r.Result()) - } else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"Points already awarded to this team in this category"}}` { + } else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"Error awarding points: Points already awarded to this team in this category"}}` { t.Error("Unexpected body", r.Body.String()) } } diff --git a/cmd/mothd/main.go b/cmd/mothd/main.go index 5aa59ee..f457edf 100644 --- a/cmd/mothd/main.go +++ b/cmd/mothd/main.go @@ -24,6 +24,11 @@ func main() { "mothballs", "Path to mothball files", ) + puzzlePath := flag.String( + "puzzles", + "", + "Path to puzzles tree; if specified, enables development mode", + ) refreshInterval := flag.Duration( "refresh", 2*time.Second, @@ -41,9 +46,18 @@ func main() { ) flag.Parse() - theme := NewTheme(afero.NewBasePathFs(afero.NewOsFs(), *themePath)) - state := NewState(afero.NewBasePathFs(afero.NewOsFs(), *statePath)) - puzzles := NewMothballs(afero.NewBasePathFs(afero.NewOsFs(), *mothballPath)) + osfs := afero.NewOsFs() + theme := NewTheme(afero.NewBasePathFs(osfs, *themePath)) + state := NewState(afero.NewBasePathFs(osfs, *statePath)) + + config := Configuration{} + + var provider PuzzleProvider + provider = NewMothballs(afero.NewBasePathFs(osfs, *mothballPath)) + if *puzzlePath != "" { + provider = NewTranspilerProvider(afero.NewBasePathFs(osfs, *puzzlePath)) + config.Devel = true + } // Add some MIME extensions // Doing this avoids decompressing a mothball entry twice per request @@ -52,9 +66,9 @@ func main() { go theme.Maintain(*refreshInterval) go state.Maintain(*refreshInterval) - go puzzles.Maintain(*refreshInterval) + go provider.Maintain(*refreshInterval) - server := NewMothServer(puzzles, theme, state) + server := NewMothServer(config, theme, state, provider) httpd := NewHTTPServer(*base, server) httpd.Run(*bindStr) diff --git a/cmd/mothd/mothballs.go b/cmd/mothd/mothballs.go index 2f6f07e..e78e3c0 100644 --- a/cmd/mothd/mothballs.go +++ b/cmd/mothd/mothballs.go @@ -88,15 +88,15 @@ func (m *Mothballs) Inventory() []Category { } // 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) (bool, error) { zfs, ok := m.getCat(cat) if !ok { - return fmt.Errorf("No such category: %s", cat) + return false, fmt.Errorf("No such category: %s", cat) } af, err := zfs.Open("answers.txt") if err != nil { - return fmt.Errorf("No answers.txt file") + return false, fmt.Errorf("No answers.txt file") } defer af.Close() @@ -104,11 +104,11 @@ func (m *Mothballs) CheckAnswer(cat string, points int, answer string) error { scanner := bufio.NewScanner(af) for scanner.Scan() { if scanner.Text() == needle { - return nil + return true, nil } } - return fmt.Errorf("Invalid answer") + return false, nil } // refresh refreshes internal state. diff --git a/cmd/mothd/mothballs_test.go b/cmd/mothd/mothballs_test.go index 565d7ac..e32ca17 100644 --- a/cmd/mothd/mothballs_test.go +++ b/cmd/mothd/mothballs_test.go @@ -81,16 +81,16 @@ func TestMothballs(t *testing.T) { t.Error("This file shouldn't exist") } - if err := m.CheckAnswer("pategory", 1, "answer"); err == nil { + if ok, _ := m.CheckAnswer("pategory", 1, "answer"); ok { t.Error("Wrong answer marked right") } - if err := m.CheckAnswer("pategory", 1, "answer123"); err != nil { + if _, err := m.CheckAnswer("pategory", 1, "answer123"); err != nil { t.Error("Right answer marked wrong", err) } - if err := m.CheckAnswer("pategory", 1, "answer456"); err != nil { + if _, err := m.CheckAnswer("pategory", 1, "answer456"); err != nil { t.Error("Right answer marked wrong", err) } - if err := m.CheckAnswer("nealegory", 1, "moo"); err == nil { + if ok, err := m.CheckAnswer("nealegory", 1, "moo"); ok { t.Error("Checking answer in non-existent category should fail") } else if err.Error() != "No such category: nealegory" { t.Error("Wrong error message") diff --git a/cmd/mothd/puzzlecmd.go b/cmd/mothd/providercommand.go similarity index 79% rename from cmd/mothd/puzzlecmd.go rename to cmd/mothd/providercommand.go index e622a20..1af9766 100644 --- a/cmd/mothd/puzzlecmd.go +++ b/cmd/mothd/providercommand.go @@ -4,7 +4,6 @@ package main import ( "bytes" "context" - "fmt" "io" "log" "os" @@ -15,14 +14,14 @@ import ( "time" ) -// PuzzleCommand specifies a command to run for the puzzle API -type PuzzleCommand struct { +// ProviderCommand specifies a command to run for the puzzle API +type ProviderCommand struct { Path string Args []string } // Inventory runs with "action=inventory", and parses the output into a category list. -func (pc PuzzleCommand) Inventory() (inv []Category) { +func (pc ProviderCommand) Inventory() (inv []Category) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() @@ -62,16 +61,18 @@ func (pc PuzzleCommand) Inventory() (inv []Category) { return } +// NullReadSeekCloser wraps a no-op Close method around an io.ReadSeeker. type NullReadSeekCloser struct { io.ReadSeeker } +// Close does nothing. func (f NullReadSeekCloser) Close() error { return nil } // Open passes its arguments to the command with "action=open". -func (pc PuzzleCommand) Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) { +func (pc ProviderCommand) Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() @@ -91,7 +92,7 @@ func (pc PuzzleCommand) Open(cat string, points int, path string) (ReadSeekClose // CheckAnswer passes its arguments to the command with "action=answer". // If the command exits successfully and sends "correct" to stdout, // nil is returned. -func (pc PuzzleCommand) CheckAnswer(cat string, points int, answer string) error { +func (pc ProviderCommand) CheckAnswer(cat string, points int, answer string) (bool, error) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() @@ -105,9 +106,9 @@ func (pc PuzzleCommand) CheckAnswer(cat string, points int, answer string) error stdout, err := cmd.Output() if ee, ok := err.(*exec.ExitError); ok { log.Printf("%s: %s", pc.Path, string(ee.Stderr)) - return err + return false, err } else if err != nil { - return err + return false, err } result := strings.TrimSpace(string(stdout)) @@ -115,12 +116,12 @@ func (pc PuzzleCommand) CheckAnswer(cat string, points int, answer string) error if result == "" { result = "Nothing written to stdout" } - return fmt.Errorf("Wrong answer: %s", result) + return false, nil } - return nil + return true, nil } -// Maintain does nothing: a command puzzle provider has no housekeeping -func (pc PuzzleCommand) Maintain(updateInterval time.Duration) { +// Maintain does nothing: a command puzzle ProviderCommand has no housekeeping +func (pc ProviderCommand) Maintain(updateInterval time.Duration) { } diff --git a/cmd/mothd/puzzlecmd_test.go b/cmd/mothd/providercommand_test.go similarity index 85% rename from cmd/mothd/puzzlecmd_test.go rename to cmd/mothd/providercommand_test.go index 3cd08f2..cf9a7e1 100644 --- a/cmd/mothd/puzzlecmd_test.go +++ b/cmd/mothd/providercommand_test.go @@ -6,8 +6,8 @@ import ( "testing" ) -func TestPuzzleCommand(t *testing.T) { - pc := PuzzleCommand{ +func TestProviderCommand(t *testing.T) { + pc := ProviderCommand{ Path: "testdata/testpiler.sh", } @@ -34,14 +34,14 @@ func TestPuzzleCommand(t *testing.T) { } } - if err := pc.CheckAnswer("pategory", 1, "answer"); err != nil { + if ok, err := pc.CheckAnswer("pategory", 1, "answer"); !ok { t.Errorf("Correct answer for pategory: %v", err) } - if err := pc.CheckAnswer("pategory", 1, "wrong"); err == nil { + if ok, _ := pc.CheckAnswer("pategory", 1, "wrong"); ok { t.Errorf("Wrong answer for pategory judged correct") } - if err := pc.CheckAnswer("pategory", 2, "answer"); err == nil { + if _, err := pc.CheckAnswer("pategory", 2, "answer"); err == nil { t.Errorf("Internal error not returned") } else if ee, ok := err.(*exec.ExitError); ok { if string(ee.Stderr) != "Internal error\n" { diff --git a/cmd/mothd/server.go b/cmd/mothd/server.go index e50eb0a..781f7c1 100644 --- a/cmd/mothd/server.go +++ b/cmd/mothd/server.go @@ -22,11 +22,14 @@ type ReadSeekCloser interface { io.Closer } +// Configuration stores information about server configuration. +type Configuration struct { + Devel bool +} + // StateExport is given to clients requesting the current state. type StateExport struct { - Config struct { - Devel bool - } + Config Configuration Messages string TeamNames map[string]string PointsLog award.List @@ -37,7 +40,7 @@ type StateExport struct { type PuzzleProvider interface { Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) Inventory() []Category - CheckAnswer(cat string, points int, answer string) error + CheckAnswer(cat string, points int, answer string) (bool, error) Maintainer } @@ -68,17 +71,19 @@ type Maintainer interface { // MothServer gathers together the providers that make up a MOTH server. type MothServer struct { - Puzzles PuzzleProvider - Theme ThemeProvider - State StateProvider + PuzzleProviders []PuzzleProvider + Theme ThemeProvider + State StateProvider + Config Configuration } // NewMothServer returns a new MothServer. -func NewMothServer(puzzles PuzzleProvider, theme ThemeProvider, state StateProvider) *MothServer { +func NewMothServer(config Configuration, theme ThemeProvider, state StateProvider, puzzleProviders ...PuzzleProvider) *MothServer { return &MothServer{ - Puzzles: puzzles, - Theme: theme, - State: state, + Config: config, + PuzzleProviders: puzzleProviders, + Theme: theme, + State: state, } } @@ -99,15 +104,52 @@ type MothRequestHandler struct { } // PuzzlesOpen opens a file associated with a puzzle. -func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (ReadSeekCloser, time.Time, error) { +// BUG(neale): Multiple providers with the same category name are not detected or handled well. +func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r ReadSeekCloser, ts time.Time, err error) { export := mh.ExportState() + found := false for _, p := range export.Puzzles[cat] { if p == points { - return mh.Puzzles.Open(cat, points, path) + found = true + } + } + if !found { + return nil, time.Time{}, fmt.Errorf("Category not found") + } + + // Try every provider until someone doesn't return an error + for _, provider := range mh.PuzzleProviders { + r, ts, err = provider.Open(cat, points, path) + if err != nil { + return r, ts, err } } - return nil, time.Time{}, fmt.Errorf("Puzzle locked") + return +} + +// 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 { + correct := false + for _, provider := range mh.PuzzleProviders { + if ok, err := provider.CheckAnswer(cat, points, answer); err != nil { + return err + } else if ok { + correct = true + } + } + if !correct { + return fmt.Errorf("Incorrect answer") + } + + msg := fmt.Sprintf("GOOD %s %s %s %d", mh.participantID, mh.teamID, cat, points) + mh.State.LogEvent(msg) + + if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil { + return fmt.Errorf("Error awarding points: %s", err) + } + + return nil } // ThemeOpen opens a file from a theme. @@ -124,29 +166,13 @@ func (mh *MothRequestHandler) Register(teamName string) error { 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 { - msg := fmt.Sprintf("BAD %s %s %s %d %s", mh.participantID, mh.teamID, cat, points, err.Error()) - mh.State.LogEvent(msg) - return err - } - - if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil { - msg := fmt.Sprintf("GOOD %s %s %s %d", mh.participantID, mh.teamID, cat, points) - mh.State.LogEvent(msg) - return err - } - - return nil -} - // 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.Config = mh.Config teamName, _ := mh.State.TeamName(mh.teamID) @@ -182,20 +208,22 @@ func (mh *MothRequestHandler) ExportState() *StateExport { // but then we got a bad reputation on some secretive blacklist, // and now the Navy can't register for events. - for _, category := range mh.Puzzles.Inventory() { - // Append sentry (end of puzzles) - allPuzzles := append(category.Puzzles, 0) + for _, provider := range mh.PuzzleProviders { + for _, category := range provider.Inventory() { + // Append sentry (end of puzzles) + allPuzzles := append(category.Puzzles, 0) - max := maxSolved[category.Name] + max := maxSolved[category.Name] - puzzles := make([]int, 0, len(allPuzzles)) - for i, val := range allPuzzles { - puzzles = allPuzzles[:i+1] - if val > max { - break + puzzles := make([]int, 0, len(allPuzzles)) + for i, val := range allPuzzles { + puzzles = allPuzzles[:i+1] + if !mh.Config.Devel && (val > max) { + break + } } + export.Puzzles[category.Name] = puzzles } - export.Puzzles[category.Name] = puzzles } } diff --git a/cmd/mothd/server_test.go b/cmd/mothd/server_test.go index fc57708..ffc481f 100644 --- a/cmd/mothd/server_test.go +++ b/cmd/mothd/server_test.go @@ -24,7 +24,7 @@ func NewTestServer() *MothServer { afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644) go theme.Maintain(TestMaintenanceInterval) - return NewMothServer(puzzles, theme, state) + return NewMothServer(Configuration{Devel: true}, theme, state, puzzles) } func TestServer(t *testing.T) { diff --git a/cmd/mothd/state/enabled b/cmd/mothd/state/enabled new file mode 100644 index 0000000..37dc114 --- /dev/null +++ b/cmd/mothd/state/enabled @@ -0,0 +1 @@ +enabled: remove or rename to suspend the contest. diff --git a/cmd/mothd/state/event.log b/cmd/mothd/state/event.log new file mode 100644 index 0000000..e69de29 diff --git a/cmd/mothd/state/hours b/cmd/mothd/state/hours new file mode 100644 index 0000000..c025c36 --- /dev/null +++ b/cmd/mothd/state/hours @@ -0,0 +1,11 @@ +# hours: when the contest is enabled +# +# Enable: + timestamp +# Disable: - timestamp +# +# You can have multiple start/stop times. +# Whatever time is the most recent, wins. +# Times in the future are ignored. + ++ 2020-09-08T23:43:53Z +- 3019-10-31T00:00:00Z diff --git a/cmd/mothd/state/initialized b/cmd/mothd/state/initialized new file mode 100644 index 0000000..05bb30e --- /dev/null +++ b/cmd/mothd/state/initialized @@ -0,0 +1,3 @@ +initialized: remove to re-initialize the contest. + +This instance was initaliazed at 2020-09-08T23:43:53Z diff --git a/cmd/mothd/state/messages.html b/cmd/mothd/state/messages.html new file mode 100644 index 0000000..dd4fe05 --- /dev/null +++ b/cmd/mothd/state/messages.html @@ -0,0 +1 @@ + diff --git a/cmd/mothd/state/points.log b/cmd/mothd/state/points.log new file mode 100644 index 0000000..e69de29 diff --git a/cmd/mothd/state/teamids.txt b/cmd/mothd/state/teamids.txt new file mode 100644 index 0000000..76a6141 --- /dev/null +++ b/cmd/mothd/state/teamids.txt @@ -0,0 +1,100 @@ +ywf3=a32 +c4hp2dxx +brb4r4t2 +ybyen862 +8mfqd834 +yk788pqx +ymf6idk3 +4qiwfar7 +h2m7=nf7 +nzbz=pzx +ddmd=m8p +87ahkr28 +7rbq3=pd +y2xix7ep +37h86=64 +7ey32edn +=f4rrhrr +4k2i2rzz +cie2p7ed +zcydpq44 +riqxziqa +ptqqwh2k +=8=3q3kd +c8na=qix +reqhwkca +fkm3tkm7 +6etm6kh7 +pwd2p=fi +ryz7t4xe +qy3azp2k +h3rweqd8 +d=2f3r=q +zha7t6rp +=nh6ncz4 +kbabkwaq +7z4dmdbz +it8iddbb +dtwptqn8 +anwhemzw +etptmc8w +c=pa3hz2 +pe4r=ede +=cw23dhe +yw3xaw3n +=a7yz2f3 +q6dqamia +4x8e6c8c +3tt88hkx +6crqe=kn +dhnprc4r +kdczyz7q +y=z8pkpk +6h3i6p=i +mipx4dmh +b6rdhb2z +kpqt8th2 +mqwa=b3f +hzzr7dwa +x8833aa4 +in327p7t +it=dnyh= +kr2pftrh +zahqwz32 +66wkyc8q +8amz4ehy +ct37zri6 +rd2zpp67 +6hczfmpt +4dckadbz +7wx3r4hf +8p6aynxm +=xwanh=4 +fw4y2qdf +6qz7k8ee +7z7neebn +a3mi3m3a +bhftc6dt +hhm3b4qd +hddy=t2c +cqi32enq +xnmknai4 +=a24=2ci +fnfc322r +fzb62kmk +w8kenc7y +q8=y=pf4 +=ry46dd8 +tz=bp4kw +amwwfaqy +2fan2phi +73=387fb +ye==8i2w +r2zznx=e +nn83t4ni +dzxpi4ae +ef6reizk +4q8e8kbq +wwyq2=x7 +y7db2nty +6222=fpb diff --git a/cmd/mothd/testdata/cat0/1/puzzle.md b/cmd/mothd/testdata/cat0/1/puzzle.md new file mode 100644 index 0000000..f29dfcd --- /dev/null +++ b/cmd/mothd/testdata/cat0/1/puzzle.md @@ -0,0 +1,3 @@ +author: neale + +Hello, world. diff --git a/cmd/mothd/transpiler.go b/cmd/mothd/transpiler.go new file mode 100644 index 0000000..042e213 --- /dev/null +++ b/cmd/mothd/transpiler.go @@ -0,0 +1,75 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "log" + "time" + + "github.com/dirtbags/moth/pkg/transpile" + "github.com/spf13/afero" +) + +// NewTranspilerProvider returns a new TranspilerProvider. +func NewTranspilerProvider(fs afero.Fs) TranspilerProvider { + return TranspilerProvider{fs} +} + +// TranspilerProvider provides puzzles generated from source files on disk +type TranspilerProvider struct { + fs afero.Fs +} + +// Inventory returns a Category list for this provider. +func (p TranspilerProvider) Inventory() []Category { + ret := make([]Category, 0) + inv, err := transpile.FsInventory(p.fs) + if err != nil { + log.Print(err) + return ret + } + for name, points := range inv { + ret = append(ret, Category{name, points}) + } + return ret +} + +type nopCloser struct { + io.ReadSeeker +} + +func (c nopCloser) Close() error { + return nil +} + +// Open returns a file associated with the given category and point value. +func (p TranspilerProvider) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) { + c := transpile.NewFsCategory(p.fs, cat) + switch filename { + case "", "puzzle.json": + p, err := c.Puzzle(points) + if err != nil { + return nopCloser{new(bytes.Reader)}, time.Time{}, err + } + jp, err := json.Marshal(p) + if err != nil { + return nopCloser{new(bytes.Reader)}, time.Time{}, err + } + return nopCloser{bytes.NewReader(jp)}, time.Now(), nil + default: + r, err := c.Open(points, filename) + return r, time.Now(), err + } +} + +// CheckAnswer checks whether an answer si correct. +func (p TranspilerProvider) CheckAnswer(cat string, points int, answer string) (bool, error) { + c := transpile.NewFsCategory(p.fs, cat) + return c.Answer(points, answer), nil +} + +// Maintain performs housekeeping. +func (p TranspilerProvider) Maintain(updateInterval time.Duration) { + // Nothing to do here. +} diff --git a/cmd/mothd/transpiler_test.go b/cmd/mothd/transpiler_test.go new file mode 100644 index 0000000..20dfd92 --- /dev/null +++ b/cmd/mothd/transpiler_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "testing" + + "github.com/spf13/afero" +) + +func TestTranspiler(t *testing.T) { + fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata") + p := NewTranspilerProvider(fs) + + inv := p.Inventory() + if len(inv) != 1 { + t.Error("Wrong inventory:", inv) + } else if len(inv[0].Puzzles) != 1 { + t.Error("Wrong inventory:", inv) + } +} diff --git a/cmd/transpile/main.go b/cmd/transpile/main.go index dc36c96..119a381 100644 --- a/cmd/transpile/main.go +++ b/cmd/transpile/main.go @@ -9,25 +9,11 @@ import ( "os" "sort" - "github.com/GoBike/envflag" + "github.com/dirtbags/moth/pkg/transpile" + "github.com/spf13/afero" ) -// Category defines the functionality required to be a puzzle category. -type Category interface { - // Inventory lists every puzzle in the category. - Inventory() ([]int, error) - - // Puzzle provides a Puzzle structure for the given point value. - Puzzle(points int) (Puzzle, error) - - // Open returns an io.ReadCloser for the given filename. - Open(points int, filename string) (io.ReadCloser, error) - - // Answer returns whether the given answer is correct. - Answer(points int, answer string) bool -} - // T contains everything required for a transpilation invocation (across the nation). type T struct { // What action to take @@ -39,7 +25,8 @@ type T struct { Fs afero.Fs } -// ParseArgs parses command-line arguments into T, returning the action to take +// ParseArgs parses command-line arguments into T, returning the action to take. +// BUG(neale): CLI arguments are not related to how the CLI will be used. func (t *T) ParseArgs() string { action := flag.String("action", "inventory", "Action to take: must be 'inventory', 'open', 'answer', or 'mothball'") flag.StringVar(&t.Cat, "cat", "", "Puzzle category") @@ -47,7 +34,7 @@ func (t *T) ParseArgs() string { flag.StringVar(&t.Answer, "answer", "", "Answer to check for correctness, for 'answer' action") flag.StringVar(&t.Filename, "filename", "", "Filename, for 'open' action") basedir := flag.String("basedir", ".", "Base directory containing all puzzles") - envflag.Parse() + flag.Parse() osfs := afero.NewOsFs() t.Fs = afero.NewBasePathFs(osfs, *basedir) @@ -130,7 +117,7 @@ func (t *T) Open() error { // Mothball writes a mothball to the writer. func (t *T) Mothball() error { c := t.NewCategory(t.Cat) - mb, err := Mothball(c) + mb, err := transpile.Mothball(c) if err != nil { return err } @@ -141,8 +128,8 @@ func (t *T) Mothball() error { } // NewCategory returns a new Fs-backed category. -func (t *T) NewCategory(name string) Category { - return NewFsCategory(t.Fs, name) +func (t *T) NewCategory(name string) transpile.Category { + return transpile.NewFsCategory(t.Fs, name) } func main() { diff --git a/cmd/transpile/main_test.go b/cmd/transpile/main_test.go index 4dd9c8e..77a6bdb 100644 --- a/cmd/transpile/main_test.go +++ b/cmd/transpile/main_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/dirtbags/moth/pkg/transpile" "github.com/spf13/afero" ) @@ -78,7 +79,7 @@ func TestEverything(t *testing.T) { t.Error(err) } - p := Puzzle{} + p := transpile.Puzzle{} if err := json.Unmarshal(stdout.Bytes(), &p); err != nil { t.Error(err) } diff --git a/cmd/transpile/basepath.go b/pkg/transpile/basepath.go similarity index 98% rename from cmd/transpile/basepath.go rename to pkg/transpile/basepath.go index 722be1c..60d6f69 100644 --- a/cmd/transpile/basepath.go +++ b/pkg/transpile/basepath.go @@ -1,4 +1,4 @@ -package main +package transpile import ( "os" diff --git a/cmd/transpile/category.go b/pkg/transpile/category.go similarity index 85% rename from cmd/transpile/category.go rename to pkg/transpile/category.go index 90e2ecd..22c57f2 100644 --- a/cmd/transpile/category.go +++ b/pkg/transpile/category.go @@ -1,11 +1,9 @@ -package main +package transpile import ( "bytes" "context" "encoding/json" - "io" - "io/ioutil" "log" "os/exec" "strconv" @@ -15,6 +13,21 @@ import ( "github.com/spf13/afero" ) +// Category defines the functionality required to be a puzzle category. +type Category interface { + // Inventory lists every puzzle in the category. + Inventory() ([]int, error) + + // Puzzle provides a Puzzle structure for the given point value. + Puzzle(points int) (Puzzle, error) + + // Open returns an io.ReadCloser for the given filename. + Open(points int, filename string) (ReadSeekCloser, error) + + // Answer returns whether the given answer is correct. + Answer(points int, answer string) bool +} + // NopReadCloser provides an io.ReadCloser which does nothing. type NopReadCloser struct { } @@ -81,7 +94,7 @@ func (c FsCategory) Puzzle(points int) (Puzzle, error) { } // Open returns an io.ReadCloser for the given filename. -func (c FsCategory) Open(points int, filename string) (io.ReadCloser, error) { +func (c FsCategory) Open(points int, filename string) (ReadSeekCloser, error) { return NewFsPuzzle(c.fs, points).Open(filename) } @@ -149,18 +162,13 @@ func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) { } // Open returns an io.ReadCloser for the given filename. -func (c FsCommandCategory) Open(points int, filename string) (io.ReadCloser, error) { +func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) { ctx, cancel := context.WithTimeout(context.Background(), c.timeout) defer cancel() cmd := exec.CommandContext(ctx, c.command, "file", strconv.Itoa(points), filename) stdout, err := cmd.Output() - buf := ioutil.NopCloser(bytes.NewBuffer(stdout)) - if err != nil { - return buf, err - } - - return buf, nil + return nopCloser{bytes.NewReader(stdout)}, err } // Answer checks whether an answer is correct. diff --git a/cmd/transpile/category_test.go b/pkg/transpile/category_test.go similarity index 99% rename from cmd/transpile/category_test.go rename to pkg/transpile/category_test.go index 2baea5f..575c056 100644 --- a/cmd/transpile/category_test.go +++ b/pkg/transpile/category_test.go @@ -1,4 +1,4 @@ -package main +package transpile import ( "bytes" diff --git a/pkg/transpile/common_test.go b/pkg/transpile/common_test.go new file mode 100644 index 0000000..c838fc1 --- /dev/null +++ b/pkg/transpile/common_test.go @@ -0,0 +1,53 @@ +package transpile + +import ( + "github.com/spf13/afero" +) + +var testMothYaml = []byte(`--- +answers: + - YAML answer +pre: + authors: + - Arthur + - Buster + - DW + attachments: + - filename: moo.txt +--- +YAML body +`) +var testMothRfc822 = []byte(`author: test +Author: Arthur +author: Fred Flintstone +answer: RFC822 answer + +RFC822 body +`) + +func newTestFs() afero.Fs { + fs := afero.NewMemMapFs() + afero.WriteFile(fs, "cat0/1/puzzle.md", testMothYaml, 0644) + afero.WriteFile(fs, "cat0/1/moo.txt", []byte("Moo."), 0644) + afero.WriteFile(fs, "cat0/2/puzzle.md", testMothRfc822, 0644) + afero.WriteFile(fs, "cat0/3/puzzle.moth", testMothYaml, 0644) + afero.WriteFile(fs, "cat0/4/puzzle.md", testMothYaml, 0644) + afero.WriteFile(fs, "cat0/5/puzzle.md", testMothYaml, 0644) + afero.WriteFile(fs, "cat0/10/puzzle.md", []byte(`--- +Answers: + - moo +Authors: + - bad field +--- +body +`), 0644) + afero.WriteFile(fs, "cat0/20/puzzle.md", []byte("Answer: no\nBadField: yes\n\nbody\n"), 0644) + afero.WriteFile(fs, "cat0/21/puzzle.md", []byte("Answer: broken\nSpooon\n"), 0644) + afero.WriteFile(fs, "cat0/22/puzzle.md", []byte("---\nanswers:\n - pencil\npre:\n unused-field: Spooon\n---\nSpoon?\n"), 0644) + afero.WriteFile(fs, "cat1/93/puzzle.md", []byte("Answer: no\n\nbody"), 0644) + afero.WriteFile(fs, "cat1/barney/puzzle.md", testMothYaml, 0644) + afero.WriteFile(fs, "unbroken/1/puzzle.md", testMothYaml, 0644) + afero.WriteFile(fs, "unbroken/1/moo.txt", []byte("Moo."), 0644) + afero.WriteFile(fs, "unbroken/2/puzzle.md", testMothRfc822, 0644) + return fs +} diff --git a/pkg/transpile/inventory.go b/pkg/transpile/inventory.go new file mode 100644 index 0000000..946d146 --- /dev/null +++ b/pkg/transpile/inventory.go @@ -0,0 +1,36 @@ +package transpile + +import ( + "log" + "sort" + + "github.com/spf13/afero" +) + +// Inventory maps category names to lists of point values. +type Inventory map[string][]int + +// FsInventory returns a mapping of category names to puzzle point values. +func FsInventory(fs afero.Fs) (Inventory, error) { + dirEnts, err := afero.ReadDir(fs, ".") + if err != nil { + log.Print(err) + return nil, err + } + + inv := make(Inventory) + for _, ent := range dirEnts { + if ent.IsDir() { + name := ent.Name() + c := NewFsCategory(fs, name) + puzzles, err := c.Inventory() + if err != nil { + return nil, err + } + sort.Ints(puzzles) + inv[name] = puzzles + } + } + + return inv, nil +} diff --git a/pkg/transpile/inventory_test.go b/pkg/transpile/inventory_test.go new file mode 100644 index 0000000..a172b98 --- /dev/null +++ b/pkg/transpile/inventory_test.go @@ -0,0 +1,16 @@ +package transpile + +import "testing" + +func TestInventory(t *testing.T) { + fs := newTestFs() + inv, err := FsInventory(fs) + if err != nil { + t.Error(err) + } + if c, ok := inv["cat0"]; !ok { + t.Error("No cat0") + } else if len(c) != 9 { + t.Error("Wrong category length", c) + } +} diff --git a/cmd/transpile/mothball.go b/pkg/transpile/mothball.go similarity index 98% rename from cmd/transpile/mothball.go rename to pkg/transpile/mothball.go index 2f95481..1ef87f2 100644 --- a/cmd/transpile/mothball.go +++ b/pkg/transpile/mothball.go @@ -1,4 +1,4 @@ -package main +package transpile import ( "archive/zip" diff --git a/cmd/transpile/mothball_test.go b/pkg/transpile/mothball_test.go similarity index 97% rename from cmd/transpile/mothball_test.go rename to pkg/transpile/mothball_test.go index 9b0391f..8430506 100644 --- a/cmd/transpile/mothball_test.go +++ b/pkg/transpile/mothball_test.go @@ -1,4 +1,4 @@ -package main +package transpile import ( "archive/zip" diff --git a/cmd/transpile/puzzle.go b/pkg/transpile/puzzle.go similarity index 93% rename from cmd/transpile/puzzle.go rename to pkg/transpile/puzzle.go index dc5ee1d..965aa33 100644 --- a/cmd/transpile/puzzle.go +++ b/pkg/transpile/puzzle.go @@ -1,4 +1,4 @@ -package main +package transpile import ( "bufio" @@ -8,7 +8,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "log" "net/mail" "os/exec" @@ -92,13 +91,20 @@ type StaticAttachment struct { Listed bool // Whether this file is listed as an attachment } +// ReadSeekCloser provides io.Reader, io.Seeker, and io.Closer. +type ReadSeekCloser interface { + io.Reader + io.Seeker + io.Closer +} + // PuzzleProvider establishes the functionality required to provide one puzzle. type PuzzleProvider interface { // Puzzle returns a Puzzle struct for the current puzzle. Puzzle() (Puzzle, error) // Open returns a newly-opened file. - Open(filename string) (io.ReadCloser, error) + Open(filename string) (ReadSeekCloser, error) // Answer returns whether the provided answer is correct. Answer(answer string) bool @@ -160,8 +166,8 @@ func (fp FsPuzzle) Puzzle() (Puzzle, error) { } // Open returns a newly-opened file. -func (fp FsPuzzle) Open(name string) (io.ReadCloser, error) { - empty := ioutil.NopCloser(new(bytes.Buffer)) +func (fp FsPuzzle) Open(name string) (ReadSeekCloser, error) { + empty := nopCloser{new(bytes.Reader)} static, _, err := fp.staticPuzzle() if err != nil { return empty, err @@ -343,15 +349,23 @@ func (fp FsCommandPuzzle) Puzzle() (Puzzle, error) { return puzzle, nil } +type nopCloser struct { + io.ReadSeeker +} + +func (c nopCloser) Close() error { + return nil +} + // Open returns a newly-opened file. -func (fp FsCommandPuzzle) Open(filename string) (io.ReadCloser, error) { +// BUG(neale): FsCommandPuzzle.Open() reads everything into memory, and will suck for large files. +func (fp FsCommandPuzzle) Open(filename string) (ReadSeekCloser, error) { ctx, cancel := context.WithTimeout(context.Background(), fp.timeout) defer cancel() cmd := exec.CommandContext(ctx, fp.command, "-file", filename) - // BUG(neale): FsCommandPuzzle.Open() reads everything into memory, and will suck for large files. out, err := cmd.Output() - buf := ioutil.NopCloser(bytes.NewBuffer(out)) + buf := nopCloser{bytes.NewReader(out)} if err != nil { return buf, err } diff --git a/cmd/transpile/puzzle_test.go b/pkg/transpile/puzzle_test.go similarity index 99% rename from cmd/transpile/puzzle_test.go rename to pkg/transpile/puzzle_test.go index 122e174..7cc1bb9 100644 --- a/cmd/transpile/puzzle_test.go +++ b/pkg/transpile/puzzle_test.go @@ -1,4 +1,4 @@ -package main +package transpile import ( "bytes" diff --git a/cmd/transpile/testdata/generated/mkcategory b/pkg/transpile/testdata/generated/mkcategory similarity index 100% rename from cmd/transpile/testdata/generated/mkcategory rename to pkg/transpile/testdata/generated/mkcategory diff --git a/cmd/transpile/testdata/static/1/puzzle.md b/pkg/transpile/testdata/static/1/puzzle.md similarity index 100% rename from cmd/transpile/testdata/static/1/puzzle.md rename to pkg/transpile/testdata/static/1/puzzle.md diff --git a/cmd/transpile/testdata/static/2/moo.js b/pkg/transpile/testdata/static/2/moo.js similarity index 100% rename from cmd/transpile/testdata/static/2/moo.js rename to pkg/transpile/testdata/static/2/moo.js diff --git a/cmd/transpile/testdata/static/2/moo.txt b/pkg/transpile/testdata/static/2/moo.txt similarity index 100% rename from cmd/transpile/testdata/static/2/moo.txt rename to pkg/transpile/testdata/static/2/moo.txt diff --git a/cmd/transpile/testdata/static/2/puzzle.md b/pkg/transpile/testdata/static/2/puzzle.md similarity index 100% rename from cmd/transpile/testdata/static/2/puzzle.md rename to pkg/transpile/testdata/static/2/puzzle.md diff --git a/cmd/transpile/testdata/static/3/mkpuzzle b/pkg/transpile/testdata/static/3/mkpuzzle similarity index 100% rename from cmd/transpile/testdata/static/3/mkpuzzle rename to pkg/transpile/testdata/static/3/mkpuzzle