diff --git a/cmd/mothd/httpd.go b/cmd/mothd/httpd.go index a31c2da..66eb6f4 100644 --- a/cmd/mothd/httpd.go +++ b/cmd/mothd/httpd.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/dirtbags/moth/pkg/jsend" ) @@ -29,6 +30,10 @@ func NewHTTPServer(base string, server *MothServer) *HTTPServer { h.HandleMothFunc("/register", h.RegisterHandler) h.HandleMothFunc("/answer", h.AnswerHandler) h.HandleMothFunc("/content/", h.ContentHandler) + + if server.Config.Devel { + h.HandleMothFunc("/mothballer/", h.MothballerHandler) + } return h } @@ -128,16 +133,16 @@ func (h *HTTPServer) AnswerHandler(mh MothRequestHandler, w http.ResponseWriter, // ContentHandler returns static content from a given puzzle func (h *HTTPServer) ContentHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) { - trimLen := len(h.base) + len("/content/") - parts := strings.SplitN(req.URL.Path[trimLen:], "/", 3) - if len(parts) < 3 { - http.Error(w, "Not Found", http.StatusNotFound) + parts := strings.SplitN(req.URL.Path[len(h.base)+1:], "/", 4) + if len(parts) < 4 { + http.NotFound(w, req) return } - cat := parts[0] - pointsStr := parts[1] - filename := parts[2] + // parts[0] == "content" + cat := parts[1] + pointsStr := parts[2] + filename := parts[3] if filename == "" { filename = "puzzle.json" @@ -154,3 +159,23 @@ func (h *HTTPServer) ContentHandler(mh MothRequestHandler, w http.ResponseWriter http.ServeContent(w, req, filename, mtime, mf) } + +// MothballerHandler returns a mothball +func (h *HTTPServer) MothballerHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) { + parts := strings.SplitN(req.URL.Path[len(h.base)+1:], "/", 2) + if len(parts) < 2 { + http.NotFound(w, req) + return + } + + // parts[0] == "mothballer" + filename := parts[1] + cat := strings.TrimSuffix(filename, ".mb") + mothball, err := mh.Mothball(cat) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.ServeContent(w, req, filename, time.Now(), mothball) +} diff --git a/cmd/mothd/httpd_test.go b/cmd/mothd/httpd_test.go index 70be3c7..6bd66dc 100644 --- a/cmd/mothd/httpd_test.go +++ b/cmd/mothd/httpd_test.go @@ -8,6 +8,8 @@ import ( "net/url" "testing" "time" + + "github.com/spf13/afero" ) const TestParticipantID = "shipox" @@ -123,3 +125,39 @@ func TestHttpd(t *testing.T) { t.Error("Unexpected body", r.Body.String()) } } + +func TestDevelMemHttpd(t *testing.T) { + srv := NewTestServer() + + { + hs := NewHTTPServer("/", srv) + + if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 404 { + t.Error("Should have gotten a 404 for mothballer in prod mode") + } + } + + { + srv.Config.Devel = true + hs := NewHTTPServer("/", srv) + + if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 500 { + t.Log(r.Body.String()) + t.Log(r.Result()) + t.Error("Should have given us an internal server error, since category is a mothball") + } + } +} + +func TestDevelFsHttps(t *testing.T) { + fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata") + transpilerProvider := NewTranspilerProvider(fs) + srv := NewMothServer(Configuration{Devel: true}, NewTestTheme(), NewTestState(), transpilerProvider) + hs := NewHTTPServer("/", srv) + + if r := hs.TestRequest("/mothballer/cat0.mb", nil); r.Result().StatusCode != 200 { + t.Log(r.Body.String()) + t.Log(r.Result()) + t.Error("Didn't get a Mothball") + } +} diff --git a/cmd/mothd/main.go b/cmd/mothd/main.go index 718348b..61bb823 100644 --- a/cmd/mothd/main.go +++ b/cmd/mothd/main.go @@ -2,7 +2,9 @@ package main import ( "flag" + "fmt" "mime" + "os" "time" "github.com/spf13/afero" @@ -44,8 +46,22 @@ func main() { "/", "Base URL of this instance", ) + seed := flag.String( + "seed", + "", + "Random seed to use, overrides $SEED", + ) flag.Parse() + // Set random seed + if *seed == "" { + *seed = os.Getenv("SEED") + } + if *seed == "" { + *seed = fmt.Sprintf("%d%d", os.Getpid(), time.Now().Unix()) + } + os.Setenv("SEED", *seed) + osfs := afero.NewOsFs() theme := NewTheme(afero.NewBasePathFs(osfs, *themePath)) state := NewState(afero.NewBasePathFs(osfs, *statePath)) diff --git a/cmd/mothd/mothballs.go b/cmd/mothd/mothballs.go index e78e3c0..deab589 100644 --- a/cmd/mothd/mothballs.go +++ b/cmd/mothd/mothballs.go @@ -3,6 +3,7 @@ package main import ( "archive/zip" "bufio" + "bytes" "fmt" "io" "log" @@ -172,6 +173,11 @@ func (m *Mothballs) refresh() { } } +// Mothball just returns an error +func (m *Mothballs) Mothball(cat string) (*bytes.Reader, error) { + return nil, fmt.Errorf("Can't repackage a compiled mothball") +} + // Maintain performs housekeeping for Mothballs. func (m *Mothballs) Maintain(updateInterval time.Duration) { m.refresh() diff --git a/cmd/mothd/providercommand.go b/cmd/mothd/providercommand.go index 1af9766..75e46b8 100644 --- a/cmd/mothd/providercommand.go +++ b/cmd/mothd/providercommand.go @@ -4,6 +4,7 @@ package main import ( "bytes" "context" + "fmt" "io" "log" "os" @@ -122,6 +123,11 @@ func (pc ProviderCommand) CheckAnswer(cat string, points int, answer string) (bo return true, nil } +// Mothball just returns an error +func (pc ProviderCommand) Mothball(cat string) (*bytes.Reader, error) { + return nil, fmt.Errorf("Can't package a command-generated category") +} + // Maintain does nothing: a command puzzle ProviderCommand has no housekeeping func (pc ProviderCommand) Maintain(updateInterval time.Duration) { } diff --git a/cmd/mothd/server.go b/cmd/mothd/server.go index 781f7c1..a355cf7 100644 --- a/cmd/mothd/server.go +++ b/cmd/mothd/server.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "fmt" "io" "strconv" @@ -41,6 +42,7 @@ type PuzzleProvider interface { Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) Inventory() []Category CheckAnswer(cat string, points int, answer string) (bool, error) + Mothball(cat string) (*bytes.Reader, error) Maintainer } @@ -229,3 +231,16 @@ func (mh *MothRequestHandler) ExportState() *StateExport { return &export } + +// Mothball generates a mothball for the given category. +func (mh *MothRequestHandler) Mothball(cat string) (r *bytes.Reader, err error) { + if !mh.Config.Devel { + return nil, fmt.Errorf("Cannot mothball in production mode") + } + for _, provider := range mh.PuzzleProviders { + if r, err = provider.Mothball(cat); err == nil { + return r, nil + } + } + return nil, err +} diff --git a/cmd/mothd/transpiler.go b/cmd/mothd/transpiler.go index 042e213..ddda86b 100644 --- a/cmd/mothd/transpiler.go +++ b/cmd/mothd/transpiler.go @@ -69,6 +69,12 @@ func (p TranspilerProvider) CheckAnswer(cat string, points int, answer string) ( return c.Answer(points, answer), nil } +// Mothball packages up a category into a mothball. +func (p TranspilerProvider) Mothball(cat string) (*bytes.Reader, error) { + c := transpile.NewFsCategory(p.fs, cat) + return transpile.Mothball(c) +} + // Maintain performs housekeeping. func (p TranspilerProvider) Maintain(updateInterval time.Duration) { // Nothing to do here. diff --git a/pkg/transpile/common_test.go b/pkg/transpile/common_test.go index c838fc1..c2c9d94 100644 --- a/pkg/transpile/common_test.go +++ b/pkg/transpile/common_test.go @@ -13,7 +13,7 @@ pre: - Buster - DW attachments: - - filename: moo.txt + - moo.txt --- YAML body `) diff --git a/pkg/transpile/puzzle.go b/pkg/transpile/puzzle.go index ed5d433..ff9c2e2 100644 --- a/pkg/transpile/puzzle.go +++ b/pkg/transpile/puzzle.go @@ -92,6 +92,26 @@ type StaticAttachment struct { FilesystemPath string // Filename in backing FS (URL, mothball, or local FS) } +// UnmarshalYAML allows a StaticAttachment to be specified as a single string. +// The way the yaml library works is weird. +func (sa *StaticAttachment) UnmarshalYAML(unmarshal func(interface{}) error) error { + if err := unmarshal(&sa.Filename); err == nil { + sa.FilesystemPath = sa.Filename + return nil + } + + parts := new(struct { + Filename string + FilesystemPath string + }) + if err := unmarshal(parts); err != nil { + return err + } + sa.Filename = parts.Filename + sa.FilesystemPath = parts.FilesystemPath + return nil +} + // ReadSeekCloser provides io.Reader, io.Seeker, and io.Closer. type ReadSeekCloser interface { io.Reader @@ -307,8 +327,16 @@ func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) { p.Debug.Summary = val[0] case "hint": p.Debug.Hints = val + case "solution": + p.Debug.Hints = val case "ksa": p.Post.KSAs = val + case "objective": + p.Post.Objective = val[0] + case "success.acceptable": + p.Post.Success.Acceptable = val[0] + case "success.mastery": + p.Post.Success.Mastery = val[0] default: return p, fmt.Errorf("Unknown header field: %s", key) } diff --git a/pkg/transpile/puzzle_test.go b/pkg/transpile/puzzle_test.go index 288cde5..a6fc813 100644 --- a/pkg/transpile/puzzle_test.go +++ b/pkg/transpile/puzzle_test.go @@ -136,3 +136,35 @@ func TestFsPuzzle(t *testing.T) { t.Error("Error answer marked correct") } } + +func TestAttachment(t *testing.T) { + buf := bytes.NewBufferString(` +pre: + attachments: + - simple + - filename: complex + filesystempath: backingfile +`) + p, err := yamlHeaderParser(buf) + if err != nil { + t.Error(err) + return + } + + att := p.Pre.Attachments + if len(att) != 2 { + t.Error("Wrong number of attachments", att) + } + if att[0].Filename != "simple" { + t.Error("Attachment 0 wrong") + } + if att[0].Filename != att[0].FilesystemPath { + t.Error("Attachment 0 wrong") + } + if att[1].Filename != "complex" { + t.Error("Attachment 1 wrong") + } + if att[1].FilesystemPath != "backingfile" { + t.Error("Attachment 2 wrong") + } +}