Add cache control to Files API static assets
Brand assets served at /api/files/v1/static had no cache headers. Introduce brand.Assets to own the embedded filesystem, content-hash ETags, and HTTP serving. Responses now carry Cache-Control and ETag so clients can cache and revalidate; stable email URLs stay revalidatable (max-age=3600, no immutable). Replace hardcoded Default*Path constants with StaticPathPrefix, logical filename constants, and StaticPath(). NewAssets validates required assets at startup so a rename fails fast instead of 404ing in sent emails. The files handler keeps routing and 404 rendering; ServeAssets sets cache headers and serves the file. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
@@ -2,24 +2,18 @@ package brand
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed assets
|
||||
staticAssets embed.FS
|
||||
//go:embed assets
|
||||
var staticAssets embed.FS
|
||||
|
||||
Assets fs.FS
|
||||
|
||||
DefaultPoweredByLogoPath string = "/api/files/v1/static/probo-gray-small.png"
|
||||
DefaultSenderCompanyLogoPath string = "/api/files/v1/static/probo.png"
|
||||
// Logical brand assets. These filenames must exist under assets/; NewAssets
|
||||
// verifies their presence at startup so a rename or removal fails fast instead
|
||||
// of silently producing a 404 in the consumers (e.g. emails).
|
||||
const (
|
||||
PoweredByLogo = "probo-gray-small.png"
|
||||
SenderCompanyLogo = "probo.png"
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
|
||||
Assets, err = fs.Sub(staticAssets, "assets")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
// requiredAssets are validated to exist whenever an Assets is constructed.
|
||||
var requiredAssets = []string{PoweredByLogo, SenderCompanyLogo}
|
||||
|
||||
103
pkg/brand/http.go
Normal file
103
pkg/brand/http.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package brand
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
)
|
||||
|
||||
// StaticPathPrefix is the single source of truth for the public URL path under
|
||||
// which brand assets are served. It must stay in sync with the route mounted by
|
||||
// the files API handler (/api -> /files/v1 -> /static/{file}).
|
||||
const StaticPathPrefix = "/api/files/v1/static"
|
||||
|
||||
// StaticPath returns the public URL path that serves the named brand asset.
|
||||
func StaticPath(name string) string {
|
||||
return path.Join(StaticPathPrefix, name)
|
||||
}
|
||||
|
||||
// Assets provides access to the embedded brand assets together with their
|
||||
// precomputed content-hash ETags.
|
||||
type Assets struct {
|
||||
fs fs.FS
|
||||
etags map[string]string
|
||||
}
|
||||
|
||||
// NewAssets builds an Assets from the embedded brand files. It panics on error
|
||||
// because the assets are embedded at build time and a failure here is a
|
||||
// programming error, not a runtime condition.
|
||||
func NewAssets() *Assets {
|
||||
assetsFS, err := fs.Sub(staticAssets, "assets")
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot open brand assets: %w", err))
|
||||
}
|
||||
|
||||
for _, name := range requiredAssets {
|
||||
if _, err := fs.Stat(assetsFS, name); err != nil {
|
||||
panic(fmt.Errorf("missing required brand asset %q: %w", name, err))
|
||||
}
|
||||
}
|
||||
|
||||
return &Assets{
|
||||
fs: assetsFS,
|
||||
etags: mustComputeAssetETags(assetsFS),
|
||||
}
|
||||
}
|
||||
|
||||
// Stat returns file information for the named asset.
|
||||
func (a *Assets) Stat(name string) (fs.FileInfo, error) {
|
||||
return fs.Stat(a.fs, name)
|
||||
}
|
||||
|
||||
// ServeAssets writes the named asset to the response. It delegates to
|
||||
// http.ServeFileFS, which sets Content-Type, handles range requests, and honors
|
||||
// a pre-set ETag response header for If-None-Match (304 Not Modified).
|
||||
func (a *Assets) ServeAssets(w http.ResponseWriter, r *http.Request, name string) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
if etag, ok := a.etags[name]; ok {
|
||||
w.Header().Set("ETag", `"`+etag+`"`)
|
||||
}
|
||||
|
||||
http.ServeFileFS(w, r, a.fs, name)
|
||||
}
|
||||
|
||||
// mustComputeAssetETags precomputes content-hash ETags for every embedded
|
||||
// brand asset, mirroring the MD5 walk used by the SPA static handler. It panics
|
||||
// on error because the assets are embedded at build time and a failure here is
|
||||
// a programming error, not a runtime condition.
|
||||
func mustComputeAssetETags(assets fs.FS) map[string]string {
|
||||
etags := make(map[string]string)
|
||||
|
||||
err := fs.WalkDir(
|
||||
assets,
|
||||
".",
|
||||
func(name string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := fs.ReadFile(assets, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read brand asset %q: %w", name, err)
|
||||
}
|
||||
|
||||
hash := md5.Sum(content)
|
||||
etags[name] = hex.EncodeToString(hash[:])
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot compute brand asset etags: %w", err))
|
||||
}
|
||||
|
||||
return etags
|
||||
}
|
||||
Reference in New Issue
Block a user