Neale Pickett
·
2023-09-29
server.go
1package main
2
3import (
4 "fmt"
5 "io"
6 "strconv"
7 "time"
8
9 "github.com/dirtbags/moth/v4/pkg/award"
10)
11
12// Category represents a puzzle category.
13type Category struct {
14 Name string
15 Puzzles []int
16}
17
18// ReadSeekCloser defines a struct that can read, seek, and close.
19type ReadSeekCloser interface {
20 io.Reader
21 io.Seeker
22 io.Closer
23}
24
25// Configuration stores information about server configuration.
26type Configuration struct {
27 Devel bool
28}
29
30// StateExport is given to clients requesting the current state.
31type StateExport struct {
32 Config Configuration
33 Enabled bool
34 TeamNames map[string]string
35 PointsLog award.List
36 Puzzles map[string][]int
37}
38
39// PuzzleProvider defines what's required to provide puzzles.
40type PuzzleProvider interface {
41 Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error)
42 Inventory() []Category
43 CheckAnswer(cat string, points int, answer string) (bool, error)
44 Mothball(cat string, w io.Writer) error
45 Maintainer
46}
47
48// ThemeProvider defines what's required to provide a theme.
49type ThemeProvider interface {
50 Open(path string) (ReadSeekCloser, time.Time, error)
51 Maintainer
52}
53
54// StateProvider defines what's required to provide MOTH state.
55type StateProvider interface {
56 Enabled() bool
57 PointsLog() award.List
58 TeamName(teamID string) (string, error)
59 SetTeamName(teamID, teamName string) error
60 AwardPoints(teamID string, cat string, points int) error
61 LogEvent(event, teamID, cat string, points int, extra ...string)
62 Maintainer
63}
64
65// Maintainer is something that can be maintained.
66type Maintainer interface {
67 // Maintain is the maintenance loop.
68 // It will only be called once, when execution begins.
69 // It's okay to just exit if there's no maintenance to be done.
70 Maintain(updateInterval time.Duration)
71
72 // refresh is a shortcut used internally for testing
73 refresh()
74}
75
76// MothServer gathers together the providers that make up a MOTH server.
77type MothServer struct {
78 PuzzleProviders []PuzzleProvider
79 Theme ThemeProvider
80 State StateProvider
81 Config Configuration
82}
83
84// NewMothServer returns a new MothServer.
85func NewMothServer(config Configuration, theme ThemeProvider, state StateProvider, puzzleProviders ...PuzzleProvider) *MothServer {
86 return &MothServer{
87 Config: config,
88 PuzzleProviders: puzzleProviders,
89 Theme: theme,
90 State: state,
91 }
92}
93
94// NewHandler returns a new http.RequestHandler for the provided teamID.
95func (s *MothServer) NewHandler(teamID string) MothRequestHandler {
96 return MothRequestHandler{
97 MothServer: s,
98 teamID: teamID,
99 }
100}
101
102// MothRequestHandler provides http.RequestHandler for a MothServer.
103type MothRequestHandler struct {
104 *MothServer
105 teamID string
106}
107
108// PuzzlesOpen opens a file associated with a puzzle.
109// BUG(neale): Multiple providers with the same category name are not detected or handled well.
110func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r ReadSeekCloser, ts time.Time, err error) {
111 export := mh.exportStateIfRegistered(true)
112 found := false
113 for _, p := range export.Puzzles[cat] {
114 if p == points {
115 found = true
116 }
117 }
118 if !found {
119 return nil, time.Time{}, fmt.Errorf("puzzle does not exist or is locked")
120 }
121
122 // Try every provider until someone doesn't return an error
123 for _, provider := range mh.PuzzleProviders {
124 r, ts, err = provider.Open(cat, points, path)
125 if err != nil {
126 return r, ts, err
127 }
128 }
129
130 // Log puzzle.json loads
131 if path == "puzzle.json" {
132 mh.State.LogEvent("load", mh.teamID, cat, points)
133 }
134
135 return
136}
137
138// CheckAnswer returns an error if answer is not a correct answer for puzzle points in category cat
139func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string) error {
140 correct := false
141 for _, provider := range mh.PuzzleProviders {
142 if ok, err := provider.CheckAnswer(cat, points, answer); err != nil {
143 return err
144 } else if ok {
145 correct = true
146 }
147 }
148 if !correct {
149 mh.State.LogEvent("wrong", mh.teamID, cat, points)
150 return fmt.Errorf("incorrect answer")
151 }
152
153 mh.State.LogEvent("correct", mh.teamID, cat, points)
154
155 if _, err := mh.State.TeamName(mh.teamID); err != nil {
156 return fmt.Errorf("invalid team ID")
157 }
158 if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
159 return err
160 }
161
162 return nil
163}
164
165// ThemeOpen opens a file from a theme.
166func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, error) {
167 return mh.Theme.Open(path)
168}
169
170// Register associates a team name with a team ID.
171func (mh *MothRequestHandler) Register(teamName string) error {
172 if teamName == "" {
173 return fmt.Errorf("empty team name")
174 }
175 mh.State.LogEvent("register", mh.teamID, "", 0)
176 return mh.State.SetTeamName(mh.teamID, teamName)
177}
178
179// ExportState anonymizes team IDs and returns StateExport.
180// If a teamID has been specified for this MothRequestHandler,
181// the anonymized team name for this teamID has the special value "self".
182// If not, the puzzles list is empty.
183func (mh *MothRequestHandler) ExportState() *StateExport {
184 return mh.exportStateIfRegistered(false)
185}
186
187// Export state, replacing the team ID with "self" if the team is registered.
188//
189// If forceRegistered is true, go ahead and export it anyway
190func (mh *MothRequestHandler) exportStateIfRegistered(forceRegistered bool) *StateExport {
191 export := StateExport{}
192 export.Config = mh.Config
193
194 teamName, err := mh.State.TeamName(mh.teamID)
195 registered := forceRegistered || mh.Config.Devel || (err == nil)
196
197 export.Enabled = mh.State.Enabled()
198 export.TeamNames = make(map[string]string)
199
200 // Anonymize team IDs in points log, and write out team names
201 pointsLog := mh.State.PointsLog()
202 exportIDs := make(map[string]string)
203 maxSolved := make(map[string]int)
204 export.PointsLog = make(award.List, len(pointsLog))
205
206 if registered {
207 export.TeamNames["self"] = teamName
208 exportIDs[mh.teamID] = "self"
209 }
210 for logno, awd := range pointsLog {
211 if id, ok := exportIDs[awd.TeamID]; ok {
212 awd.TeamID = id
213 } else {
214 exportID := strconv.Itoa(logno)
215 name, _ := mh.State.TeamName(awd.TeamID)
216 exportIDs[awd.TeamID] = exportID
217 awd.TeamID = exportID
218 export.TeamNames[exportID] = name
219 }
220 export.PointsLog[logno] = awd
221
222 // Record the highest-value unlocked puzzle in each category
223 if awd.Points > maxSolved[awd.Category] {
224 maxSolved[awd.Category] = awd.Points
225 }
226 }
227
228 export.Puzzles = make(map[string][]int)
229 if registered {
230 // We used to hand this out to everyone,
231 // but then we got a bad reputation on some secretive blacklist,
232 // and now the Navy can't register for events.
233 for _, provider := range mh.PuzzleProviders {
234 for _, category := range provider.Inventory() {
235 // Append sentry (end of puzzles)
236 allPuzzles := append(category.Puzzles, 0)
237
238 max := maxSolved[category.Name]
239
240 puzzles := make([]int, 0, len(allPuzzles))
241 for i, val := range allPuzzles {
242 puzzles = allPuzzles[:i+1]
243 if !mh.Config.Devel && (val > max) {
244 break
245 }
246 }
247 export.Puzzles[category.Name] = puzzles
248 }
249 }
250 }
251
252 return &export
253}
254
255// Mothball generates a mothball for the given category.
256func (mh *MothRequestHandler) Mothball(cat string, w io.Writer) error {
257 var err error
258
259 if !mh.Config.Devel {
260 return fmt.Errorf("cannot mothball in production mode")
261 }
262 for _, provider := range mh.PuzzleProviders {
263 if err = provider.Mothball(cat, w); err == nil {
264 return nil
265 }
266 }
267 return err
268}