Neale Pickett
·
2025-03-10
httpd.go
1package main
2
3import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "html/template"
9 "io"
10 "io/fs"
11 "net"
12 "net/http"
13 "net/url"
14 "os"
15 "path"
16 "path/filepath"
17 "slices"
18 "strings"
19
20 "github.com/dirtbags/pupate/docs"
21 "github.com/dirtbags/pupate/internal/pupate"
22)
23
24// StateExport is given to clients requesting the current state.
25//
26// This is a subset of the full MOTHv5 state export,
27// only enough for a client to be able to render a puzzles list.
28type StateExport struct {
29 Errors []string
30 Puzzles map[string][]string
31 TeamData map[string]string
32}
33
34// IsHiddenDir tests to see if a directory is skipped in GetStateCurrent.
35//
36// Unix and MOTHv5 both have a hidden directory name character.
37func IsHiddenDir(name string) bool {
38 if name == "" {
39 return true
40 }
41 switch name[0] {
42 case '.':
43 return true
44 case '_':
45 return true
46 }
47 return false
48}
49
50// GetStateCurrent serves StateExport based on the contents of s.OutDir.
51//
52// By reading the filesystem instead of memory, we provide two desirable properties:
53//
54// 1. Served files can be inspected on disk by the operator
55// 2. No locks required, nor chan chan communication
56func (ctx *Context) GetStateCurrent(w http.ResponseWriter, req *http.Request) {
57 export := StateExport{
58 Puzzles: make(map[string][]string),
59 TeamData: map[string]string{"self": "Debug Team"},
60 }
61 export.Errors = make([]string, len(ctx.buildErrors))
62 for i, err := range ctx.buildErrors {
63 export.Errors[i] = err.Error()
64 }
65
66 catDents, err := os.ReadDir(ctx.OutDir)
67 if err != nil {
68 http.Error(w, err.Error(), http.StatusInternalServerError)
69 return
70 }
71
72 for _, catDent := range catDents {
73 if !catDent.IsDir() {
74 continue
75 }
76 name := catDent.Name()
77 if IsHiddenDir(name) {
78 continue
79 }
80 path := filepath.Join(ctx.OutDir, name)
81
82 puzzleDents, err := os.ReadDir(path)
83 if err != nil {
84 http.Error(w, err.Error(), http.StatusInternalServerError)
85 return
86 }
87 pupate.DirEntrySort(puzzleDents)
88 slices.Reverse(puzzleDents)
89
90 puzzles := make([]string, 0, len(puzzleDents))
91 for _, dent := range puzzleDents {
92 if !dent.IsDir() {
93 continue
94 }
95 puzzleName := dent.Name()
96 if IsHiddenDir(puzzleName) {
97 continue
98 }
99 puzzles = append(puzzles, puzzleName)
100 }
101
102 export.Puzzles[name] = puzzles
103 }
104
105 payload, err := json.Marshal(export)
106 if err != nil {
107 http.Error(w, err.Error(), http.StatusInternalServerError)
108 return
109 }
110
111 w.Header().Set("Content-Type", "application/json")
112 w.Header().Set("Cache-Control", "no-cache")
113 w.Write(payload)
114}
115
116func (ctx *Context) GetUpdates(w http.ResponseWriter, req *http.Request) {
117 wf, ok := w.(http.Flusher)
118 if !ok {
119 http.Error(w, "No output Flusher available", http.StatusInternalServerError)
120 return
121 }
122
123 w.Header().Set("Content-Type", "text/event-stream")
124 w.Header().Set("Cache-Control", "no-cache")
125 w.WriteHeader(http.StatusOK)
126 wf.Flush()
127
128 events := make(chan []string, 5)
129 ctx.addListener <- events
130 for event := range events {
131 fmt.Fprintln(w, "event: update")
132 for _, d := range event {
133 fmt.Fprintln(w, "data:", d)
134 }
135 fmt.Fprintln(w)
136 wf.Flush()
137 ctx.addListener <- events
138 }
139}
140
141// DocTemplate used to render documentation
142var DocTemplate = template.Must(
143 template.New("docs.HtmlTemplate").Funcs(pupate.VariantBaseFuncs).Parse(docs.HtmlTemplateStr),
144)
145
146// Doc provides the "dot" for documentation templates
147type Doc struct {
148 PupateVersion string
149 PupateDate string
150
151 Name string
152 Title string
153 Content string
154}
155
156func (ctx *Context) ServeDocs(w http.ResponseWriter, req *http.Request) {
157 name := req.PathValue("name")
158 if name == "" {
159 name = "index.md"
160 }
161
162 doc := &Doc{
163 Name: name,
164 PupateVersion: Version,
165 PupateDate: VersionDate,
166 }
167
168 // Not a markdown file
169 if !strings.HasSuffix(name, ".md") {
170 http.ServeFileFS(w, req, ctx.DocsFS, name)
171 return
172 }
173
174 src, err := ctx.DocsFS.Open(name)
175 if err != nil {
176 http.NotFound(w, req)
177 return
178 }
179 defer src.Close()
180
181 content, err := pupate.PageRead(src, doc)
182 if err != nil {
183 http.Error(w, err.Error(), http.StatusInternalServerError)
184 return
185 }
186 doc.Content = content
187
188 html := new(bytes.Buffer)
189 if err := DocTemplate.Execute(html, doc); err != nil {
190 http.Error(w, err.Error(), http.StatusInternalServerError)
191 return
192 }
193
194 w.Header().Set("Content-type", "text/html; charset=utf-8")
195 io.Copy(w, html)
196}
197
198func (ctx *Context) ServeRoot(w http.ResponseWriter, req *http.Request) {
199 upath := req.URL.Path
200 if !strings.HasPrefix(upath, "/") {
201 upath = "/" + upath
202 req.URL.Path = upath
203 }
204 if upath == "/" {
205 upath = "/index.html"
206 }
207 upath = path.Clean(upath)
208
209 if _, err := fs.Stat(ctx.WebFS, upath[1:]); errors.Is(err, fs.ErrNotExist) {
210 // Fall back to VariantFS
211 http.ServeFileFS(w, req, ctx.VariantFS, "/web" + upath)
212 return
213 }
214 http.ServeFileFS(w, req, ctx.WebFS, upath)
215}
216
217func (ctx *Context) ServeHTTP(listen string) error {
218 h := NewHTTPLogMux()
219
220 puzzleFS := http.Dir(ctx.OutDir)
221 h.HandleFunc("/", ctx.ServeRoot)
222 h.Handle("/-/puzzles/", http.StripPrefix("/-/puzzles/", http.FileServer(puzzleFS)))
223 h.HandleFunc("/docs/{name...}", ctx.ServeDocs)
224 h.HandleFunc("/-/state/current.json", ctx.GetStateCurrent)
225 h.HandleFunc("/-/state/updates", ctx.GetUpdates)
226 h.HandleFunc("PUT /-/state/teamdata", ctx.GetStateCurrent) // all login attempts succeed
227
228 ln, err := net.Listen("tcp", listen)
229 if err != nil {
230 return err
231 }
232 ctx.MyURL = url.URL{
233 Scheme: "http",
234 Host: ln.Addr().String(),
235 }
236
237 go ctx.WatchAndRebuild()
238 go http.Serve(ln, h)
239
240 return nil
241}