Server can now be a devel server

This commit is contained in:
Neale Pickett 2020-09-08 17:49:02 -06:00
parent 6696d27ee0
commit f1f6140eea
36 changed files with 492 additions and 121 deletions

View File

@ -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())
}
}

View File

@ -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)

View File

@ -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.

View File

@ -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")

View File

@ -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) {
}

View File

@ -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" {

View File

@ -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
}
}

View File

@ -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) {

1
cmd/mothd/state/enabled Normal file
View File

@ -0,0 +1 @@
enabled: remove or rename to suspend the contest.

View File

11
cmd/mothd/state/hours Normal file
View File

@ -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

View File

@ -0,0 +1,3 @@
initialized: remove to re-initialize the contest.
This instance was initaliazed at 2020-09-08T23:43:53Z

View File

@ -0,0 +1 @@
<!-- messages.html: put client broadcast messages here. -->

View File

100
cmd/mothd/state/teamids.txt Normal file
View File

@ -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

3
cmd/mothd/testdata/cat0/1/puzzle.md vendored Normal file
View File

@ -0,0 +1,3 @@
author: neale
Hello, world.

75
cmd/mothd/transpiler.go Normal file
View File

@ -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.
}

View File

@ -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)
}
}

View File

@ -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() {

View File

@ -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)
}

View File

@ -1,4 +1,4 @@
package main
package transpile
import (
"os"

View File

@ -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.

View File

@ -1,4 +1,4 @@
package main
package transpile
import (
"bytes"

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -1,4 +1,4 @@
package main
package transpile
import (
"archive/zip"

View File

@ -1,4 +1,4 @@
package main
package transpile
import (
"archive/zip"

View File

@ -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
}

View File

@ -1,4 +1,4 @@
package main
package transpile
import (
"bytes"