diff --git a/res/puzzles.html b/res/puzzle-list.html similarity index 91% rename from res/puzzles.html rename to res/puzzle-list.html index 4d1942e..32f79dc 100644 --- a/res/puzzles.html +++ b/res/puzzle-list.html @@ -38,12 +38,12 @@ function render(obj) { l.appendChild(i); if (points === 0) { - i.textContent = "‡"; + i.textContent = "✿"; } else { var a = document.createElement('a'); i.appendChild(a); a.textContent = points; - a.href = cat + "/" + id + "/index.html"; + a.href = "puzzle.html?cat=" + cat + "&points=" + points + "&pid=" + id; } } @@ -73,7 +73,7 @@ document.addEventListener("DOMContentLoaded", init); diff --git a/res/scoreboard.html b/res/scoreboard.html index e16abf6..a50d9f1 100644 --- a/res/scoreboard.html +++ b/res/scoreboard.html @@ -140,7 +140,7 @@ document.addEventListener("DOMContentLoaded", init); diff --git a/src/handlers.go b/src/handlers.go index cdd6254..4750c70 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -318,6 +318,42 @@ func (ctx Instance) pointsHandler(w http.ResponseWriter, req *http.Request) { w.Write(jret) } +func (ctx Instance) contentHandler(w http.ResponseWriter, req *http.Request) { + // Prevent directory traversal + if strings.Contains(req.URL.Path, "/.") { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + // Be clever: use only the last three parts of the path. This may prove to be a bad idea. + parts := strings.Split(req.URL.Path, "/") + if len(parts) < 3 { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + fileName := parts[len(parts)-1] + puzzleId := parts[len(parts)-2] + categoryName := parts[len(parts)-3] + + mb, ok := ctx.Categories[categoryName] + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + mbFilename := fmt.Sprintf("content/%s/%s", puzzleId, fileName) + mf, err := mb.Open(mbFilename) + if err != nil { + log.Print(err) + http.Error(w, "Not Found", http.StatusNotFound) + return + } + defer mf.Close() + + http.ServeContent(w, req, fileName, mf.ModTime(), mf) +} + func (ctx Instance) staticHandler(w http.ResponseWriter, req *http.Request) { ServeStatic(w, req, ctx.ResourcesDir) } @@ -327,6 +363,7 @@ func (ctx Instance) BindHandlers(mux *http.ServeMux) { mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler) mux.HandleFunc(ctx.Base+"/token", ctx.tokenHandler) mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler) + mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler) mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler) mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler) } diff --git a/src/instance.go b/src/instance.go index bad4112..267a85e 100644 --- a/src/instance.go +++ b/src/instance.go @@ -18,6 +18,7 @@ type Instance struct { StateDir string ResourcesDir string Categories map[string]*Mothball + update chan bool } func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) { @@ -27,6 +28,7 @@ func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, e StateDir: stateDir, ResourcesDir: resourcesDir, Categories: map[string]*Mothball{}, + update: make(chan bool, 10), } // Roll over and die if directories aren't even set up @@ -145,6 +147,7 @@ func (ctx *Instance) AwardPoints(teamid, category string, points int) error { return err } + ctx.update <- true log.Printf("Award %s %s %d", teamid, category, points) return nil } diff --git a/src/maintenance.go b/src/maintenance.go index 501e140..fa7b0cd 100644 --- a/src/maintenance.go +++ b/src/maintenance.go @@ -121,7 +121,13 @@ func (ctx *Instance) CollectPoints() { // maintenance is the goroutine that runs a periodic maintenance task func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) { - for ; ; time.Sleep(maintenanceInterval) { + for { ctx.Tidy() + select { + case <-ctx.update: + log.Print("Forced update") + case <-time.After(maintenanceInterval): + log.Print("Housekeeping...") + } } } diff --git a/src/mothball.go b/src/mothball.go index 4f18257..4e2a962 100644 --- a/src/mothball.go +++ b/src/mothball.go @@ -6,6 +6,7 @@ import ( "io" "io/ioutil" "os" + "strings" "time" ) @@ -15,6 +16,101 @@ type Mothball struct { mtime time.Time } +type MothballFile struct { + f io.ReadCloser + pos int64 + zf *zip.File + io.Reader + io.Seeker + io.Closer +} + +func NewMothballFile(zf *zip.File) (*MothballFile, error) { + mf := &MothballFile{ + zf: zf, + pos: 0, + f: nil, + } + if err := mf.reopen(); err != nil { + return nil, err + } + return mf, nil +} + +func (mf *MothballFile) reopen() error { + if mf.f != nil { + if err := mf.f.Close(); err != nil { + return err + } + } + f, err := mf.zf.Open() + if err != nil { + return err + } + mf.f = f + mf.pos = 0 + return nil +} + +func (mf *MothballFile) ModTime() time.Time { + return mf.zf.Modified +} + +func (mf *MothballFile) Read(p []byte) (int, error) { + n, err := mf.f.Read(p) + mf.pos += int64(n) + return n, err +} + +func (mf *MothballFile) Seek(offset int64, whence int) (int64, error) { + var pos int64 + switch whence { + case io.SeekStart: + pos = offset + case io.SeekCurrent: + pos = mf.pos + int64(offset) + case io.SeekEnd: + pos = int64(mf.zf.UncompressedSize64) - int64(offset) + } + + if pos < 0 { + return mf.pos, fmt.Errorf("Tried to seek %d before start of file", pos) + } + if pos >= int64(mf.zf.UncompressedSize64) { + // We don't need to decompress anything, we're at the end of the file + mf.f.Close() + mf.f = ioutil.NopCloser(strings.NewReader("")) + mf.pos = int64(mf.zf.UncompressedSize64) + return mf.pos, nil + } + if pos < mf.pos { + if err := mf.reopen(); err != nil { + return mf.pos, err + } + } + + buf := make([]byte, 32 * 1024) + for pos > mf.pos { + l := pos - mf.pos + if l > int64(cap(buf)) { + l = int64(cap(buf)) - 1 + } + p := buf[0:int(l)] + n, err := mf.Read(p) + if err != nil { + return mf.pos, err + } else if n <= 0 { + return mf.pos, fmt.Errorf("Short read (%d bytes)", n) + } + } + + return mf.pos, nil +} + +func (mf *MothballFile) Close() error { + return mf.f.Close() +} + func OpenMothball(filename string) (*Mothball, error) { var m Mothball @@ -57,14 +153,30 @@ func (m *Mothball) Refresh() error { return nil } -func (m *Mothball) Open(filename string) (io.ReadCloser, error) { +func (m *Mothball) get(filename string) (*zip.File, error) { for _, f := range m.zf.File { if filename == f.Name { - ret, err := f.Open() - return ret, err + return f, nil } } - return nil, fmt.Errorf("File not found: %s in %s", filename, m.filename) + return nil, fmt.Errorf("File not found: %s %s", m.filename, filename) +} + +func (m *Mothball) Header(filename string) (*zip.FileHeader, error) { + f, err := m.get(filename) + if err != nil { + return nil, err + } + return &f.FileHeader, nil +} + +func (m *Mothball) Open(filename string) (*MothballFile, error) { + f, err := m.get(filename) + if err != nil { + return nil, err + } + mf, err := NewMothballFile(f) + return mf, err } func (m *Mothball) ReadFile(filename string) ([]byte, error) { diff --git a/src/mothd.go b/src/mothd.go index 9a7bcc4..c61af99 100644 --- a/src/mothd.go +++ b/src/mothd.go @@ -3,6 +3,7 @@ package main import ( "flag" "log" + "mime" "net/http" "time" ) @@ -61,6 +62,11 @@ func main() { } ctx.BindHandlers(http.DefaultServeMux) + // Add some MIME extensions + // Doing this avoids decompressing a mothball entry twice per request + mime.AddExtensionType(".json", "application/json") + mime.AddExtensionType(".zip", "application/zip") + go ctx.Maintenance(*maintenanceInterval) log.Printf("Listening on %s", *listen) diff --git a/src/static.go b/src/static.go index 64d4031..c13a0c9 100644 --- a/src/static.go +++ b/src/static.go @@ -67,7 +67,7 @@ func ShowHtml(w http.ResponseWriter, status Status, title string, body string) { fmt.Fprintf(w, "
%s
", body) fmt.Fprintf(w, "") @@ -397,7 +397,7 @@ func ServeStatic(w http.ResponseWriter, req *http.Request, resourcesDir string) staticIndex(w) case "/scoreboard.html": staticScoreboard(w) - case "/puzzles.html": + case "/puzzle-list.html": staticPuzzles(w) default: http.NotFound(w, req)