clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

archive/golden-reference
atlas / cmd / cartograph / server.go
server.go 6.1K
1package main23import (4 "bytes"5 "embed"6 "fmt"7 "html/template"8 "log/slog"9 "net/http"10 "strings"11)1213//go:embed ui14var uiFS embed.FS1516// server is the dashboard: read-only views over the library, and the17// operations panel that runs the pipeline's tools on request.18type server struct {19 library *library20 shop *workshop21 pages map[string]*template.Template22}2324func newServer(lib *library, shop *workshop) *server {25 pages := make(map[string]*template.Template)26 for _, name := range []string{"collection", "volume", "diff", "sources"} {27 pages[name] = template.Must(template.ParseFS(uiFS, "ui/layout.tmpl", "ui/"+name+".tmpl"))28 }29 return &server{library: lib, shop: shop, pages: pages}30}3132func (s *server) routes() http.Handler {33 mux := http.NewServeMux()34 mux.HandleFunc("GET /{$}", s.handleCollection)35 mux.HandleFunc("GET /volume/{slug}", s.handleVolume)36 mux.HandleFunc("GET /volume/{slug}/diff", s.handleDiff)37 mux.HandleFunc("GET /sources", s.handleSources)38 mux.HandleFunc("POST /ops/run", s.handleOps)39 mux.HandleFunc("GET /static/style.css", staticFile("ui/style.css", "text/css; charset=utf-8"))40 mux.HandleFunc("GET /static/app.js", staticFile("ui/app.js", "text/javascript; charset=utf-8"))41 return mux42}4344// render executes a page into a buffer first, so a template error becomes a45// clean 500 rather than half a page. Every page carries a policy that keeps46// it self-contained: nothing loads from anywhere but this server.47func (s *server) render(w http.ResponseWriter, page string, data any) {48 var body bytes.Buffer49 if err := s.pages[page].ExecuteTemplate(&body, "layout", data); err != nil {50 slog.Error("cartograph: render", "page", page, "error", err)51 http.Error(w, "rendering failed: "+err.Error(), http.StatusInternalServerError)52 return53 }54 w.Header().Set("Content-Type", "text/html; charset=utf-8")55 w.Header().Set("Content-Security-Policy",56 "default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; form-action 'self'")57 w.Write(body.Bytes())58}5960func (s *server) handleCollection(w http.ResponseWriter, r *http.Request) {61 volumes, skipped, err := s.library.volumes()62 if err != nil {63 http.Error(w, err.Error(), http.StatusInternalServerError)64 return65 }66 s.render(w, "collection", struct {67 Dir string68 Volumes []*volume69 Skipped []string70 }{Dir: s.library.dir, Volumes: volumes, Skipped: skipped})71}7273func (s *server) handleVolume(w http.ResponseWriter, r *http.Request) {74 held, err := s.library.volumeBySlug(r.PathValue("slug"))75 if err != nil {76 http.Error(w, err.Error(), http.StatusInternalServerError)77 return78 }79 if held == nil {80 http.NotFound(w, r)81 return82 }83 s.render(w, "volume", struct{ Volume *volume }{Volume: held})84}8586// handleDiff compares two builds of one game, named by file. The packed pins87// are read here, on demand, because only a comparison ever needs them whole.88func (s *server) handleDiff(w http.ResponseWriter, r *http.Request) {89 held, err := s.library.volumeBySlug(r.PathValue("slug"))90 if err != nil {91 http.Error(w, err.Error(), http.StatusInternalServerError)92 return93 }94 if held == nil {95 http.NotFound(w, r)96 return97 }98 from, to := held.Build(r.FormValue("a")), held.Build(r.FormValue("b"))99 if from == nil || to == nil {100 http.Error(w, "pick two builds of this volume", http.StatusBadRequest)101 return102 }103 pinsA, err := loadPins(from.Path, from.MapSlugs)104 if err == nil {105 var pinsB map[string]map[int64]string106 if pinsB, err = loadPins(to.Path, to.MapSlugs); err == nil {107 s.render(w, "diff", struct {108 Volume *volume109 Diff *buildDiff110 }{Volume: held, Diff: diffBuilds(from, to, pinsA, pinsB)})111 return112 }113 }114 http.Error(w, err.Error(), http.StatusInternalServerError)115}116117func (s *server) handleSources(w http.ResponseWriter, r *http.Request) {118 s.render(w, "sources", struct {119 Sources []Source120 Repo string121 Archive string122 Bundles string123 }{Sources: sources, Repo: s.shop.repo, Archive: s.shop.archive, Bundles: s.shop.bundles})124}125126// handleOps runs one pipeline tool and streams its voice back as plain text.127// Every operation is explicit: it exists only because this request named it,128// and it dies with the request if the page is abandoned.129func (s *server) handleOps(w http.ResponseWriter, r *http.Request) {130 // A browser sends Origin on any cross-site POST; refusing a foreign one131 // keeps a stray web page from operating the workbench through the132 // visitor's browser.133 if origin := r.Header.Get("Origin"); origin != "" && origin != "http://"+r.Host {134 http.Error(w, "operations are submitted from the dashboard alone", http.StatusForbidden)135 return136 }137 if s.shop.repo == "" {138 http.Error(w, "no atlas repository found: start cartograph with -repo", http.StatusBadRequest)139 return140 }141 if s.shop.archive == "" {142 http.Error(w, "operations need an archive: start cartograph with -archive", http.StatusBadRequest)143 return144 }145146 var argv []string147 var err error148 switch op := r.FormValue("op"); op {149 case "fetch":150 src, ok := sourceBySlug(r.FormValue("source"))151 if !ok {152 http.Error(w, "unknown source "+r.FormValue("source"), http.StatusBadRequest)153 return154 }155 argv, err = fetchArgv(src, r.FormValue("target"), s.shop.archive)156 case "tiles":157 argv = tilesArgv(s.shop.archive)158 case "generate":159 argv, err = generateArgv(s.shop.archive, s.shop.bundles)160 default:161 http.Error(w, "unknown operation "+op, http.StatusBadRequest)162 return163 }164 if err != nil {165 http.Error(w, err.Error(), http.StatusBadRequest)166 return167 }168169 if !s.shop.busy.TryLock() {170 http.Error(w, "an operation is already running", http.StatusConflict)171 return172 }173 defer s.shop.busy.Unlock()174175 w.Header().Set("Content-Type", "text/plain; charset=utf-8")176 w.Header().Set("X-Content-Type-Options", "nosniff")177 out := flushWriter{w}178 fmt.Fprintf(out, "%s $ %s\n\n", s.shop.repo, strings.Join(argv, " "))179 if err := streamCommand(r.Context(), w, s.shop.repo, argv); err != nil {180 fmt.Fprintf(out, "\ncartograph: %v\n", err)181 return182 }183 fmt.Fprintf(out, "\ndone\n")184}185186func staticFile(name, contentType string) http.HandlerFunc {187 return func(w http.ResponseWriter, r *http.Request) {188 data, err := uiFS.ReadFile(name)189 if err != nil {190 http.NotFound(w, r)191 return192 }193 w.Header().Set("Content-Type", contentType)194 w.Write(data)195 }196}