1package app23import (4 "encoding/json"5 "io"6 "log/slog"7 "net/http"8 "sort"9 "strconv"10 "strings"1112 "github.com/FelineStateMachine/atlas/format/bundle"13 "github.com/FelineStateMachine/atlas/internal/app/hostenv"14 "github.com/FelineStateMachine/atlas/internal/logging"15)1617// The data plane. Its shape is not a choice this rewrite gets to make: the18// seam reads it, and it stays byte-compatible with the implementation this19// one replaces (issue #5 §4.2, docs/app.md §2.1). app_test.go holds this20// file to that shape.2122// BasePath is the URL prefix volume content is served under. It appears in the23// composed catalog so nothing has to assemble a content URL from parts it had24// to guess.25const BasePath = "/data/v"2627// contentTypes names the content types the bundle layout can hold, and is the28// gate on what may be served at all: an entry whose extension is not named29// here is not served, whatever it is. Responses carry nosniff, so a response30// must say what it is or a browser drops it.31var contentTypes = map[string]string{32 ".json": "application/json",33 ".text": "application/json",34 ".bin": "application/octet-stream",35 ".jpg": "image/jpeg",36 ".png": "image/png",37 ".webp": "image/webp",38 ".svg": "image/svg+xml",39}4041// catalogVolume is one volume as the composed catalog lists it. The base URL42// carries the version stamp, so everything beneath it may be cached as43// immutable and a new build of the volume arrives at new URLs.44//45// The field order and the tags are the wire, and the wire is pinned by46// TestCatalogComposition and TestCatalogCarriesTheBuildAndItsGrid.47type catalogVolume struct {48 Slug string `json:"slug"`49 Title string `json:"title"`50 Stamp string `json:"stamp"`51 Base string `json:"base"`52 TileGrid bundle.TileGrid `json:"tileGrid"`53 Worlds []bundle.WorldEntry `json:"worlds"`54}5556// catalogDoc is the whole catalog. BundlesDir rides along so an empty library57// can tell the reader where a bundle goes, in the words of their own machine;58// it is whatever the store calls its location and the handler never reads it.59type catalogDoc struct {60 Volumes []catalogVolume `json:"volumes"`61 BundlesDir string `json:"bundlesDir,omitempty"`62}6364// handleCatalog answers with the library as it stands. It is composed per65// request and never cached: it is the one response whose whole job is to be66// current.67func (a *App) handleCatalog(w http.ResponseWriter, r *http.Request) {68 body, err := composeCatalog(a.library().order, a.env.Volumes().Location())69 if err != nil {70 // Nothing in a validated manifest can fail to marshal. If something71 // does, an empty catalog is a saner face than a panic.72 slog.Error("composing the catalog", logging.Op("catalog"), slog.Any("error", err))73 body = []byte(`{"volumes":[]}`)74 }75 w.Header().Set("Content-Type", "application/json")76 w.Header().Set("Cache-Control", "no-store")77 _, _ = w.Write(body)78}7980// composeCatalog folds the serving volumes into the catalog document.81// Volumes are listed by title, which is the order a reader meets them in.82func composeCatalog(volumes []hostenv.Volume, location string) ([]byte, error) {83 listed := make([]catalogVolume, 0, len(volumes))84 for _, volume := range volumes {85 manifest := volume.Manifest()86 listed = append(listed, catalogVolume{87 Slug: manifest.Volume.Slug,88 Title: manifest.Volume.Title,89 Stamp: manifest.Version.Stamp,90 Base: volumeBase(manifest),91 TileGrid: manifest.TileGrid,92 Worlds: manifest.Worlds,93 })94 }95 sort.Slice(listed, func(i, j int) bool { return listed[i].Title < listed[j].Title })96 return json.Marshal(catalogDoc{Volumes: listed, BundlesDir: location})97}9899// volumeBase is where one build's content is served from.100func volumeBase(m bundle.Manifest) string {101 return BasePath + "/" + m.Volume.Slug + "/" + bundle.ShortStamp(m.Version.Stamp)102}103104// handleContent serves one entry out of one build.105//106// The stamp in the URL is the whole cache story: it names exactly one build of107// one volume, so the answer may be cached forever, and the moment a newer108// build takes the slug over the old URL is gone -- 404, which is the client's109// cue to refetch the catalog.110//111// Byte ranges are deliberately not served. Tiles are stored uncompressed112// precisely so that they could be, and issue #5 §2 describes them that way,113// but the data plane's published behavior is plainer: a Range request is114// answered 200 with the whole body and no Accept-Ranges, and115// TestContentDoesNotServeRanges asserts exactly that exchange. Answering 206116// would be a better data plane and a different one; it is a change to make117// deliberately, in the open, once something wants it.118func (a *App) handleContent(w http.ResponseWriter, r *http.Request) {119 held, ok := a.library().bySlug[r.PathValue("slug")]120 if !ok {121 http.NotFound(w, r)122 return123 }124 manifest := held.Manifest()125 if r.PathValue("stamp") != bundle.ShortStamp(manifest.Version.Stamp) {126 http.NotFound(w, r)127 return128 }129130 rest := r.PathValue("rest")131 if !strings.HasPrefix(rest, bundle.WorldsPrefix) &&132 !strings.HasPrefix(rest, bundle.TilesPrefix) &&133 !strings.HasPrefix(rest, bundle.IconsPrefix) {134 http.NotFound(w, r)135 return136 }137 dot := strings.LastIndexByte(rest, '.')138 if dot < 0 {139 http.NotFound(w, r)140 return141 }142 kind, ok := contentTypes[rest[dot:]]143 if !ok {144 http.NotFound(w, r)145 return146 }147148 entry, size, err := held.Open(rest)149 if err != nil {150 http.NotFound(w, r)151 return152 }153 defer entry.Close()154 w.Header().Set("Content-Type", kind)155 w.Header().Set("Content-Length", strconv.FormatInt(size, 10))156 w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")157 if _, err := io.Copy(w, entry); err != nil {158 // The response is already on the wire; there is nothing to say to the159 // client, and a reader who navigated away is the usual cause.160 slog.Debug("content cut short", logging.Op("serve"),161 logging.Volume(manifest.Volume.Slug), logging.Path(rest), slog.Any("error", err))162 }163}