Files
probo/pkg/brand/http.go
Ludovic Vielle dbfd191bc5 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>
2026-06-12 17:24:00 +02:00

104 lines
2.8 KiB
Go

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
}