A ton of half-baked changes

This commit is contained in:
Neale Pickett 2022-05-10 13:20:54 -06:00
parent 55254234bf
commit fa5ea87f22
33 changed files with 1020 additions and 345 deletions

View File

@ -4,12 +4,12 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"testing" "testing"
"time" "time"
"github.com/spf13/afero"
) )
const TestParticipantID = "shipox" const TestParticipantID = "shipox"
@ -33,7 +33,12 @@ func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest
} }
func TestHttpd(t *testing.T) { func TestHttpd(t *testing.T) {
hs := NewHTTPServer("/", NewTestServer()) server, err := NewTestServer()
if err != nil {
log.Fatal(err)
}
defer server.cleanup()
hs := NewHTTPServer("/", server.MothServer)
if r := hs.TestRequest("/", nil); r.Result().StatusCode != 200 { if r := hs.TestRequest("/", nil); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())
@ -137,10 +142,14 @@ func TestHttpd(t *testing.T) {
} }
func TestDevelMemHttpd(t *testing.T) { func TestDevelMemHttpd(t *testing.T) {
srv := NewTestServer() srv, err := NewTestServer()
if err != nil {
t.Fatal(err)
}
defer srv.cleanup()
{ {
hs := NewHTTPServer("/", srv) hs := NewHTTPServer("/", srv.MothServer)
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 404 { if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 404 {
t.Error("Should have gotten a 404 for mothballer in prod mode") t.Error("Should have gotten a 404 for mothballer in prod mode")
@ -149,7 +158,7 @@ func TestDevelMemHttpd(t *testing.T) {
{ {
srv.Config.Devel = true srv.Config.Devel = true
hs := NewHTTPServer("/", srv) hs := NewHTTPServer("/", srv.MothServer)
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 500 { if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 500 {
t.Log(r.Body.String()) t.Log(r.Body.String())
@ -160,9 +169,9 @@ func TestDevelMemHttpd(t *testing.T) {
} }
func TestDevelFsHttps(t *testing.T) { func TestDevelFsHttps(t *testing.T) {
fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata") fsys := os.DirFS("testdata")
transpilerProvider := NewTranspilerProvider(fs) transpilerProvider := NewTranspilerProvider(fsys)
srv := NewMothServer(Configuration{Devel: true}, NewTestTheme(), NewTestState(), transpilerProvider) srv := NewMothServer(Configuration{Devel: true}, NewTheme("testdata/theme"), NewTestState(), transpilerProvider)
hs := NewHTTPServer("/", srv) hs := NewHTTPServer("/", srv)
if r := hs.TestRequest("/mothballer/cat0.mb", nil); r.Result().StatusCode != 200 { if r := hs.TestRequest("/mothballer/cat0.mb", nil); r.Result().StatusCode != 200 {

View File

@ -7,8 +7,6 @@ import (
"mime" "mime"
"os" "os"
"time" "time"
"github.com/spf13/afero"
) )
func main() { func main() {
@ -54,21 +52,20 @@ func main() {
) )
flag.Parse() flag.Parse()
osfs := afero.NewOsFs() theme := NewTheme(*themePath)
theme := NewTheme(afero.NewBasePathFs(osfs, *themePath))
config := Configuration{} config := Configuration{}
var provider PuzzleProvider var provider PuzzleProvider
provider = NewMothballs(afero.NewBasePathFs(osfs, *mothballPath)) provider = NewMothballs(os.DirFS(*mothballPath))
if *puzzlePath != "" { if *puzzlePath != "" {
provider = NewTranspilerProvider(afero.NewBasePathFs(osfs, *puzzlePath)) provider = NewTranspilerProvider(os.DirFS(*puzzlePath))
config.Devel = true config.Devel = true
log.Println("-=- You are in development mode, champ! -=-") log.Println("-=- You are in development mode, champ! -=-")
} }
var state StateProvider var state StateProvider
state = NewState(afero.NewBasePathFs(osfs, *statePath)) state = NewState(*statePath)
if config.Devel { if config.Devel {
state = NewDevelState(state) state = NewDevelState(state)
} }

View File

@ -3,36 +3,35 @@ package main
import ( import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"bytes"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/spf13/afero"
"github.com/spf13/afero/zipfs"
) )
type zipCategory struct { type zipCategory struct {
afero.Fs zip.Reader
io.Closer io.Closer
mtime time.Time mtime time.Time
} }
// Mothballs provides a collection of active mothball files (puzzle categories) // Mothballs provides a collection of active mothball files (puzzle categories)
type Mothballs struct { type Mothballs struct {
afero.Fs fs.FS
categories map[string]zipCategory categories map[string]zipCategory
categoryLock *sync.RWMutex categoryLock *sync.RWMutex
} }
// NewMothballs returns a new Mothballs structure backed by the provided directory // NewMothballs returns a new Mothballs structure backed by the provided directory
func NewMothballs(fs afero.Fs) *Mothballs { func NewMothballs(fsys fs.FS) *Mothballs {
return &Mothballs{ return &Mothballs{
Fs: fs, FS: fsys,
categories: make(map[string]zipCategory), categories: make(map[string]zipCategory),
categoryLock: new(sync.RWMutex), categoryLock: new(sync.RWMutex),
} }
@ -45,8 +44,8 @@ func (m *Mothballs) getCat(cat string) (zipCategory, bool) {
return ret, ok return ret, ok
} }
// Open returns a ReadSeekCloser corresponding to the filename in a puzzle's category and points // Open returns an fs.File corresponding to the filename in a puzzle's category and points
func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) { func (m *Mothballs) Open(cat string, points int, filename string) (fs.File, time.Time, error) {
zc, ok := m.getCat(cat) zc, ok := m.getCat(cat)
if !ok { if !ok {
return nil, time.Time{}, fmt.Errorf("no such category: %s", cat) return nil, time.Time{}, fmt.Errorf("no such category: %s", cat)
@ -112,6 +111,41 @@ func (m *Mothballs) CheckAnswer(cat string, points int, answer string) (bool, er
return false, nil return false, nil
} }
func (m *Mothballs) newZipCategory(f fs.File) (zipCategory, error) {
var zrc *zip.Reader
var err error
var closer io.ReadCloser = f
var zipCat zipCategory
fi, err := f.Stat()
if err != nil {
return zipCat, err
}
zipCat.mtime = fi.ModTime()
switch r := f.(type) {
case io.ReaderAt:
zrc, err = zip.NewReader(r, fi.Size())
default:
log.Println("Does not implement io.ReaderAt, buffering in RAM:", r)
buf := new(bytes.Buffer)
size, err := io.Copy(buf, f)
if err != nil {
return zipCat, err
}
f.Close()
reader := bytes.NewReader(buf.Bytes())
zrc, err = zip.NewReader(reader, size)
closer = io.NopCloser(reader)
}
if err != nil {
return zipCat, err
}
zipCat.Reader = *zrc
zipCat.Closer = closer
return zipCat, nil
}
// refresh refreshes internal state. // refresh refreshes internal state.
// It looks for changes to the directory listing, and caches any new mothballs. // It looks for changes to the directory listing, and caches any new mothballs.
func (m *Mothballs) refresh() { func (m *Mothballs) refresh() {
@ -119,7 +153,7 @@ func (m *Mothballs) refresh() {
defer m.categoryLock.Unlock() defer m.categoryLock.Unlock()
// Any new categories? // Any new categories?
files, err := afero.ReadDir(m.Fs, "/") files, err := fs.ReadDir(m.FS, "/")
if err != nil { if err != nil {
log.Println("Error listing mothballs:", err) log.Println("Error listing mothballs:", err)
return return
@ -136,7 +170,7 @@ func (m *Mothballs) refresh() {
reopen := false reopen := false
if existingMothball, ok := m.categories[categoryName]; !ok { if existingMothball, ok := m.categories[categoryName]; !ok {
reopen = true reopen = true
} else if si, err := m.Fs.Stat(filename); err != nil { } else if si, err := fs.Stat(m.FS, filename); err != nil {
log.Println(err) log.Println(err)
} else if si.ModTime().After(existingMothball.mtime) { } else if si.ModTime().After(existingMothball.mtime) {
existingMothball.Close() existingMothball.Close()
@ -145,33 +179,14 @@ func (m *Mothballs) refresh() {
} }
if reopen { if reopen {
f, err := m.Fs.Open(filename) if f, err := m.FS.Open(filename); err != nil {
if err != nil {
log.Println(err) log.Println(err)
continue } else if zipCat, err := m.newZipCategory(f); err != nil {
}
fi, err := f.Stat()
if err != nil {
f.Close()
log.Println(err) log.Println(err)
continue } else {
m.categories[categoryName] = zipCat
log.Println("Adding category:", categoryName)
} }
zrc, err := zip.NewReader(f, fi.Size())
if err != nil {
f.Close()
log.Println(err)
continue
}
m.categories[categoryName] = zipCategory{
Fs: zipfs.New(zrc),
Closer: f,
mtime: fi.ModTime(),
}
log.Println("Adding category:", categoryName)
} }
} }

View File

@ -2,11 +2,12 @@ package main
import ( import (
"archive/zip" "archive/zip"
"bytes"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"testing" "testing"
"testing/fstest"
"github.com/spf13/afero" "time"
) )
type testFileContents struct { type testFileContents struct {
@ -23,9 +24,27 @@ var testFiles = []testFileContents{
{"3/moo.txt", `moo`}, {"3/moo.txt", `moo`},
} }
func (m *Mothballs) createMothballWithFiles(cat string, contents []testFileContents) { type TestMothballs struct {
f, _ := m.Create(fmt.Sprintf("%s.mb", cat)) *Mothballs
defer f.Close() fsys fstest.MapFS
now time.Time
}
func NewTestMothballs() TestMothballs {
fsys := make(fstest.MapFS)
m := TestMothballs{
fsys: fsys,
Mothballs: NewMothballs(fsys),
now: time.Now(),
}
m.createMothball("pategory")
m.refresh()
return m
}
func (m *TestMothballs) createMothballWithFiles(cat string, contents []testFileContents) {
f := new(bytes.Buffer)
w := zip.NewWriter(f) w := zip.NewWriter(f)
defer w.Close() defer w.Close()
@ -38,9 +57,16 @@ func (m *Mothballs) createMothballWithFiles(cat string, contents []testFileConte
of, _ := w.Create(file.Name) of, _ := w.Create(file.Name)
of.Write([]byte(file.Body)) of.Write([]byte(file.Body))
} }
filename := fmt.Sprintf("%.mb", cat)
m.now = m.now.Add(time.Millisecond)
m.fsys[filename] = &fstest.MapFile{
Data: f.Bytes(),
Mode: 0x644,
ModTime: m.now,
}
} }
func (m *Mothballs) createMothball(cat string) { func (m *TestMothballs) createMothball(cat string) {
m.createMothballWithFiles( m.createMothballWithFiles(
cat, cat,
[]testFileContents{ []testFileContents{
@ -49,14 +75,7 @@ func (m *Mothballs) createMothball(cat string) {
) )
} }
func NewTestMothballs() *Mothballs { func TestMothballStuff(t *testing.T) {
m := NewMothballs(new(afero.MemMapFs))
m.createMothball("pategory")
m.refresh()
return m
}
func TestMothballs(t *testing.T) {
m := NewTestMothballs() m := NewTestMothballs()
if _, ok := m.categories["pategory"]; !ok { if _, ok := m.categories["pategory"]; !ok {
t.Error("Didn't create a new category") t.Error("Didn't create a new category")
@ -129,7 +148,7 @@ func TestMothballs(t *testing.T) {
} }
m.createMothball("test2") m.createMothball("test2")
m.Fs.Remove("pategory.mb") delete(m.fsys, "pategory.mb")
m.refresh() m.refresh()
inv = m.Inventory() inv = m.Inventory()
if len(inv) != 1 { if len(inv) != 1 {

View File

@ -76,7 +76,7 @@ func (f NullReadSeekCloser) Close() error {
} }
// Open passes its arguments to the command with "action=open". // Open passes its arguments to the command with "action=open".
func (pc ProviderCommand) Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) { func (pc ProviderCommand) Open(cat string, points int, path string) (io.ReadSeekCloser, time.Time, error) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel() defer cancel()

View File

@ -15,13 +15,6 @@ type Category struct {
Puzzles []int Puzzles []int
} }
// ReadSeekCloser defines a struct that can read, seek, and close.
type ReadSeekCloser interface {
io.Reader
io.Seeker
io.Closer
}
// Configuration stores information about server configuration. // Configuration stores information about server configuration.
type Configuration struct { type Configuration struct {
Devel bool Devel bool
@ -38,7 +31,7 @@ type StateExport struct {
// PuzzleProvider defines what's required to provide puzzles. // PuzzleProvider defines what's required to provide puzzles.
type PuzzleProvider interface { type PuzzleProvider interface {
Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) Open(cat string, points int, path string) (io.ReadSeekCloser, time.Time, error)
Inventory() []Category Inventory() []Category
CheckAnswer(cat string, points int, answer string) (bool, error) CheckAnswer(cat string, points int, answer string) (bool, error)
Mothball(cat string, w io.Writer) error Mothball(cat string, w io.Writer) error
@ -47,7 +40,7 @@ type PuzzleProvider interface {
// ThemeProvider defines what's required to provide a theme. // ThemeProvider defines what's required to provide a theme.
type ThemeProvider interface { type ThemeProvider interface {
Open(path string) (ReadSeekCloser, time.Time, error) Open(path string) (io.ReadSeekCloser, time.Time, error)
Maintainer Maintainer
} }
@ -106,7 +99,7 @@ type MothRequestHandler struct {
// PuzzlesOpen opens a file associated with a puzzle. // PuzzlesOpen opens a file associated with a puzzle.
// BUG(neale): Multiple providers with the same category name are not detected or handled well. // 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) { func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r io.ReadSeekCloser, ts time.Time, err error) {
export := mh.exportStateIfRegistered(true) export := mh.exportStateIfRegistered(true)
found := false found := false
for _, p := range export.Puzzles[cat] { for _, p := range export.Puzzles[cat] {
@ -162,7 +155,7 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string)
} }
// ThemeOpen opens a file from a theme. // ThemeOpen opens a file from a theme.
func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, error) { func (mh *MothRequestHandler) ThemeOpen(path string) (io.ReadSeekCloser, time.Time, error) {
return mh.Theme.Open(path) return mh.Theme.Open(path)
} }

View File

@ -2,33 +2,53 @@ package main
import ( import (
"io/ioutil" "io/ioutil"
"os"
"testing" "testing"
"time" "time"
"github.com/spf13/afero"
) )
const TestMaintenanceInterval = time.Millisecond * 1 const TestMaintenanceInterval = time.Millisecond * 1
const TestTeamID = "teamID" const TestTeamID = "teamID"
func NewTestServer() *MothServer { type TestMothServer struct {
*MothServer
stateDir string
}
func NewTestServer() (*TestMothServer, error) {
puzzles := NewTestMothballs() puzzles := NewTestMothballs()
go puzzles.Maintain(TestMaintenanceInterval) go puzzles.Maintain(TestMaintenanceInterval)
state := NewTestState() stateDir, err := ioutil.TempDir("", "state")
afero.WriteFile(state, "teamids.txt", []byte("teamID\n"), 0644) if err != nil {
afero.WriteFile(state, "messages.html", []byte("messages.html"), 0644) return nil, err
}
state := NewState(stateDir)
os.WriteFile(state.path("teamids.txt"), []byte("teamID\n"), 0644)
os.WriteFile(state.path("messages.html"), []byte("messages.html"), 0644)
go state.Maintain(TestMaintenanceInterval) go state.Maintain(TestMaintenanceInterval)
theme := NewTestTheme() theme := NewTheme("testdata/theme")
afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644)
go theme.Maintain(TestMaintenanceInterval) go theme.Maintain(TestMaintenanceInterval)
return NewMothServer(Configuration{}, theme, state, puzzles) return &TestMothServer{
MothServer: NewMothServer(Configuration{}, theme, state, puzzles),
stateDir: stateDir,
}, nil
}
func (m *TestMothServer) cleanup() {
if m.stateDir != "" {
os.RemoveAll(m.stateDir)
}
} }
func TestDevelServer(t *testing.T) { func TestDevelServer(t *testing.T) {
server := NewTestServer() server, err := NewTestServer()
if err != nil {
t.Fatal(err)
}
defer server.cleanup()
server.Config.Devel = true server.Config.Devel = true
anonHandler := server.NewHandler("badParticipantId", "badTeamId") anonHandler := server.NewHandler("badParticipantId", "badTeamId")
@ -48,7 +68,11 @@ func TestProdServer(t *testing.T) {
participantID := "participantID" participantID := "participantID"
teamID := TestTeamID teamID := TestTeamID
server := NewTestServer() server, err := NewTestServer()
if err != nil {
t.Fatal(err)
}
defer server.cleanup()
handler := server.NewHandler(participantID, teamID) handler := server.NewHandler(participantID, teamID)
anonHandler := server.NewHandler("badParticipantId", "badTeamId") anonHandler := server.NewHandler("badParticipantId", "badTeamId")

View File

@ -15,7 +15,6 @@ import (
"time" "time"
"github.com/dirtbags/moth/pkg/award" "github.com/dirtbags/moth/pkg/award"
"github.com/spf13/afero"
) )
// DistinguishableChars are visually unambiguous glyphs. // DistinguishableChars are visually unambiguous glyphs.
@ -34,7 +33,7 @@ var ErrAlreadyRegistered = errors.New("team ID has already been registered")
// We use the filesystem for synchronization between threads. // We use the filesystem for synchronization between threads.
// The only thing State methods need to know is the path to the state directory. // The only thing State methods need to know is the path to the state directory.
type State struct { type State struct {
afero.Fs basedir string
// Enabled tracks whether the current State system is processing updates // Enabled tracks whether the current State system is processing updates
Enabled bool Enabled bool
@ -42,7 +41,7 @@ type State struct {
refreshNow chan bool refreshNow chan bool
eventStream chan []string eventStream chan []string
eventWriter *csv.Writer eventWriter *csv.Writer
eventWriterFile afero.File eventWriterFile *os.File
// Caches, so we're not hammering NFS with metadata operations // Caches, so we're not hammering NFS with metadata operations
teamNames map[string]string teamNames map[string]string
@ -52,9 +51,9 @@ type State struct {
} }
// NewState returns a new State struct backed by the given Fs // NewState returns a new State struct backed by the given Fs
func NewState(fs afero.Fs) *State { func NewState(basedir string) *State {
s := &State{ s := &State{
Fs: fs, basedir: basedir,
Enabled: true, Enabled: true,
refreshNow: make(chan bool, 5), refreshNow: make(chan bool, 5),
eventStream: make(chan []string, 80), eventStream: make(chan []string, 80),
@ -67,12 +66,17 @@ func NewState(fs afero.Fs) *State {
return s return s
} }
func (s *State) path(elem ...string) string {
elements := append([]string{s.basedir}, elem...)
return filepath.Join(elements...)
}
// updateEnabled checks a few things to see if this state directory is "enabled". // updateEnabled checks a few things to see if this state directory is "enabled".
func (s *State) updateEnabled() { func (s *State) updateEnabled() {
nextEnabled := true nextEnabled := true
why := "`state/enabled` present, `state/hours.txt` missing" why := "`state/enabled` present, `state/hours.txt` missing"
if untilFile, err := s.Open("hours.txt"); err == nil { if untilFile, err := os.Open(s.path("hours.txt")); err == nil {
defer untilFile.Close() defer untilFile.Close()
why = "`state/hours.txt` present" why = "`state/hours.txt` present"
@ -111,7 +115,7 @@ func (s *State) updateEnabled() {
} }
} }
if _, err := s.Stat("enabled"); os.IsNotExist(err) { if _, err := os.Stat(s.path("enabled")); os.IsNotExist(err) {
nextEnabled = false nextEnabled = false
why = "`state/enabled` missing" why = "`state/enabled` missing"
} }
@ -141,7 +145,7 @@ func (s *State) TeamName(teamID string) (string, error) {
// SetTeamName writes out team name. // SetTeamName writes out team name.
// This can only be done once per team. // This can only be done once per team.
func (s *State) SetTeamName(teamID, teamName string) error { func (s *State) SetTeamName(teamID, teamName string) error {
idsFile, err := s.Open("teamids.txt") idsFile, err := os.Open(s.path("teamids.txt"))
if err != nil { if err != nil {
return fmt.Errorf("team IDs file does not exist") return fmt.Errorf("team IDs file does not exist")
} }
@ -159,7 +163,7 @@ func (s *State) SetTeamName(teamID, teamName string) error {
} }
teamFilename := filepath.Join("teams", teamID) teamFilename := filepath.Join("teams", teamID)
teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644) teamFile, err := os.OpenFile(s.path(teamFilename), os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
if os.IsExist(err) { if os.IsExist(err) {
return ErrAlreadyRegistered return ErrAlreadyRegistered
} else if err != nil { } else if err != nil {
@ -220,11 +224,11 @@ func (s *State) awardPointsAtTime(when int64, teamID string, category string, po
tmpfn := filepath.Join("points.tmp", fn) tmpfn := filepath.Join("points.tmp", fn)
newfn := filepath.Join("points.new", fn) newfn := filepath.Join("points.new", fn)
if err := afero.WriteFile(s, tmpfn, []byte(a.String()), 0644); err != nil { if err := os.WriteFile(s.path(tmpfn), []byte(a.String()), 0644); err != nil {
return err return err
} }
if err := s.Rename(tmpfn, newfn); err != nil { if err := os.Rename(s.path(tmpfn), newfn); err != nil {
return err return err
} }
@ -237,14 +241,14 @@ func (s *State) awardPointsAtTime(when int64, teamID string, category string, po
// collectPoints gathers up files in points.new/ and appends their contents to points.log, // collectPoints gathers up files in points.new/ and appends their contents to points.log,
// removing each points.new/ file as it goes. // removing each points.new/ file as it goes.
func (s *State) collectPoints() { func (s *State) collectPoints() {
files, err := afero.ReadDir(s, "points.new") files, err := os.ReadDir(s.path("points.new"))
if err != nil { if err != nil {
log.Print(err) log.Print(err)
return return
} }
for _, f := range files { for _, f := range files {
filename := filepath.Join("points.new", f.Name()) filename := filepath.Join("points.new", f.Name())
awardstr, err := afero.ReadFile(s, filename) awardstr, err := os.ReadFile(s.path(filename))
if err != nil { if err != nil {
log.Print("Opening new points: ", err) log.Print("Opening new points: ", err)
continue continue
@ -270,7 +274,7 @@ func (s *State) collectPoints() {
} else { } else {
log.Print("Award: ", awd.String()) log.Print("Award: ", awd.String())
logf, err := s.OpenFile("points.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) logf, err := os.OpenFile(s.path("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
log.Print("Can't append to points log: ", err) log.Print("Can't append to points log: ", err)
return return
@ -284,7 +288,7 @@ func (s *State) collectPoints() {
s.lock.Unlock() s.lock.Unlock()
} }
if err := s.Remove(filename); err != nil { if err := os.Remove(s.path(filename)); err != nil {
log.Print("Unable to remove new points file: ", err) log.Print("Unable to remove new points file: ", err)
} }
} }
@ -292,7 +296,7 @@ func (s *State) collectPoints() {
func (s *State) maybeInitialize() { func (s *State) maybeInitialize() {
// Are we supposed to re-initialize? // Are we supposed to re-initialize?
if _, err := s.Stat("initialized"); !os.IsNotExist(err) { if _, err := os.Stat(s.path("initialized")); !os.IsNotExist(err) {
return return
} }
@ -300,14 +304,14 @@ func (s *State) maybeInitialize() {
log.Print("initialized file missing, re-initializing") log.Print("initialized file missing, re-initializing")
// Remove any extant control and state files // Remove any extant control and state files
s.Remove("enabled") os.Remove(s.path("enabled"))
s.Remove("hours.txt") os.Remove(s.path("hours.txt"))
s.Remove("points.log") os.Remove(s.path("points.log"))
s.Remove("messages.html") os.Remove(s.path("messages.html"))
s.Remove("mothd.log") os.Remove(s.path("mothd.log"))
s.RemoveAll("points.tmp") os.RemoveAll(s.path("points.tmp"))
s.RemoveAll("points.new") os.RemoveAll(s.path("points.new"))
s.RemoveAll("teams") os.RemoveAll(s.path("teams"))
// Open log file // Open log file
if err := s.reopenEventLog(); err != nil { if err := s.reopenEventLog(); err != nil {
@ -316,12 +320,12 @@ func (s *State) maybeInitialize() {
s.LogEvent("init", "", "", "", 0) s.LogEvent("init", "", "", "", 0)
// Make sure various subdirectories exist // Make sure various subdirectories exist
s.Mkdir("points.tmp", 0755) os.Mkdir(s.path("points.tmp"), 0755)
s.Mkdir("points.new", 0755) os.Mkdir(s.path("points.new"), 0755)
s.Mkdir("teams", 0755) os.Mkdir(s.path("teams"), 0755)
// Preseed available team ids if file doesn't exist // Preseed available team ids if file doesn't exist
if f, err := s.OpenFile("teamids.txt", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil { if f, err := os.OpenFile(s.path("teamids.txt"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
id := make([]byte, 8) id := make([]byte, 8)
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
for i := range id { for i := range id {
@ -334,19 +338,19 @@ func (s *State) maybeInitialize() {
} }
// Create some files // Create some files
if f, err := s.Create("initialized"); err == nil { if f, err := os.Create(s.path("initialized")); err == nil {
fmt.Fprintln(f, "initialized: remove to re-initialize the contest.") fmt.Fprintln(f, "initialized: remove to re-initialize the contest.")
fmt.Fprintln(f) fmt.Fprintln(f)
fmt.Fprintln(f, "This instance was initialized at", now) fmt.Fprintln(f, "This instance was initialized at", now)
f.Close() f.Close()
} }
if f, err := s.Create("enabled"); err == nil { if f, err := os.Create(s.path("enabled")); err == nil {
fmt.Fprintln(f, "enabled: remove or rename to suspend the contest.") fmt.Fprintln(f, "enabled: remove or rename to suspend the contest.")
f.Close() f.Close()
} }
if f, err := s.Create("hours.txt"); err == nil { if f, err := os.Create(s.path("hours.txt")); err == nil {
fmt.Fprintln(f, "# hours.txt: when the contest is enabled") fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
fmt.Fprintln(f, "#") fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# Enable: + timestamp") fmt.Fprintln(f, "# Enable: + timestamp")
@ -361,12 +365,12 @@ func (s *State) maybeInitialize() {
f.Close() f.Close()
} }
if f, err := s.Create("messages.html"); err == nil { if f, err := os.Create(s.path("messages.html")); err == nil {
fmt.Fprintln(f, "<!-- messages.html: put client broadcast messages here. -->") fmt.Fprintln(f, "<!-- messages.html: put client broadcast messages here. -->")
f.Close() f.Close()
} }
if f, err := s.Create("points.log"); err == nil { if f, err := os.Create(s.path("points.log")); err == nil {
f.Close() f.Close()
} }
} }
@ -396,7 +400,7 @@ func (s *State) reopenEventLog() error {
log.Print(err) log.Print(err)
} }
} }
eventWriterFile, err := s.OpenFile("events.csv", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) eventWriterFile, err := os.OpenFile(s.path("events.csv"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
return err return err
} }
@ -409,7 +413,7 @@ func (s *State) updateCaches() {
s.lock.Lock() s.lock.Lock()
defer s.lock.Unlock() defer s.lock.Unlock()
if f, err := s.Open("points.log"); err != nil { if f, err := os.Open(s.path("points.log")); err != nil {
log.Println(err) log.Println(err)
} else { } else {
defer f.Close() defer f.Close()
@ -434,13 +438,12 @@ func (s *State) updateCaches() {
delete(s.teamNames, k) delete(s.teamNames, k)
} }
teamsFs := afero.NewBasePathFs(s.Fs, "teams") if dirents, err := os.ReadDir(s.path("teams")); err != nil {
if dirents, err := afero.ReadDir(teamsFs, "."); err != nil {
log.Printf("Reading team ids: %v", err) log.Printf("Reading team ids: %v", err)
} else { } else {
for _, dirent := range dirents { for _, dirent := range dirents {
teamID := dirent.Name() teamID := dirent.Name()
if teamNameBytes, err := afero.ReadFile(teamsFs, teamID); err != nil { if teamNameBytes, err := os.ReadFile(s.path("teams", teamID)); err != nil {
log.Printf("Reading team %s: %v", teamID, err) log.Printf("Reading team %s: %v", teamID, err)
} else { } else {
teamName := strings.TrimSpace(string(teamNameBytes)) teamName := strings.TrimSpace(string(teamNameBytes))
@ -451,7 +454,7 @@ func (s *State) updateCaches() {
} }
if bMessages, err := afero.ReadFile(s, "messages.html"); err == nil { if bMessages, err := os.ReadFile(s.path("messages.html")); err == nil {
s.messages = string(bMessages) s.messages = string(bMessages)
} }
} }

1
cmd/mothd/testdata/theme/index.html vendored Normal file
View File

@ -0,0 +1 @@
this is the index

View File

@ -1,26 +1,27 @@
package main package main
import ( import (
"io"
"os"
"path"
"time" "time"
"github.com/spf13/afero"
) )
// Theme defines a filesystem-backed ThemeProvider. // Theme defines a filesystem-backed ThemeProvider.
type Theme struct { type Theme struct {
afero.Fs basedir string
} }
// NewTheme returns a new Theme, backed by Fs. // NewTheme returns a new Theme, backed by Fs.
func NewTheme(fs afero.Fs) *Theme { func NewTheme(basedir string) *Theme {
return &Theme{ return &Theme{
Fs: fs, basedir: basedir,
} }
} }
// Open returns a new opened file. // Open returns a new opened file.
func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) { func (t *Theme) Open(name string) (io.ReadSeekCloser, time.Time, error) {
f, err := t.Fs.Open(name) f, err := os.Open(path.Join(t.basedir, name))
if err != nil { if err != nil {
return nil, time.Time{}, err return nil, time.Time{}, err
} }

View File

@ -2,25 +2,12 @@ package main
import ( import (
"io/ioutil" "io/ioutil"
"os"
"testing" "testing"
"github.com/spf13/afero"
) )
func NewTestTheme() *Theme {
return NewTheme(new(afero.MemMapFs))
}
func TestTheme(t *testing.T) { func TestTheme(t *testing.T) {
s := NewTestTheme() s := NewTheme("testdata/theme")
filename := "/index.html"
index := "this is the index"
afero.WriteFile(s.Fs, filename, []byte(index), 0644)
fileInfo, err := s.Fs.Stat(filename)
if err != nil {
t.Error(err)
}
if f, timestamp, err := s.Open("/index.html"); err != nil { if f, timestamp, err := s.Open("/index.html"); err != nil {
t.Error(err) t.Error(err)
@ -28,7 +15,9 @@ func TestTheme(t *testing.T) {
t.Error(err) t.Error(err)
} else if string(buf) != index { } else if string(buf) != index {
t.Error("Read wrong value from index") t.Error("Read wrong value from index")
} else if !timestamp.Equal(fileInfo.ModTime()) { } else if fi, err := os.Stat("testdata/theme/index.html"); err != nil {
t.Error(err)
} else if !timestamp.Equal(fi.ModTime()) {
t.Error("Timestamp compared wrong") t.Error("Timestamp compared wrong")
} }

View File

@ -4,21 +4,21 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"io" "io"
"io/fs"
"log" "log"
"time" "time"
"github.com/dirtbags/moth/pkg/transpile" "github.com/dirtbags/moth/pkg/transpile"
"github.com/spf13/afero"
) )
// NewTranspilerProvider returns a new TranspilerProvider. // NewTranspilerProvider returns a new TranspilerProvider.
func NewTranspilerProvider(fs afero.Fs) TranspilerProvider { func NewTranspilerProvider(fs fs.FS) TranspilerProvider {
return TranspilerProvider{fs} return TranspilerProvider{fs}
} }
// TranspilerProvider provides puzzles generated from source files on disk // TranspilerProvider provides puzzles generated from source files on disk
type TranspilerProvider struct { type TranspilerProvider struct {
fs afero.Fs fs fs.FS
} }
// Inventory returns a Category list for this provider. // Inventory returns a Category list for this provider.

View File

@ -1,14 +1,12 @@
package main package main
import ( import (
"os"
"testing" "testing"
"github.com/spf13/afero"
) )
func TestTranspiler(t *testing.T) { func TestTranspiler(t *testing.T) {
fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata") p := NewTranspilerProvider(os.DirFS("testdata"))
p := NewTranspilerProvider(fs)
inv := p.Inventory() inv := p.Inventory()
if len(inv) != 1 { if len(inv) != 1 {

View File

@ -5,13 +5,13 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"os" "os"
"sort" "sort"
"github.com/dirtbags/moth/pkg/namesubfs"
"github.com/dirtbags/moth/pkg/transpile" "github.com/dirtbags/moth/pkg/transpile"
"github.com/spf13/afero"
) )
// T represents the state of things // T represents the state of things
@ -20,8 +20,8 @@ type T struct {
Stdout io.Writer Stdout io.Writer
Stderr io.Writer Stderr io.Writer
Args []string Args []string
BaseFs afero.Fs BaseFs fs.FS
fs afero.Fs fs fs.FS
} }
// Command is a function invoked by the user // Command is a function invoked by the user
@ -88,7 +88,7 @@ func (t *T) ParseArgs() (Command, error) {
return nothing, err return nothing, err
} }
if *directory != "" { if *directory != "" {
t.fs = afero.NewBasePathFs(t.BaseFs, *directory) t.fs = namesubfs.Sub(t.BaseFs, *directory)
} else { } else {
t.fs = t.BaseFs t.fs = t.BaseFs
} }
@ -121,7 +121,10 @@ func (t *T) PrintInventory() error {
// DumpPuzzle writes a puzzle's JSON to the writer. // DumpPuzzle writes a puzzle's JSON to the writer.
func (t *T) DumpPuzzle() error { func (t *T) DumpPuzzle() error {
puzzle := transpile.NewFsPuzzle(t.fs) puzzle, err := transpile.NewFsPuzzle(t.fs)
if err != nil {
return err
}
p, err := puzzle.Puzzle() p, err := puzzle.Puzzle()
if err != nil { if err != nil {
@ -142,7 +145,10 @@ func (t *T) DumpFile() error {
filename = t.Args[0] filename = t.Args[0]
} }
puzzle := transpile.NewFsPuzzle(t.fs) puzzle, err := transpile.NewFsPuzzle(t.fs)
if err != nil {
return err
}
f, err := puzzle.Open(filename) f, err := puzzle.Open(filename)
if err != nil { if err != nil {
@ -166,7 +172,7 @@ func (t *T) DumpMothball() error {
w = t.Stdout w = t.Stdout
} else { } else {
filename = t.Args[0] filename = t.Args[0]
outf, err := t.BaseFs.Create(filename) outf, err := os.Create(filename)
if err != nil { if err != nil {
return err return err
} }
@ -177,7 +183,7 @@ func (t *T) DumpMothball() error {
if err := transpile.Mothball(c, w); err != nil { if err := transpile.Mothball(c, w); err != nil {
if filename != "" { if filename != "" {
t.BaseFs.Remove(filename) os.Remove(filename)
} }
return err return err
} }
@ -190,8 +196,11 @@ func (t *T) CheckAnswer() error {
if len(t.Args) > 0 { if len(t.Args) > 0 {
answer = t.Args[0] answer = t.Args[0]
} }
c := transpile.NewFsPuzzle(t.fs) c, err := transpile.NewFsPuzzle(t.fs)
_, err := fmt.Fprintf(t.Stdout, `{"Correct":%v}`, c.Answer(answer)) if err != nil {
return err
}
_, err = fmt.Fprintf(t.Stdout, `{"Correct":%v}`, c.Answer(answer))
return err return err
} }
@ -206,7 +215,7 @@ func main() {
Stdout: os.Stdout, Stdout: os.Stdout,
Stderr: os.Stderr, Stderr: os.Stderr,
Args: os.Args, Args: os.Args,
BaseFs: afero.NewOsFs(), BaseFs: os.DirFS(""),
} }
cmd, err := t.ParseArgs() cmd, err := t.ParseArgs()
if err != nil { if err != nil {

View File

@ -5,13 +5,15 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"io/fs"
"io/ioutil" "io/ioutil"
"os" "os"
"strings" "strings"
"testing" "testing"
"github.com/dirtbags/moth/pkg/transpile" "github.com/dirtbags/moth/pkg/transpile"
"github.com/spf13/afero" "github.com/psanford/memfs"
) )
var testMothYaml = []byte(`--- var testMothYaml = []byte(`---
@ -27,20 +29,20 @@ attachments:
YAML body YAML body
`) `)
func newTestFs() afero.Fs { func newTestFs() fs.FS {
fs := afero.NewMemMapFs() fsys := memfs.New()
afero.WriteFile(fs, "cat0/1/puzzle.md", testMothYaml, 0644) fsys.WriteFile("cat0/1/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/1/moo.txt", []byte("Moo."), 0644) fsys.WriteFile("cat0/1/moo.txt", []byte("Moo."), 0644)
afero.WriteFile(fs, "cat0/2/puzzle.moth", testMothYaml, 0644) fsys.WriteFile("cat0/2/puzzle.moth", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/3/puzzle.moth", testMothYaml, 0644) fsys.WriteFile("cat0/3/puzzle.moth", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/4/puzzle.md", testMothYaml, 0644) fsys.WriteFile("cat0/4/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/5/puzzle.md", testMothYaml, 0644) fsys.WriteFile("cat0/5/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "cat0/10/puzzle.md", testMothYaml, 0644) fsys.WriteFile("cat0/10/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "unbroken/1/puzzle.md", testMothYaml, 0644) fsys.WriteFile("unbroken/1/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "unbroken/1/moo.txt", []byte("Moo."), 0644) fsys.WriteFile("unbroken/1/moo.txt", []byte("Moo."), 0644)
afero.WriteFile(fs, "unbroken/2/puzzle.md", testMothYaml, 0644) fsys.WriteFile("unbroken/2/puzzle.md", testMothYaml, 0644)
afero.WriteFile(fs, "unbroken/2/moo.txt", []byte("Moo."), 0644) fsys.WriteFile("unbroken/2/moo.txt", []byte("Moo."), 0644)
return fs return fsys
} }
func (tp T) Run(args ...string) error { func (tp T) Run(args ...string) error {
@ -124,8 +126,7 @@ func TestMothballs(t *testing.T) {
return return
} }
// afero.WriteFile(tp.BaseFs, "unbroken.mb", []byte("moo"), 0644) fis, err := fs.ReadDir(tp.BaseFs, "/")
fis, err := afero.ReadDir(tp.BaseFs, "/")
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
@ -140,13 +141,24 @@ func TestMothballs(t *testing.T) {
} }
defer mb.Close() defer mb.Close()
info, err := mb.Stat() var zmb *zip.Reader
if err != nil { switch r := mb.(type) {
t.Error(err) case io.ReaderAt:
return info, err := mb.Stat()
if err != nil {
t.Error(err)
return
}
zmb, err = zip.NewReader(r, info.Size())
default:
t.Log("Doesn't implement ReaderAt, so I'm buffering the whole thing in memory:", r)
buf := new(bytes.Buffer)
size, err := io.Copy(buf, r)
if err != nil {
t.Error(err)
}
zmb, err = zip.NewReader(bytes.NewReader(buf.Bytes()), size)
} }
zmb, err := zip.NewReader(mb, info.Size())
if err != nil { if err != nil {
t.Error(err) t.Error(err)
return return
@ -185,7 +197,7 @@ func TestFilesystem(t *testing.T) {
Stdin: stdin, Stdin: stdin,
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,
BaseFs: afero.NewOsFs(), BaseFs: os.DirFS(""),
} }
stdout.Reset() stdout.Reset()
@ -220,7 +232,7 @@ func TestCwd(t *testing.T) {
Stdin: stdin, Stdin: stdin,
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,
BaseFs: afero.NewOsFs(), BaseFs: os.DirFS(""),
} }
stdout.Reset() stdout.Reset()

View File

@ -44,7 +44,8 @@ or with `POST` as `application/x-www-form-encoded` data.
Returns the current Moth event state as a JSON object. Returns the current Moth event state as a JSON object.
### Parameters ### Parameters
* `id`: team ID (optional) * `userid`: user ID (optional)
* `teamid`: team ID (optional)
### Return ### Return
@ -127,8 +128,9 @@ For this reason "this team is already registered"
does not return an error. does not return an error.
### Parameters ### Parameters
* `id`: team ID * `userid`: user ID (optional)
* `name`: team name * `teamid`: team ID
* `teamname`: team name
### Return ### Return
@ -153,7 +155,7 @@ POST /register HTTP/1.0
Content-Type: application/x-www-form-urlencoded Content-Type: application/x-www-form-urlencoded
Content-Length: 26 Content-Length: 26
id=b387ca98&name=dirtbags teamid=b387ca98&teamname=dirtbags
``` ```
#### Repsonse #### Repsonse
@ -174,7 +176,8 @@ Submits an answer for points.
If the answer is wrong, no points are awarded 😉 If the answer is wrong, no points are awarded 😉
### Parameters ### Parameters
* `id`: team ID * `userid`: user ID (optional)
* `teamid`: team ID
* `category`: along with `points`, uniquely identifies a puzzle * `category`: along with `points`, uniquely identifies a puzzle
* `points`: along with `category`, uniquely identifies a puzzle * `points`: along with `category`, uniquely identifies a puzzle
@ -222,6 +225,7 @@ Parameters are all in the URL for this endpoint,
so `curl` and `wget` can be used. so `curl` and `wget` can be used.
### Parameters ### Parameters
* `userid`: user ID (optional)
* `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle * `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle
* `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle * `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle
* `{filename}` (in URL): filename to retrieve * `{filename}` (in URL): filename to retrieve
@ -298,6 +302,7 @@ Parameters are all in the URL for this endpoint,
so `curl` and `wget` can be used. so `curl` and `wget` can be used.
### Parameters ### Parameters
* `userid`: user ID (optional)
* `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle * `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle
* `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle * `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle
* `{filename}` (in URL): filename to retrieve * `{filename}` (in URL): filename to retrieve
@ -327,6 +332,32 @@ Content-Length: 98
This is an attachment file! This is just plain text for the example. Many attachments are JPEGs. This is an attachment file! This is just plain text for the example. Many attachments are JPEGs.
``` ```
## `/chat/read`
Reads messages from a chat forum.
This yields [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events),
which allows new messages to be delivered instantly to the client.
### Parameters
* `userid`: user ID
* `since`: timestamp of oldest message to retrieve
* `forum`: chat forum to read (can be specified more than once!)
## `/chat/say`
Send a message to a chat forum.
### Parameters
* `userid`: user ID
* `forum`: chat forum to send to
* `text`: text of message to send
## `/chat/
# Puzzle # Puzzle

2
docs/internals.md Normal file
View File

@ -0,0 +1,2 @@
# Internal Structures

70
docs/user-tracking.md Normal file
View File

@ -0,0 +1,70 @@
# User Tracking
We need some way to have track users uniquely.
## Motivation
### Individual progress
We're way too far gone on this one.
I fought it while I could,
but everybody and their dog wants to track individual progress,
so we need to continue providing at least advisory information about who's doing what.
### Attendance
CPE certificates are the biggest driver here.
Doing this client-side won't work,
because people want to fight me about their certificates,
and I need something to fall back on.
The sponsor also has a keen interest in attrition,
and we need attendance data for this as well.
### Chat
We need to integrate a chat system,
and for our big events,
we need the chat system to use the "display name" provided by each participant.
## Requirements
Essentially, we need something like team ID,
but for an individual participant.
### Support drop-in events
One of our big wins right now is our ability to run drop-in events,
like Def Con contests,
high school science cafes,
etc.
We dealt with this by pre-generating authentication tokens and providing a
`/register` API endpoint to set a team name.
This was a good design and we should keep this.
### Run without Internet
Def Con's network is crap,
and we may yet run another event that's disconnected.
We need a way to run events without an Internet connection.
### Minimal storage
If possible, I'd prefer to not even have a password.
Ideally just a token for user, and their display name.
## Solution
I'm realizing the best solution is to do almost nothing.
We already have a client that provides a "participant ID",
which is logged into the event log.
The new chat system could pretty easily cache a mapping of `pid` to display name.
On cache miss, it could use whatever backend is provided to look things up.
This could be alfio, a URL to a CSV file, or something else.

3
go.mod
View File

@ -3,10 +3,9 @@ module github.com/dirtbags/moth
go 1.13 go 1.13
require ( require (
github.com/go-redis/redis/v8 v8.11.4
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/spf13/afero v1.5.1
github.com/yuin/goldmark v1.3.1 github.com/yuin/goldmark v1.3.1
golang.org/x/text v0.3.5 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )

76
go.sum
View File

@ -1,5 +1,31 @@
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@ -8,37 +34,87 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/psanford/memfs v0.0.0-20210214183328-a001468d78ef h1:NKxTG6GVGbfMXc2mIk+KphcH6hagbVXhcFkbTgYleTI=
github.com/psanford/memfs v0.0.0-20210214183328-a001468d78ef/go.mod h1:tcaRap0jS3eifrEEllL6ZMd9dg8IlDpi2S1oARrQ+NI=
github.com/spf13/afero v1.4.0 h1:jsLTaI1zwYO3vjrzHalkVcIHXTNmdQFepW4OI8H3+x8= github.com/spf13/afero v1.4.0 h1:jsLTaI1zwYO3vjrzHalkVcIHXTNmdQFepW4OI8H3+x8=
github.com/spf13/afero v1.4.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.4.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/afero v1.5.1 h1:VHu76Lk0LSP1x254maIu2bplkWpfBWI+B+6fdoZprcg= github.com/spf13/afero v1.5.1 h1:VHu76Lk0LSP1x254maIu2bplkWpfBWI+B+6fdoZprcg=
github.com/spf13/afero v1.5.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.5.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM= github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I= github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I=
github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

47
pkg/microchat/alfio.go Normal file
View File

@ -0,0 +1,47 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type AlfioUserResolver struct {
apiUrl string
}
// NewAlfioUserResolver returns an AlfioUserResolver for the provided API URL
func NewAlfioUserResolver(apiUrl string) AlfioUserResolver {
return AlfioUserResolver{
apiUrl: apiUrl,
}
}
// AlfioTicket defines the parts of the alfio ticket that we care about
type AlfioTicket struct {
FullName string `json:"fullName"`
TicketCategoryName string `json:"ticketCategoryName"`
}
// Resolve looks up a ticket to resolve into "${fullName} (${ticketCategory})"
func (a AlfioUserResolver) Resolve(event string, user string) (string, error) {
url := fmt.Sprintf("%s/event/%s/ticket/%s", a.apiUrl, event, user)
res, err := http.Get(url)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf(res.Status)
}
var ticket AlfioTicket
decoder := json.NewDecoder(res.Body)
if err := decoder.Decode(&ticket); err != nil {
return "", err
}
username := fmt.Sprintf("%s (%s)", ticket.FullName, ticket.TicketCategoryName)
return username, nil
}

50
pkg/microchat/cache.go Normal file
View File

@ -0,0 +1,50 @@
package main
import (
"context"
"fmt"
"time"
"github.com/go-redis/redis/v8"
)
// CacheResolver is a UserResolver that caches whatever's returned
type CacheResolver struct {
resolver UserResolver
rdb *redis.Client
expiration time.Duration
}
// NewCacheResolver returns a new CacheResolver
//
// Items will be cached in rdb with an expration of expiration.
func NewCacheResolver(resolver UserResolver, rdb *redis.Client, expiration time.Duration) *CacheResolver {
return &CacheResolver{
rdb: rdb,
resolver: resolver,
expiration: expiration,
}
}
// Resolve resolves an eventID and userID.
//
// It checks the cache first. If a match is found, that is returned.
// If not, it passes the request along to the upstream Resolver,
// caches the result, and returns it.
func (cr *CacheResolver) Resolve(eventID string, userID string) (string, error) {
key := fmt.Sprintf("username:%s|%s", eventID, userID)
name, err := cr.rdb.Get(context.TODO(), key).Result()
if err == nil {
// Cache hit
return name, nil
}
name, err = cr.resolver.Resolve(eventID, userID)
if err != nil {
return "", err
}
cr.rdb.Set(context.TODO(), key, name, cr.expiration)
return name, nil
}

51
pkg/microchat/hmac.go Normal file
View File

@ -0,0 +1,51 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"log"
"strings"
)
// HmacResolverSeparator is the string used to separater username from hmac
const HmacResolverSeparator = "::"
// HmacResolver resolves usernames using SHA256 HMAC
type HmacResolver struct {
key string
}
// Resolve resolves usernames using HMAC.
//
// User strings are expected to be the concatenation of:
// desired username, HmacResolverSeparator, MAC
//
// If there is no separator, the correct user string is computed and printed to the log.
// So you can use this to compute the correct usernames.
func (h *HmacResolver) Resolve(event string, user string) (string, error) {
userparts := strings.Split(user, HmacResolverSeparator)
username := userparts[0]
mac := hmac.New(sha256.New, []byte(h.key))
fmt.Fprint(mac, event)
fmt.Fprint(mac, user)
expectedMAC := mac.Sum(nil)
if len(userparts) == 1 {
expectedEnc := base64.URLEncoding.EncodeToString(expectedMAC)
log.Printf("Authenticated username: %s%s%s", username, HmacResolverSeparator, expectedEnc)
return "", fmt.Errorf("No authentication provided")
}
givenMAC, err := base64.URLEncoding.DecodeString(userparts[1])
if err != nil {
return "", err
}
if hmac.Equal(givenMAC, expectedMAC) {
return username, nil
}
return "", fmt.Errorf("Authentication failed")
}

10
pkg/microchat/message.go Normal file
View File

@ -0,0 +1,10 @@
package main
// Message contains everything sent to the client about a single message
type Message struct {
// User is the full ID of the user sending this message
User string
// Text is the message itself
Text string
}

228
pkg/microchat/microchat.go Normal file
View File

@ -0,0 +1,228 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"time"
"github.com/go-redis/redis/v8"
)
// Send something to the client at least this often, no matter what
const Keepalive = 30 * time.Second
// UserResolver can turn event ID and user ID into a username
type UserResolver interface {
// Resolve takes an event ID and user ID, and returns a username
Resolve(string, string) (string, error)
}
// resolver is the UserResolver currently in use for this server instance
var resolver UserResolver
// throttler is our global Throttler
var throttler *Throttler
var rdb *redis.Client
func forumKey(event string, forum string) string {
return fmt.Sprintf("%s|%s", event, forum)
}
type LogEvent struct {
Event string
User string
Username string
Forum string
Text string
}
func sayHandler(w http.ResponseWriter, r *http.Request) {
event := r.FormValue("event")
user := r.FormValue("user")
forum := r.FormValue("forum") // this can be empty
text := r.FormValue("text")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
if (event == "") || (user == "") || (text == "") {
http.Error(w, "Insufficient Arguments", http.StatusBadRequest)
return
}
if len(text) > 4096 {
http.Error(w, "Too Long", http.StatusRequestEntityTooLarge)
return
}
logEvent := LogEvent{
Event: event,
User: user,
Forum: forum,
Text: text,
}
if username, err := resolver.Resolve(event, user); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
log.Println("Rejected say", event, user, text)
return
} else {
logEvent.Username = username
}
if !throttler.CanPost(event, user) {
log.Println("Rejected (too fast)", logEvent)
http.Error(w, "Slow Down", http.StatusTooManyRequests)
return
}
rdb.XAdd(
context.Background(),
&redis.XAddArgs{
Stream: forumKey(event, forum),
ID: "*",
Values: map[string]interface{}{
"user": user,
"text": text,
"client": r.RemoteAddr,
},
},
)
log.Println("Posted", logEvent)
w.WriteHeader(http.StatusOK)
}
func readHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
event := r.FormValue("event")
user := r.FormValue("user")
since := r.FormValue("since")
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
if (event == "") || (user == "") {
http.Error(w, "Insufficient Arguments", http.StatusBadRequest)
return
}
var fora []string
for _, forum := range r.Form["forum"] {
fora = append(fora, forumKey(event, forum))
}
if since == "" {
since = "0"
}
if _, err := resolver.Resolve(event, user); err != nil {
log.Println("Rejected read", event, user)
http.Error(w, err.Error(), http.StatusForbidden)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Cannot flush this connection", http.StatusInternalServerError)
return
}
for {
if err := r.Context().Err(); err != nil {
break
}
var streams []string
for _, forum := range fora {
streams = append(streams, forum, since)
}
results, err := rdb.XRead(
context.Background(),
&redis.XReadArgs{
Streams: streams,
Count: 0,
Block: Keepalive,
},
).Result()
if err == redis.Nil {
// Keepalive timeout was hit with no data
fmt.Fprintln(w, ": ping")
} else if err != nil {
log.Fatalf("XReadStreams(%v) => %v, %v", streams, results, err)
}
for _, res := range results {
for _, rmsg := range res.Messages {
var user string
if val, ok := rmsg.Values["user"]; !ok {
http.Error(w, fmt.Sprintf("user not defined on message %s", rmsg.ID), http.StatusInternalServerError)
return
} else {
user = val.(string)
}
username, err := resolver.Resolve(event, user)
if err != nil {
username = fmt.Sprintf("??? %s", err.Error())
}
ucmsg := Message{
User: username,
Text: rmsg.Values["text"].(string),
}
jmsg, err := json.Marshal(ucmsg)
if err != nil {
http.Error(w, fmt.Sprintf("JSON Marshal: %s", err.Error()), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "id: %s\n", rmsg.ID)
fmt.Fprintf(w, "data: %s\n", string(jmsg))
fmt.Fprintf(w, "\n")
// next loop iteration, only ask for stuff that's happened since the last message
since = rmsg.ID
}
}
flusher.Flush()
}
}
func main() {
redisServer := flag.String("redis", "localhost:6379", "redis server")
alfioAuth := flag.String("alfio", "", "Enable alfio authentication with given API base URL")
hmacAuth := flag.String("hmac", "", "Enable HMAC authentication with given secret")
noAuth := flag.Bool("noauth", false, "Enable lame (aka no) authentication")
flag.Parse()
rdb = redis.NewClient(&redis.Options{Addr: *redisServer})
if *alfioAuth != "" {
alfResolver := NewAlfioUserResolver(*alfioAuth)
resolver = NewCacheResolver(alfResolver, rdb, 15*time.Minute)
} else if *hmacAuth != "" {
resolver = &HmacResolver{key: *hmacAuth}
} else if *noAuth {
resolver = NoAuthResolver{}
} else {
log.Fatal("No resolver specified")
return
}
throttler = &Throttler{
rdb: rdb,
expiration: 2 * time.Second,
}
http.HandleFunc("/say", sayHandler)
http.HandleFunc("/read", readHandler)
http.Handle("/", http.FileServer(http.Dir("static/")))
bind := ":8080"
log.Printf("Listening on %s", bind)
log.Fatal(http.ListenAndServe(bind, nil))
}

18
pkg/microchat/noauth.go Normal file
View File

@ -0,0 +1,18 @@
package main
import "fmt"
// NoAuthResolver is a pass-through resolver
type NoAuthResolver struct {
}
// Resolve just returns user, no authentication whatsover is performed
func (n NoAuthResolver) Resolve(event string, user string) (string, error) {
if (event == "") || (user == "") {
return user, fmt.Errorf("User and event must be specified")
}
if (len(event) > 40) || (len(user) > 40) {
return "", fmt.Errorf("Too large for me to handle!")
}
return user, nil
}

37
pkg/microchat/throttle.go Normal file
View File

@ -0,0 +1,37 @@
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/go-redis/redis/v8"
)
// Throttler provides a per-user timeout on posting
type Throttler struct {
rdb *redis.Client
expiration time.Duration
}
// CanPost returns true if the given userID is okay to post
func (t *Throttler) CanPost(eventID string, userID string) bool {
key := fmt.Sprintf("throttle:%s|%s", eventID, userID)
setargs := t.rdb.SetArgs(
context.TODO(),
key,
true,
redis.SetArgs{
Mode: "NX",
TTL: t.expiration,
},
)
if err := setargs.Err(); err == redis.Nil {
return false
} else if err != nil {
log.Print(err)
}
return true
}

View File

@ -0,0 +1,51 @@
package namesubfs
import (
"io/fs"
"log"
"path"
)
// Sub returns a NameSubFS corresponding to the subtree rooted at fsys's dir.
func NameSub(fsys fs.FS, dir string) (*NameSubFS, error) {
switch f := fsys.(type) {
case *NameSubFS:
return f.NameSub(dir)
default:
baseFS := &NameSubFS{fsys, ""}
return baseFS.NameSub(dir)
}
}
// A NameSubFS is a file system allowing the query of the full path name of entries
type NameSubFS struct {
fs.FS
dir string
}
// FullName returns the path to name.
//
// This is not the absolute path!
// It is relative to whatever was provided to the initial Sub call.
func (f *NameSubFS) FullName(name string) string {
return path.Join(f.dir, name)
}
// NameSub returns a NameSubFS corresponding to the subtree rooted at dir.
func (f *NameSubFS) NameSub(dir string) (*NameSubFS, error) {
log.Println("Sub", f.dir)
newFS, err := fs.Sub(f.FS, dir)
if err != nil {
return nil, err
}
newNameSubFS := NameSubFS{
FS: newFS,
dir: f.FullName(dir),
}
return &newNameSubFS, err
}
// NameSub returns an FS corresponding to the subtree rooted at dir.
func (f *NameSubFS) Sub(dir string) (fs.FS, error) {
return f.NameSub(dir)
}

View File

@ -0,0 +1,39 @@
package namesubfs
import (
"io/fs"
"testing"
"testing/fstest"
)
func TestSubFS(t *testing.T) {
testfs := fstest.MapFS{
"static/moo.txt": &fstest.MapFile{Data: []byte("moo.\n")},
"static/subdir/moo2.txt": &fstest.MapFile{Data: []byte("moo too.\n")},
}
if static, err := NameSub(testfs, "static"); err != nil {
t.Error(err)
} else if buf, err := fs.ReadFile(static, "moo.txt"); err != nil {
t.Error(err)
} else if string(buf) != "moo.\n" {
t.Error("Wrong file contents")
} else if subdir, err := NameSub(static, "subdir"); err != nil {
t.Error(err)
} else if buf, err := fs.ReadFile(subdir, "moo2.txt"); err != nil {
t.Error(err)
} else if string(buf) != "moo too.\n" {
t.Error("Wrong file contents too")
} else if subdir.FullName("glue") != "static/subdir/glue" {
t.Error("Wrong full name", subdir.FullName("glue"))
}
if a, err := NameSub(testfs, "a"); err != nil {
t.Error(err)
} else if b, err := fs.Sub(a, "b"); err != nil {
t.Error(err)
} else if c, err := NameSub(b, "c"); err != nil {
t.Error(err)
} else if c.FullName("d") != "a/b/c/d" {
t.Error(c.FullName("d"))
}
}

View File

@ -1,72 +0,0 @@
package transpile
import (
"os"
"path/filepath"
"runtime"
"strings"
"github.com/spf13/afero"
)
// RecursiveBasePathFs is an overloaded afero.BasePathFs that has a recursive RealPath().
type RecursiveBasePathFs struct {
afero.Fs
source afero.Fs
path string
}
// NewRecursiveBasePathFs returns a new RecursiveBasePathFs.
func NewRecursiveBasePathFs(source afero.Fs, path string) *RecursiveBasePathFs {
ret := &RecursiveBasePathFs{
source: source,
path: path,
}
if path == "" {
ret.Fs = source
} else {
ret.Fs = afero.NewBasePathFs(source, path)
}
return ret
}
// RealPath returns the real path to a file, "breaking out" of the RecursiveBasePathFs.
func (b *RecursiveBasePathFs) RealPath(name string) (path string, err error) {
if err := validateBasePathName(name); err != nil {
return name, err
}
bpath := filepath.Clean(b.path)
path = filepath.Clean(filepath.Join(bpath, name))
switch pfs := b.source.(type) {
case *RecursiveBasePathFs:
return pfs.RealPath(path)
case *afero.BasePathFs:
return pfs.RealPath(path)
case *afero.OsFs:
return path, nil
}
if !strings.HasPrefix(path, bpath) {
return name, os.ErrNotExist
}
return path, nil
}
func validateBasePathName(name string) error {
if runtime.GOOS != "windows" {
// Not much to do here;
// the virtual file paths all look absolute on *nix.
return nil
}
// On Windows a common mistake would be to provide an absolute OS path
// We could strip out the base part, but that would not be very portable.
if filepath.IsAbs(name) {
return os.ErrNotExist
}
return nil
}

View File

@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"log" "log"
"os/exec" "os/exec"
"path" "path"
@ -12,6 +13,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dirtbags/moth/pkg/namesubfs"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -28,41 +30,20 @@ type Category interface {
// Puzzle provides a Puzzle structure for the given point value. // Puzzle provides a Puzzle structure for the given point value.
Puzzle(points int) (Puzzle, error) 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 returns whether the given answer is correct.
Answer(points int, answer string) bool Answer(points int, answer string) bool
} }
// NopReadCloser provides an io.ReadCloser which does nothing.
type NopReadCloser struct {
}
// Read satisfies io.Reader.
func (n NopReadCloser) Read(b []byte) (int, error) {
return 0, nil
}
// Close satisfies io.Closer.
func (n NopReadCloser) Close() error {
return nil
}
// NewFsCategory returns a Category based on which files are present. // NewFsCategory returns a Category based on which files are present.
// If 'mkcategory' is present and executable, an FsCommandCategory is returned. // If 'mkcategory' is present and executable, an FsCommandCategory is returned.
// Otherwise, FsCategory is returned. // Otherwise, FsCategory is returned.
func NewFsCategory(fs afero.Fs, cat string) Category { func NewFsCategory(fsys fs.FS, cat string) Category {
bfs := NewRecursiveBasePathFs(fs, cat) bfs := namesubfs.Sub(fsys, cat)
if info, err := bfs.Stat("mkcategory"); (err == nil) && (info.Mode()&0100 != 0) { if info, err := fs.Stat(bfs, "mkcategory"); (err == nil) && (info.Mode()&0100 != 0) {
if command, err := bfs.RealPath(info.Name()); err != nil { return FsCommandCategory{
log.Println("Unable to resolve full path to", info.Name()) fs: bfs,
} else { command: bfs.FullPath(info.Name()),
return FsCommandCategory{ timeout: 2 * time.Second,
fs: bfs,
command: command,
timeout: 2 * time.Second,
}
} }
} }
return FsCategory{fs: bfs} return FsCategory{fs: bfs}
@ -100,11 +81,6 @@ func (c FsCategory) Puzzle(points int) (Puzzle, error) {
return NewFsPuzzlePoints(c.fs, points).Puzzle() return NewFsPuzzlePoints(c.fs, points).Puzzle()
} }
// Open returns an io.ReadCloser for the given filename.
func (c FsCategory) Open(points int, filename string) (ReadSeekCloser, error) {
return NewFsPuzzlePoints(c.fs, points).Open(filename)
}
// Answer checks whether an answer is correct. // Answer checks whether an answer is correct.
func (c FsCategory) Answer(points int, answer string) bool { func (c FsCategory) Answer(points int, answer string) bool {
// BUG(neale): FsCategory.Answer should probably always return false, to prevent you from running uncompiled puzzles with participants. // BUG(neale): FsCategory.Answer should probably always return false, to prevent you from running uncompiled puzzles with participants.
@ -177,13 +153,7 @@ func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) {
return p, nil return p, nil
} }
// Open returns an io.ReadCloser for the given filename. // Answer checks whether an answer is correct.Open
func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) {
stdout, err := c.run("file", strconv.Itoa(points), filename)
return nopCloser{bytes.NewReader(stdout)}, err
}
// Answer checks whether an answer is correct.
func (c FsCommandCategory) Answer(points int, answer string) bool { func (c FsCommandCategory) Answer(points int, answer string) bool {
stdout, err := c.run("answer", strconv.Itoa(points), answer) stdout, err := c.run("answer", strconv.Itoa(points), answer)
if err != nil { if err != nil {

View File

@ -3,11 +3,10 @@ package transpile
import ( import (
"bytes" "bytes"
"io" "io"
"os"
"os/exec" "os/exec"
"strings" "strings"
"testing" "testing"
"github.com/spf13/afero"
) )
func TestFsCategory(t *testing.T) { func TestFsCategory(t *testing.T) {
@ -33,7 +32,9 @@ func TestFsCategory(t *testing.T) {
t.Error("Incorrect answer accepted as correct") t.Error("Incorrect answer accepted as correct")
} }
if r, err := c.Open(1, "moo.txt"); err != nil { if p, err := c.Puzzle(1); err != nil {
t.Error(err)
} else if r, err := p.Open("moo.txt"); err != nil {
t.Log(c.Puzzle(1)) t.Log(c.Puzzle(1))
t.Error(err) t.Error(err)
} else { } else {
@ -54,8 +55,8 @@ func TestFsCategory(t *testing.T) {
} }
func TestOsFsCategory(t *testing.T) { func TestOsFsCategory(t *testing.T) {
fs := NewRecursiveBasePathFs(afero.NewOsFs(), "testdata") fsys := os.DirFS("testdata")
static := NewFsCategory(fs, "static") static := NewFsCategory(fsys, "static")
if p, err := static.Puzzle(1); err != nil { if p, err := static.Puzzle(1); err != nil {
t.Error(err) t.Error(err)
@ -71,7 +72,7 @@ func TestOsFsCategory(t *testing.T) {
t.Error("Wrong authors", p.Authors) t.Error("Wrong authors", p.Authors)
} }
generated := NewFsCategory(fs, "generated") generated := NewFsCategory(fsys, "generated")
if inv, err := generated.Inventory(); err != nil { if inv, err := generated.Inventory(); err != nil {
t.Error(err) t.Error(err)

View File

@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"net/mail" "net/mail"
"os" "os"
@ -18,7 +19,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/spf13/afero" "github.com/dirtbags/moth/pkg/namesubfs"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
@ -37,8 +38,8 @@ type PuzzleDebug struct {
Summary string Summary string
} }
// Puzzle contains everything about a puzzle that a client would see. // PuzzleMetadata contains everything about a puzzle that a client would see.
type Puzzle struct { type PuzzleMetadata struct {
Debug PuzzleDebug Debug PuzzleDebug
Authors []string Authors []string
Attachments []string Attachments []string
@ -57,6 +58,9 @@ type Puzzle struct {
Answers []string Answers []string
} }
type Puzzle interface {
}
func (puzzle *Puzzle) computeAnswerHashes() { func (puzzle *Puzzle) computeAnswerHashes() {
if len(puzzle.Answers) == 0 { if len(puzzle.Answers) == 0 {
return return
@ -111,35 +115,27 @@ func (sa *StaticAttachment) UnmarshalYAML(unmarshal func(interface{}) error) err
return nil return nil
} }
// 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. // PuzzleProvider establishes the functionality required to provide one puzzle.
type PuzzleProvider interface { type PuzzleProvider interface {
// Puzzle returns a Puzzle struct for the current puzzle. // Puzzle returns a Puzzle struct for the current puzzle.
Puzzle() (Puzzle, error) Puzzle() (Puzzle, error)
// Open returns a newly-opened file. // Open returns a newly-opened file.
Open(filename string) (ReadSeekCloser, error) Open(filename string) (fs.File, error)
// Answer returns whether the provided answer is correct. // Answer returns whether the provided answer is correct.
Answer(answer string) bool Answer(answer string) bool
} }
// NewFsPuzzle returns a new FsPuzzle. // NewFsPuzzle returns a new FsPuzzle.
func NewFsPuzzle(fs afero.Fs) PuzzleProvider { func NewFsPuzzle(fsys fs.FS) (PuzzleProvider, error) {
var command string var command string
bfs := NewRecursiveBasePathFs(fs, "") if bfs, err := namesubfs.Sub(fsys, ""); err != nil {
if info, err := bfs.Stat("mkpuzzle"); !os.IsNotExist(err) { return nil, err
} else if info, err := fs.Stat(bfs, "mkpuzzle"); !os.IsNotExist(err) {
if (info.Mode() & 0100) != 0 { if (info.Mode() & 0100) != 0 {
if command, err = bfs.RealPath(info.Name()); err != nil { command = bfs.FullName(info.Name())
log.Println("WARN: Unable to resolve full path to", info.Name())
}
} else { } else {
log.Println("WARN: mkpuzzle exists, but isn't executable.") log.Println("WARN: mkpuzzle exists, but isn't executable.")
} }
@ -147,26 +143,27 @@ func NewFsPuzzle(fs afero.Fs) PuzzleProvider {
if command != "" { if command != "" {
return FsCommandPuzzle{ return FsCommandPuzzle{
fs: fs, fs: fsys,
command: command, command: command,
timeout: 2 * time.Second, timeout: 2 * time.Second,
} }, nil
} }
return FsPuzzle{ return FsPuzzle{
fs: fs, fs: fsys,
} }, nil
} }
// NewFsPuzzlePoints returns a new FsPuzzle for points. // NewFsPuzzlePoints returns a new FsPuzzle for points.
func NewFsPuzzlePoints(fs afero.Fs, points int) PuzzleProvider { func NewFsPuzzlePoints(fs fs.FS, points int) PuzzleProvider {
return NewFsPuzzle(NewRecursiveBasePathFs(fs, strconv.Itoa(points))) subfs, _ := namesubfs.Sub(fs, strconv.Itoa(points))
return NewFsPuzzle(subfs)
} }
// FsPuzzle is a single puzzle's directory. // FsPuzzle is a single puzzle's directory.
type FsPuzzle struct { type FsPuzzle struct {
fs afero.Fs fs fs.FS
mkpuzzle bool mkpuzzle bool
} }
@ -360,7 +357,7 @@ func (fp FsPuzzle) Answer(answer string) bool {
// FsCommandPuzzle provides an FsPuzzle backed by running a command. // FsCommandPuzzle provides an FsPuzzle backed by running a command.
type FsCommandPuzzle struct { type FsCommandPuzzle struct {
fs afero.Fs fs fs.FS
command string command string
timeout time.Duration timeout time.Duration
} }