mirror of https://github.com/dirtbags/moth.git
Compare commits
2 Commits
959a802c84
...
fa5ea87f22
Author | SHA1 | Date |
---|---|---|
Neale Pickett | fa5ea87f22 | |
Neale Pickett | 55254234bf |
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
this is the index
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
41
docs/api.md
41
docs/api.md
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Internal Structures
|
||||||
|
|
|
@ -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
3
go.mod
|
@ -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
76
go.sum
|
@ -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=
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
package transpile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Sub(fsys fs.FS, dir string) (*SubFS, error) {
|
||||||
|
return &SubFS{fsys, dir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubFS struct {
|
||||||
|
fs.FS
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *SubFS) FullName(name string) string {
|
||||||
|
return path.Join(f.dir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *SubFS) Sub(dir string) (*SubFS, error) {
|
||||||
|
newFS, err := fs.Sub(f, dir)
|
||||||
|
newSubFS := SubFS{
|
||||||
|
FS: newFS,
|
||||||
|
dir: f.FullName(dir),
|
||||||
|
}
|
||||||
|
return &newSubFS, err
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
package transpile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubFS(t *testing.T) {
|
||||||
|
testdata := os.DirFS("testdata")
|
||||||
|
if static, err := Sub(testdata, "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 := static.Sub("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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
moo.
|
|
@ -0,0 +1 @@
|
||||||
|
moo too.
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue