aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md6
-rw-r--r--README.md1
-rw-r--r--assetembed/assetembed.go148
-rw-r--r--assetembed/assetembed_test.go101
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:
diff --git a/README.md b/README.md
index 5689217..aad5a55 100644
--- a/README.md
+++ b/README.md
@@ -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)
+}