package gallery import ( "crypto/sha256" "encoding/hex" "fmt" "io/fs" "path" "sort" "strings" ) // Every asset is written twice: under name.<16 hex of its sha256>.ext, // which every page and frame links, and under its plain name, which // nothing links, for hotlinks and the site's own checks. The tree is // served by a static edge that caches assets, and with plain names a // deploy could pair a new page with yesterday's cached tokens.css, a // page of new markup drawn by old rules. A name that changes whenever // the bytes do cannot be served stale. // The declaration. What the tree ships under assets/ is declared apart // from being written: the source's css/ and js/ files and the examples' // pictures, which assetNames reads off the source, and the gallery's // own files, which the file that owns each declares at init through // declareAsset. assets writes exactly the declared names, and the tree // gate expects exactly them, so an asset the render forgot and one it // wrote unasked both fail the gate. // assetSource is one declared asset of the gallery's own: its plain // name under assets/ and how to read its bytes. type assetSource struct { Name string Body func(s *site) ([]byte, error) } var declaredAssets []assetSource // declareAsset registers one of the gallery's own assets. Call it from // an init function in the file that owns the bytes. func declareAsset(name string, body func(s *site) ([]byte, error)) { declaredAssets = append(declaredAssets, assetSource{Name: name, Body: body}) } // assetNames is every plain name the tree ships under assets/, sorted: // css/ and js/ as the source has them, images/ for every file // under examples/assets, and the declared ones. It reads the source and // the declaration only, never what has been written. func (s *site) assetNames() ([]string, error) { var names []string for _, dir := range []string{"css", "js"} { err := fs.WalkDir(s.src, dir, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { names = append(names, p) } return nil }) if err != nil { return nil, err } } pictures, err := fs.ReadDir(s.src, "examples/assets") if err != nil { return nil, err } for _, e := range pictures { if !e.IsDir() { names = append(names, "images/"+e.Name()) } } for _, a := range declaredAssets { names = append(names, a.Name) } sort.Strings(names) for i := 1; i < len(names); i++ { if names[i] == names[i-1] { return nil, fmt.Errorf("the asset %s is declared twice", names[i]) } } return names, nil } // assetBody reads one declared asset's bytes: a source file by its path, // a picture from examples/assets, or the gallery's own through its // declaration. func (s *site) assetBody(name string) ([]byte, error) { for _, a := range declaredAssets { if a.Name == name { return a.Body(s) } } if strings.HasPrefix(name, "images/") { return fs.ReadFile(s.src, "examples/assets/"+strings.TrimPrefix(name, "images/")) } return fs.ReadFile(s.src, name) } // hashedName is the name an asset is linked by: its stem, sixteen hex // digits of the sha256 of its bytes, and its extension. func hashedName(name string, body []byte) string { sum := sha256.Sum256(body) ext := path.Ext(name) return strings.TrimSuffix(name, ext) + "." + hex.EncodeToString(sum[:8]) + ext } // putAsset writes one asset under assets/, plain and hashed, and // records the hashed name for the pages to link. func (s *site) putAsset(name string, body []byte) error { hashed := hashedName(name, body) s.hashed[name] = hashed if err := s.put("assets/"+name, body); err != nil { return err } return s.put("assets/"+hashed, append([]byte(nil), body...)) } // asset is the address a page links an asset by: the hashed name under // the mount. A name the tree does not ship panics, because it is a typo // in this package's own templates, and a page linking a file that is // not there renders unstyled with no other sign of it. func (s *site) asset(name string) string { hashed, ok := s.hashed[name] if !ok { panic(fmt.Sprintf("gallery: no asset %q in the tree", name)) } return s.url("assets/" + hashed) } // Asset is the URL of one of the gallery's assets, by its plain name. func (p page) Asset(name string) string { return p.s.asset(name) } // Fingerprinted are the addresses, for a tree rendered for mount, of // every asset written under its hashed name, sorted: each hashed twin // the tree holds beside its plain name, with the same bytes. The name // is worked out from the plain file's bytes, as putAsset works it out, // so a name that only looks hashed, with no plain twin or with hex // that is not its bytes' hash, is never listed: other bytes could be // written under it, and a browser told it never changes would keep // the old ones. func Fingerprinted(mount string, tree map[string][]byte) []string { m := cleanMount(mount) var out []string for name, body := range tree { plain, ok := strings.CutPrefix(name, "assets/") if !ok { continue } hashed := "assets/" + hashedName(plain, body) if twin, ok := tree[hashed]; ok && string(twin) == string(body) { out = append(out, m+"/"+hashed) } } sort.Strings(out) return out }