pupate

Puzzle transpiler
git clone https://git.woozle.org/neale/pupate.git

pupate / cmd / cocoon
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}