From fa5ea87f2291e80f5a7f63cc29c31ba6f34e8c6d Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 10 May 2022 13:20:54 -0600 Subject: [PATCH] A ton of half-baked changes --- cmd/mothd/httpd_test.go | 27 ++-- cmd/mothd/main.go | 11 +- cmd/mothd/mothballs.go | 85 ++++++----- cmd/mothd/mothballs_test.go | 49 ++++-- cmd/mothd/providercommand.go | 2 +- cmd/mothd/server.go | 15 +- cmd/mothd/server_test.go | 46 ++++-- cmd/mothd/state.go | 81 +++++----- cmd/mothd/testdata/theme/index.html | 1 + cmd/mothd/theme.go | 15 +- cmd/mothd/theme_test.go | 21 +-- cmd/mothd/transpiler.go | 6 +- cmd/mothd/transpiler_test.go | 6 +- cmd/transpile/main.go | 33 ++-- cmd/transpile/main_test.go | 62 +++++--- docs/api.md | 41 ++++- docs/internals.md | 2 + docs/user-tracking.md | 70 +++++++++ go.mod | 3 +- go.sum | 76 ++++++++++ pkg/microchat/alfio.go | 47 ++++++ pkg/microchat/cache.go | 50 ++++++ pkg/microchat/hmac.go | 51 +++++++ pkg/microchat/message.go | 10 ++ pkg/microchat/microchat.go | 228 ++++++++++++++++++++++++++++ pkg/microchat/noauth.go | 18 +++ pkg/microchat/throttle.go | 37 +++++ pkg/namesubfs/namesubfs.go | 51 +++++++ pkg/namesubfs/namesubfs_test.go | 39 +++++ pkg/transpile/basepath.go | 72 --------- pkg/transpile/category.go | 50 ++---- pkg/transpile/category_test.go | 13 +- pkg/transpile/puzzle.go | 47 +++--- 33 files changed, 1020 insertions(+), 345 deletions(-) create mode 100644 cmd/mothd/testdata/theme/index.html create mode 100644 docs/internals.md create mode 100644 docs/user-tracking.md create mode 100644 pkg/microchat/alfio.go create mode 100644 pkg/microchat/cache.go create mode 100644 pkg/microchat/hmac.go create mode 100644 pkg/microchat/message.go create mode 100644 pkg/microchat/microchat.go create mode 100644 pkg/microchat/noauth.go create mode 100644 pkg/microchat/throttle.go create mode 100644 pkg/namesubfs/namesubfs.go create mode 100644 pkg/namesubfs/namesubfs_test.go delete mode 100644 pkg/transpile/basepath.go diff --git a/cmd/mothd/httpd_test.go b/cmd/mothd/httpd_test.go index fc4ae9a..64538c7 100644 --- a/cmd/mothd/httpd_test.go +++ b/cmd/mothd/httpd_test.go @@ -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 { diff --git a/cmd/mothd/main.go b/cmd/mothd/main.go index 32a1cc4..4a5447b 100644 --- a/cmd/mothd/main.go +++ b/cmd/mothd/main.go @@ -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) } diff --git a/cmd/mothd/mothballs.go b/cmd/mothd/mothballs.go index f204975..8439b99 100644 --- a/cmd/mothd/mothballs.go +++ b/cmd/mothd/mothballs.go @@ -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) } } diff --git a/cmd/mothd/mothballs_test.go b/cmd/mothd/mothballs_test.go index 61bcd82..5d17afa 100644 --- a/cmd/mothd/mothballs_test.go +++ b/cmd/mothd/mothballs_test.go @@ -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 { diff --git a/cmd/mothd/providercommand.go b/cmd/mothd/providercommand.go index 1bdd693..9157aac 100644 --- a/cmd/mothd/providercommand.go +++ b/cmd/mothd/providercommand.go @@ -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() diff --git a/cmd/mothd/server.go b/cmd/mothd/server.go index 608e6d6..1a28048 100644 --- a/cmd/mothd/server.go +++ b/cmd/mothd/server.go @@ -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) } diff --git a/cmd/mothd/server_test.go b/cmd/mothd/server_test.go index 6b6e621..68362a5 100644 --- a/cmd/mothd/server_test.go +++ b/cmd/mothd/server_test.go @@ -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") diff --git a/cmd/mothd/state.go b/cmd/mothd/state.go index 9d11621..db3a1e4 100644 --- a/cmd/mothd/state.go +++ b/cmd/mothd/state.go @@ -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, "") 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) } } diff --git a/cmd/mothd/testdata/theme/index.html b/cmd/mothd/testdata/theme/index.html new file mode 100644 index 0000000..aec685f --- /dev/null +++ b/cmd/mothd/testdata/theme/index.html @@ -0,0 +1 @@ +this is the index \ No newline at end of file diff --git a/cmd/mothd/theme.go b/cmd/mothd/theme.go index a70ca32..774542a 100644 --- a/cmd/mothd/theme.go +++ b/cmd/mothd/theme.go @@ -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 } diff --git a/cmd/mothd/theme_test.go b/cmd/mothd/theme_test.go index 3025daa..d52189b 100644 --- a/cmd/mothd/theme_test.go +++ b/cmd/mothd/theme_test.go @@ -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") } diff --git a/cmd/mothd/transpiler.go b/cmd/mothd/transpiler.go index 7103b20..bb3ca72 100644 --- a/cmd/mothd/transpiler.go +++ b/cmd/mothd/transpiler.go @@ -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. diff --git a/cmd/mothd/transpiler_test.go b/cmd/mothd/transpiler_test.go index 8cd562e..6b17888 100644 --- a/cmd/mothd/transpiler_test.go +++ b/cmd/mothd/transpiler_test.go @@ -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 { diff --git a/cmd/transpile/main.go b/cmd/transpile/main.go index f225464..b2f7403 100644 --- a/cmd/transpile/main.go +++ b/cmd/transpile/main.go @@ -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 { diff --git a/cmd/transpile/main_test.go b/cmd/transpile/main_test.go index 042795a..1aa7938 100644 --- a/cmd/transpile/main_test.go +++ b/cmd/transpile/main_test.go @@ -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() diff --git a/docs/api.md b/docs/api.md index 45ee0c5..6245bba 100644 --- a/docs/api.md +++ b/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. ### 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 diff --git a/docs/internals.md b/docs/internals.md new file mode 100644 index 0000000..fc3b804 --- /dev/null +++ b/docs/internals.md @@ -0,0 +1,2 @@ +# Internal Structures + diff --git a/docs/user-tracking.md b/docs/user-tracking.md new file mode 100644 index 0000000..a40e71c --- /dev/null +++ b/docs/user-tracking.md @@ -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. + diff --git a/go.mod b/go.mod index b2e9f71..14666d5 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 8af9236..b2f18aa 100644 --- a/go.sum +++ b/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/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= diff --git a/pkg/microchat/alfio.go b/pkg/microchat/alfio.go new file mode 100644 index 0000000..661d386 --- /dev/null +++ b/pkg/microchat/alfio.go @@ -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 +} diff --git a/pkg/microchat/cache.go b/pkg/microchat/cache.go new file mode 100644 index 0000000..e08cf8e --- /dev/null +++ b/pkg/microchat/cache.go @@ -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 +} diff --git a/pkg/microchat/hmac.go b/pkg/microchat/hmac.go new file mode 100644 index 0000000..68c7195 --- /dev/null +++ b/pkg/microchat/hmac.go @@ -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") +} diff --git a/pkg/microchat/message.go b/pkg/microchat/message.go new file mode 100644 index 0000000..05c9929 --- /dev/null +++ b/pkg/microchat/message.go @@ -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 +} diff --git a/pkg/microchat/microchat.go b/pkg/microchat/microchat.go new file mode 100644 index 0000000..da06822 --- /dev/null +++ b/pkg/microchat/microchat.go @@ -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)) +} diff --git a/pkg/microchat/noauth.go b/pkg/microchat/noauth.go new file mode 100644 index 0000000..391802a --- /dev/null +++ b/pkg/microchat/noauth.go @@ -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 +} diff --git a/pkg/microchat/throttle.go b/pkg/microchat/throttle.go new file mode 100644 index 0000000..257c33a --- /dev/null +++ b/pkg/microchat/throttle.go @@ -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 +} diff --git a/pkg/namesubfs/namesubfs.go b/pkg/namesubfs/namesubfs.go new file mode 100644 index 0000000..ed0407e --- /dev/null +++ b/pkg/namesubfs/namesubfs.go @@ -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) +} diff --git a/pkg/namesubfs/namesubfs_test.go b/pkg/namesubfs/namesubfs_test.go new file mode 100644 index 0000000..da040f4 --- /dev/null +++ b/pkg/namesubfs/namesubfs_test.go @@ -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")) + } +} diff --git a/pkg/transpile/basepath.go b/pkg/transpile/basepath.go deleted file mode 100644 index ec9e469..0000000 --- a/pkg/transpile/basepath.go +++ /dev/null @@ -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 -} diff --git a/pkg/transpile/category.go b/pkg/transpile/category.go index 0891d7e..dd9f9c8 100644 --- a/pkg/transpile/category.go +++ b/pkg/transpile/category.go @@ -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 { diff --git a/pkg/transpile/category_test.go b/pkg/transpile/category_test.go index e8fcbed..c559e2e 100644 --- a/pkg/transpile/category_test.go +++ b/pkg/transpile/category_test.go @@ -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) diff --git a/pkg/transpile/puzzle.go b/pkg/transpile/puzzle.go index 44d0ab5..bd5687d 100644 --- a/pkg/transpile/puzzle.go +++ b/pkg/transpile/puzzle.go @@ -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 }