aboutsummaryrefslogtreecommitdiff
path: root/assetembed/assetembed.go
blob: f8dcaf8af324b3333be020f8efcb918354957de2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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 "go.xyrillian.de/gg/assetembed"

import (
	"crypto/sha512"
	"encoding/base64"
	"fmt"
	"io/fs"
	"mime"
	"net/http"
	"path"
	"strconv"
	"strings"
	"time"

	. "go.xyrillian.de/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
	}
}