diff options
| -rw-r--r-- | CHANGELOG.md | 6 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | assetembed/assetembed.go | 148 | ||||
| -rw-r--r-- | assetembed/assetembed_test.go | 101 |
4 files changed, 256 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 10b8714..de6d9be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net> SPDX-License-Identifier: Apache-2.0 --> +# v1.3.0 (TBD) + +Changes: + +- Add package assetembed. + # v1.2.0 (2025-08-11) Changes: @@ -9,6 +9,7 @@ My personal extension of the standard library, mostly containing foundational ge ## List of packages +- [assetembed](./assetembed/): HTTP handler for efficiently serving embedded assets using the cache-busting pattern - [jsonmatch](./jsonmatch/): matching of encoded JSON payloads against fixed assertions - [option](./option/): an Option type with strong isolation - [options](./options/): additional functions for type Option diff --git a/assetembed/assetembed.go b/assetembed/assetembed.go new file mode 100644 index 0000000..29bbe8e --- /dev/null +++ b/assetembed/assetembed.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +// Package assetembed provides a HTTP handler for serving asset files embedded in a Go binary through the embed.FS type. +// It is similar in purpose to http.FileServerFS() from the standard library, +// but instead of serving files directly with their names as found in the filesystem, +// it inserts a cryptographic digest of the file contents into the filename that the handler serves. +// +// This implements the "cache-busting pattern": +// User agents will immediately know when to update assets served by the HTTP server +// because links to those assets will change to refer to a new filename (that includes the hash of the updated file contents). +// This allows the HTTP handler to serve files with the very efficient "Cache-Control: immutable" caching method. +package assetembed + +import ( + "crypto/sha512" + "encoding/base64" + "fmt" + "io/fs" + "mime" + "net/http" + "path" + "strconv" + "strings" + "time" + + . "github.com/majewsky/gg/option" +) + +// Handler serves static asset files, as described in the package documentation. +type Handler struct { + digestPaths map[string]string // e.g. "res/app.css" -> "res/app-OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb.css" + sriAttributes map[string]string // e.g. "res/app.css" -> "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb" + contents map[string][]byte // e.g. "res/app-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.css" -> [contents of res/app.css] +} + +// NewHandler builds a new Handler instance. +// This will read all files in assetFS and store a copy of their contents inside the Handler instance. +// +// For filesystems backed by actual disk or network storage, this can be a very expensive operation. +// We recommend only using this function with embed.FS or other in-memory filesystems, for which the ReadFile() function is a cheap copy-by-reference. +func NewHandler(assetFS fs.ReadFileFS) (*Handler, error) { + h := &Handler{ + digestPaths: make(map[string]string), + sriAttributes: make(map[string]string), + contents: make(map[string][]byte), + } + return h, fs.WalkDir(assetFS, ".", func(fullPath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + buf, err := assetFS.ReadFile(fullPath) + if err != nil { + return err + } + fullPathWithDigest, sriAttribute := deriveAttributesForFile(fullPath, buf) + h.digestPaths[fullPath] = fullPathWithDigest + h.sriAttributes[fullPath] = sriAttribute + h.contents[fullPathWithDigest] = buf + return nil + }) +} + +func deriveAttributesForFile(fullPath string, contents []byte) (pathWithDigest, sriAttribute string) { + // we're using SHA512-384 here because that appears to be the common choice for SRI hashes + digest := sha512.Sum384(contents) + digestURLBase64 := base64.URLEncoding.EncodeToString(digest[:]) + digestStdBase64 := base64.StdEncoding.EncodeToString(digest[:]) + + dirPath, fileName := path.Split(fullPath) // e.g. "res/", "app.css" + ext := path.Ext(fileName) // e.g. ".css" + fileNameWithDigest := fmt.Sprintf("%s-%s%s", + strings.TrimSuffix(fileName, ext), + digestURLBase64, + ext, + ) + return path.Join(dirPath, fileNameWithDigest), "sha384-" + digestStdBase64 +} + +// AssetPath takes the path to a file within the original filesystem, +// and returns the path with digest that the HTTP handler can serve. +// If the provided file path does not exist in the filesystem, None is returned. +// +// This function is intended for embedding URLs linking to assets served by this handler e.g. into HTML documents as part of templating. +// +// For example, if "res/app.css" is given and exists in the filesystem, then something like +// "res/app-OLBgp1GsljhM2TJ-sbHjaiH9txEUvgdDTAzHv2P24donTt6_529l-9Ua0vFImLlb.css" may be returned. +// +// A stability guarantee is given for the general shape of the transformed file path +// (only the basename is altered and the extension stays intact), but not for which digest is embedded and how. +func (h *Handler) AssetPath(originalPath string) Option[string] { + result, exists := h.digestPaths[originalPath] + if exists { + return Some(result) + } else { + return None[string]() + } +} + +// SubresourceIntegrity takes the path to a file within the original filesystem, +// and returns a suitable value for the "integrity" attribute of an HTML element that supports Subresource Integrity. +// +// This method is intended to be used alongside AssetPath() during HTML template rendering. +// +// No stability guarantee is given for which digest is used to generated the result value. +func (h *Handler) SubresourceIntegrity(originalPath string) Option[string] { + result, exists := h.sriAttributes[originalPath] + if exists { + return Some(result) + } else { + return None[string]() + } +} + +var cacheControlHeader = fmt.Sprintf( + "public, max-age=%d, immutable", + int64((14 * 24 * time.Hour).Seconds()), // max-age = 2 weeks +) + +// ServeHTTP implements the http.Handler interface. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + buf, exists := h.contents[strings.TrimPrefix(r.URL.Path, "/")] + if !exists { + http.NotFound(w, r) + return + } + + if r.Method != http.MethodHead && r.Method != http.MethodGet { + http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed) + return + } + + contentType := mime.TypeByExtension(path.Ext(r.URL.Path)) + if contentType == "" { + contentType = http.DetectContentType(buf) + } + + w.Header().Set("Cache-Control", cacheControlHeader) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.Itoa(len(buf))) + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + w.Write(buf) //nolint:errcheck // no good way to deal with the error return + } +} diff --git a/assetembed/assetembed_test.go b/assetembed/assetembed_test.go new file mode 100644 index 0000000..d652ac5 --- /dev/null +++ b/assetembed/assetembed_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package assetembed_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/majewsky/gg/assetembed" + . "github.com/majewsky/gg/internal/test" + . "github.com/majewsky/gg/option" +) + +func TestAssetembed(t *testing.T) { + const ( + fooTxt = "hello world" + fooSHA384 = "/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9" + barJS = "alert('hello')" + barSHA384 = "nK45OZX/RRKGmhPEj7lSXYQ3NDNNqiBUgbymhjhJ/jpCg0eAyZQ5UuzakE/UFcBd" + ) + h, err := assetembed.NewHandler(fstest.MapFS{ + "foo.txt": &fstest.MapFile{ + Data: []byte(fooTxt), + Mode: 0644, + ModTime: time.Unix(1, 0), + }, + "res/assets/bar.js": &fstest.MapFile{ + Data: []byte(barJS), + Mode: 0644, + ModTime: time.Unix(2, 0), + }, + }) + if err != nil { + t.Fatal(err.Error()) + } + + // test Handler.AssetPath() + urlSafe := func(base64str string) string { + base64str = strings.ReplaceAll(base64str, "+", "-") + base64str = strings.ReplaceAll(base64str, "/", "_") + return base64str + } + fooTxtPath := fmt.Sprintf("foo-%s.txt", urlSafe(fooSHA384)) + AssertEqual(t, h.AssetPath("foo.txt"), Some(fooTxtPath)) + barJSPath := fmt.Sprintf("res/assets/bar-%s.js", urlSafe(barSHA384)) + AssertEqual(t, h.AssetPath("res/assets/bar.js"), Some(barJSPath)) + AssertEqual(t, h.AssetPath("res/assets/unknown.css"), None[string]()) + + // test Handler.SubresourceIntegrity() + AssertEqual(t, h.SubresourceIntegrity("foo.txt"), Some("sha384-"+fooSHA384)) + AssertEqual(t, h.SubresourceIntegrity("res/assets/bar.js"), Some("sha384-"+barSHA384)) + AssertEqual(t, h.SubresourceIntegrity("res/assets/unknown.css"), None[string]()) + + // test HTTP handler + for _, method := range []string{http.MethodHead, http.MethodGet} { + t.Logf("testing with method = %q", method) + probe := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, http.NoBody) + resp := httptest.NewRecorder() + h.ServeHTTP(resp, req) + return resp + } + ifGet := func(payload string) string { + if method == http.MethodGet { + return payload + } else { + return "" + } + } + + resp := probe("/" + fooTxtPath) + AssertEqual(t, resp.Code, http.StatusOK) + AssertEqual(t, resp.Header().Get("Content-Type"), "text/plain; charset=utf-8") + AssertEqual(t, resp.Header().Get("Content-Length"), strconv.Itoa(len(fooTxt))) + AssertEqual(t, resp.Body.String(), ifGet(fooTxt)) + + resp = probe("/" + barJSPath) + AssertEqual(t, resp.Code, http.StatusOK) + AssertEqual(t, resp.Header().Get("Content-Type"), "text/javascript; charset=utf-8") + AssertEqual(t, resp.Header().Get("Content-Length"), strconv.Itoa(len(barJS))) + AssertEqual(t, resp.Body.String(), ifGet(barJS)) + + resp = probe("/foo.txt") // without digest! + AssertEqual(t, resp.Code, http.StatusNotFound) + + resp = probe("/res/assets/unknown.css") // entirely unknown file + AssertEqual(t, resp.Code, http.StatusNotFound) + } + + req := httptest.NewRequest(http.MethodPut, "/"+fooTxtPath, http.NoBody) + resp := httptest.NewRecorder() + h.ServeHTTP(resp, req) + AssertEqual(t, resp.Code, http.StatusMethodNotAllowed) +} |
