Compare commits

...

2 Commits

Author SHA1 Message Date
Neale Pickett fa5ea87f22 A ton of half-baked changes 2022-05-10 13:20:54 -06:00
Neale Pickett 55254234bf new SubFS that can tell you the full FS path 2021-12-03 17:58:08 -07:00
37 changed files with 1076 additions and 345 deletions

View File

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

View File

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

View File

@ -3,36 +3,35 @@ package main
import (
"archive/zip"
"bufio"
"bytes"
"fmt"
"io"
"io/fs"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/spf13/afero"
"github.com/spf13/afero/zipfs"
)
type zipCategory struct {
afero.Fs
zip.Reader
io.Closer
mtime time.Time
}
// Mothballs provides a collection of active mothball files (puzzle categories)
type Mothballs struct {
afero.Fs
fs.FS
categories map[string]zipCategory
categoryLock *sync.RWMutex
}
// 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{
Fs: fs,
FS: fsys,
categories: make(map[string]zipCategory),
categoryLock: new(sync.RWMutex),
}
@ -45,8 +44,8 @@ func (m *Mothballs) getCat(cat string) (zipCategory, bool) {
return ret, ok
}
// Open returns a ReadSeekCloser 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) {
// 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) (fs.File, time.Time, error) {
zc, ok := m.getCat(cat)
if !ok {
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
}
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.
// It looks for changes to the directory listing, and caches any new mothballs.
func (m *Mothballs) refresh() {
@ -119,7 +153,7 @@ func (m *Mothballs) refresh() {
defer m.categoryLock.Unlock()
// Any new categories?
files, err := afero.ReadDir(m.Fs, "/")
files, err := fs.ReadDir(m.FS, "/")
if err != nil {
log.Println("Error listing mothballs:", err)
return
@ -136,7 +170,7 @@ func (m *Mothballs) refresh() {
reopen := false
if existingMothball, ok := m.categories[categoryName]; !ok {
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)
} else if si.ModTime().After(existingMothball.mtime) {
existingMothball.Close()
@ -145,33 +179,14 @@ func (m *Mothballs) refresh() {
}
if reopen {
f, err := m.Fs.Open(filename)
if err != nil {
if f, err := m.FS.Open(filename); err != nil {
log.Println(err)
continue
}
fi, err := f.Stat()
if err != nil {
f.Close()
} else if zipCat, err := m.newZipCategory(f); err != nil {
log.Println(err)
continue
} else {
m.categories[categoryName] = zipCat
log.Println("Adding category:", categoryName)
}
zrc, err := zip.NewReader(f, fi.Size())
if err != nil {
f.Close()
log.Println(err)
continue
}
m.categories[categoryName] = zipCategory{
Fs: zipfs.New(zrc),
Closer: f,
mtime: fi.ModTime(),
}
log.Println("Adding category:", categoryName)
}
}

View File

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

View File

@ -76,7 +76,7 @@ func (f NullReadSeekCloser) Close() error {
}
// Open passes its arguments to the command with "action=open".
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)
defer cancel()

View File

@ -15,13 +15,6 @@ type Category struct {
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.
type Configuration struct {
Devel bool
@ -38,7 +31,7 @@ type StateExport struct {
// PuzzleProvider defines what's required to provide puzzles.
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
CheckAnswer(cat string, points int, answer string) (bool, error)
Mothball(cat string, w io.Writer) error
@ -47,7 +40,7 @@ type PuzzleProvider interface {
// ThemeProvider defines what's required to provide a theme.
type ThemeProvider interface {
Open(path string) (ReadSeekCloser, time.Time, error)
Open(path string) (io.ReadSeekCloser, time.Time, error)
Maintainer
}
@ -106,7 +99,7 @@ type MothRequestHandler struct {
// PuzzlesOpen opens a file associated with a puzzle.
// 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)
found := false
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.
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)
}

View File

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

View File

@ -15,7 +15,6 @@ import (
"time"
"github.com/dirtbags/moth/pkg/award"
"github.com/spf13/afero"
)
// 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.
// The only thing State methods need to know is the path to the state directory.
type State struct {
afero.Fs
basedir string
// Enabled tracks whether the current State system is processing updates
Enabled bool
@ -42,7 +41,7 @@ type State struct {
refreshNow chan bool
eventStream chan []string
eventWriter *csv.Writer
eventWriterFile afero.File
eventWriterFile *os.File
// Caches, so we're not hammering NFS with metadata operations
teamNames map[string]string
@ -52,9 +51,9 @@ type State struct {
}
// NewState returns a new State struct backed by the given Fs
func NewState(fs afero.Fs) *State {
func NewState(basedir string) *State {
s := &State{
Fs: fs,
basedir: basedir,
Enabled: true,
refreshNow: make(chan bool, 5),
eventStream: make(chan []string, 80),
@ -67,12 +66,17 @@ func NewState(fs afero.Fs) *State {
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".
func (s *State) updateEnabled() {
nextEnabled := true
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()
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
why = "`state/enabled` missing"
}
@ -141,7 +145,7 @@ func (s *State) TeamName(teamID string) (string, error) {
// SetTeamName writes out team name.
// This can only be done once per team.
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 {
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)
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) {
return ErrAlreadyRegistered
} 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)
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
}
if err := s.Rename(tmpfn, newfn); err != nil {
if err := os.Rename(s.path(tmpfn), newfn); err != nil {
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,
// removing each points.new/ file as it goes.
func (s *State) collectPoints() {
files, err := afero.ReadDir(s, "points.new")
files, err := os.ReadDir(s.path("points.new"))
if err != nil {
log.Print(err)
return
}
for _, f := range files {
filename := filepath.Join("points.new", f.Name())
awardstr, err := afero.ReadFile(s, filename)
awardstr, err := os.ReadFile(s.path(filename))
if err != nil {
log.Print("Opening new points: ", err)
continue
@ -270,7 +274,7 @@ func (s *State) collectPoints() {
} else {
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 {
log.Print("Can't append to points log: ", err)
return
@ -284,7 +288,7 @@ func (s *State) collectPoints() {
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)
}
}
@ -292,7 +296,7 @@ func (s *State) collectPoints() {
func (s *State) maybeInitialize() {
// 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
}
@ -300,14 +304,14 @@ func (s *State) maybeInitialize() {
log.Print("initialized file missing, re-initializing")
// Remove any extant control and state files
s.Remove("enabled")
s.Remove("hours.txt")
s.Remove("points.log")
s.Remove("messages.html")
s.Remove("mothd.log")
s.RemoveAll("points.tmp")
s.RemoveAll("points.new")
s.RemoveAll("teams")
os.Remove(s.path("enabled"))
os.Remove(s.path("hours.txt"))
os.Remove(s.path("points.log"))
os.Remove(s.path("messages.html"))
os.Remove(s.path("mothd.log"))
os.RemoveAll(s.path("points.tmp"))
os.RemoveAll(s.path("points.new"))
os.RemoveAll(s.path("teams"))
// Open log file
if err := s.reopenEventLog(); err != nil {
@ -316,12 +320,12 @@ func (s *State) maybeInitialize() {
s.LogEvent("init", "", "", "", 0)
// Make sure various subdirectories exist
s.Mkdir("points.tmp", 0755)
s.Mkdir("points.new", 0755)
s.Mkdir("teams", 0755)
os.Mkdir(s.path("points.tmp"), 0755)
os.Mkdir(s.path("points.new"), 0755)
os.Mkdir(s.path("teams"), 0755)
// 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)
for i := 0; i < 100; i++ {
for i := range id {
@ -334,19 +338,19 @@ func (s *State) maybeInitialize() {
}
// 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)
fmt.Fprintln(f, "This instance was initialized at", now)
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.")
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, "#")
fmt.Fprintln(f, "# Enable: + timestamp")
@ -361,12 +365,12 @@ func (s *State) maybeInitialize() {
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. -->")
f.Close()
}
if f, err := s.Create("points.log"); err == nil {
if f, err := os.Create(s.path("points.log")); err == nil {
f.Close()
}
}
@ -396,7 +400,7 @@ func (s *State) reopenEventLog() error {
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 {
return err
}
@ -409,7 +413,7 @@ func (s *State) updateCaches() {
s.lock.Lock()
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)
} else {
defer f.Close()
@ -434,13 +438,12 @@ func (s *State) updateCaches() {
delete(s.teamNames, k)
}
teamsFs := afero.NewBasePathFs(s.Fs, "teams")
if dirents, err := afero.ReadDir(teamsFs, "."); err != nil {
if dirents, err := os.ReadDir(s.path("teams")); err != nil {
log.Printf("Reading team ids: %v", err)
} else {
for _, dirent := range dirents {
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)
} else {
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)
}
}

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

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

View File

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

View File

@ -2,25 +2,12 @@ package main
import (
"io/ioutil"
"os"
"testing"
"github.com/spf13/afero"
)
func NewTestTheme() *Theme {
return NewTheme(new(afero.MemMapFs))
}
func TestTheme(t *testing.T) {
s := NewTestTheme()
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)
}
s := NewTheme("testdata/theme")
if f, timestamp, err := s.Open("/index.html"); err != nil {
t.Error(err)
@ -28,7 +15,9 @@ func TestTheme(t *testing.T) {
t.Error(err)
} else if string(buf) != 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")
}

View File

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

View File

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

View File

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

View File

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

View File

@ -44,7 +44,8 @@ or with `POST` as `application/x-www-form-encoded` data.
Returns the current Moth event state as a JSON object.
### Parameters
* `id`: team ID (optional)
* `userid`: user ID (optional)
* `teamid`: team ID (optional)
### Return
@ -127,8 +128,9 @@ For this reason "this team is already registered"
does not return an error.
### Parameters
* `id`: team ID
* `name`: team name
* `userid`: user ID (optional)
* `teamid`: team ID
* `teamname`: team name
### Return
@ -153,7 +155,7 @@ POST /register HTTP/1.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 26
id=b387ca98&name=dirtbags
teamid=b387ca98&teamname=dirtbags
```
#### Repsonse
@ -174,7 +176,8 @@ Submits an answer for points.
If the answer is wrong, no points are awarded 😉
### Parameters
* `id`: team ID
* `userid`: user ID (optional)
* `teamid`: team ID
* `category`: along with `points`, 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.
### Parameters
* `userid`: user ID (optional)
* `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle
* `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle
* `{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.
### Parameters
* `userid`: user ID (optional)
* `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle
* `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle
* `{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.
```
## `/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

2
docs/internals.md Normal file
View File

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

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

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

3
go.mod
View File

@ -3,10 +3,9 @@ module github.com/dirtbags/moth
go 1.13
require (
github.com/go-redis/redis/v8 v8.11.4
github.com/kr/text v0.2.0 // indirect
github.com/spf13/afero v1.5.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/yaml.v2 v2.4.0
)

76
go.sum
View File

@ -1,5 +1,31 @@
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/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/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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/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/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/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
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/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.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/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
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=
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-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-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-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.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
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/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/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/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/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.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

28
pkg/subfs/subfs.go Normal file
View File

@ -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
}

26
pkg/subfs/subfs_test.go Normal file
View File

@ -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")
}
}

1
pkg/subfs/testdata/moo.txt vendored Normal file
View File

@ -0,0 +1 @@
moo.

1
pkg/subfs/testdata/subdir/moo2.txt vendored Normal file
View File

@ -0,0 +1 @@
moo too.

View File

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

View File

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

View File

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

View File

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