mirror of https://github.com/dirtbags/moth.git
Server can now be a devel server
This commit is contained in:
parent
6696d27ee0
commit
f1f6140eea
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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) {
|
||||
}
|
|
@ -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" {
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
enabled: remove or rename to suspend the contest.
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
initialized: remove to re-initialize the contest.
|
||||
|
||||
This instance was initaliazed at 2020-09-08T23:43:53Z
|
|
@ -0,0 +1 @@
|
|||
<!-- messages.html: put client broadcast messages here. -->
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
author: neale
|
||||
|
||||
Hello, world.
|
|
@ -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.
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package transpile
|
||||
|
||||
import (
|
||||
"os"
|
|
@ -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.
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package transpile
|
||||
|
||||
import (
|
||||
"bytes"
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package transpile
|
||||
|
||||
import (
|
||||
"archive/zip"
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package transpile
|
||||
|
||||
import (
|
||||
"archive/zip"
|
|
@ -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
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package transpile
|
||||
|
||||
import (
|
||||
"bytes"
|
Loading…
Reference in New Issue