admin: pre-gzip embedded static assets, add cache headers (#9918)
The admin UI served embedded static files uncompressed and without cache headers: embed.FS has zero mod times, so no Last-Modified, no ETag, no 304s -- every page load re-downloaded ~700KB of css/js in full, which gets painful over slow or tunneled links. Gzip the static tree at generation time (go generate ./weed/admin) and embed only the compressed mirror, shrinking the binary ~1.5MB. The handler hands the pre-compressed bytes to gzip-capable clients, decompresses for the rest, and sets Cache-Control, per-variant content-hash ETags and Vary so repeat loads revalidate with a 304. bootstrap.min.css goes 232KB -> 30KB on the wire. A drift test keeps static_gz/ in sync with static/.
This commit is contained in:
parent
c2271d59bb
commit
e56a1c4c05
@ -21,11 +21,17 @@ install-templ:
|
||||
|
||||
# Generate templ files
|
||||
.PHONY: generate
|
||||
generate: install-templ
|
||||
generate: install-templ static-gz
|
||||
@echo "Generating templ files..."
|
||||
@templ generate ./view
|
||||
@echo "Generated: $(TEMPL_GO_FILES)"
|
||||
|
||||
# Regenerate the gzipped static mirror embedded into the binary
|
||||
.PHONY: static-gz
|
||||
static-gz:
|
||||
@echo "Generating gzipped static assets..."
|
||||
@go run gen_static_gz.go
|
||||
|
||||
# Clean generated files
|
||||
.PHONY: clean-templ
|
||||
clean-templ:
|
||||
|
||||
58
weed/admin/gen_static_gz.go
Normal file
58
weed/admin/gen_static_gz.go
Normal file
@ -0,0 +1,58 @@
|
||||
//go:build ignore
|
||||
|
||||
// Regenerates static_gz/, the gzipped mirror of static/ that is embedded
|
||||
// into the binary. Run after changing anything under static/:
|
||||
//
|
||||
// go generate ./weed/admin
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
if err := os.RemoveAll("static_gz"); err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir("static", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := zw.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel("static", p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := filepath.Join("static_gz", rel+".gz")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(out, buf.Bytes(), 0644)
|
||||
})
|
||||
}
|
||||
@ -2,13 +2,13 @@ package admin
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var StaticFS embed.FS
|
||||
//go:generate go run gen_static_gz.go
|
||||
|
||||
// GetStaticFS returns the embedded static filesystem
|
||||
func GetStaticFS() (fs.FS, error) {
|
||||
return fs.Sub(StaticFS, "static")
|
||||
}
|
||||
// staticGzFS is the gzipped mirror of static/, generated by gen_static_gz.go.
|
||||
// Only the compressed copies are embedded; StaticHandler decompresses for
|
||||
// clients that do not accept gzip.
|
||||
//
|
||||
//go:embed all:static_gz
|
||||
var staticGzFS embed.FS
|
||||
|
||||
BIN
weed/admin/static_gz/css/admin.css.gz
Normal file
BIN
weed/admin/static_gz/css/admin.css.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/css/bootstrap.min.css.gz
Normal file
BIN
weed/admin/static_gz/css/bootstrap.min.css.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/css/bootstrap.min.css.map.gz
Normal file
BIN
weed/admin/static_gz/css/bootstrap.min.css.map.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/css/fontawesome.min.css.gz
Normal file
BIN
weed/admin/static_gz/css/fontawesome.min.css.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/favicon.ico.gz
Normal file
BIN
weed/admin/static_gz/favicon.ico.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/js/admin.js.gz
Normal file
BIN
weed/admin/static_gz/js/admin.js.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/js/bootstrap.bundle.min.js.gz
Normal file
BIN
weed/admin/static_gz/js/bootstrap.bundle.min.js.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/js/bootstrap.bundle.min.js.map.gz
Normal file
BIN
weed/admin/static_gz/js/bootstrap.bundle.min.js.map.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/js/htmx.min.js.gz
Normal file
BIN
weed/admin/static_gz/js/htmx.min.js.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/js/iam-utils.js.gz
Normal file
BIN
weed/admin/static_gz/js/iam-utils.js.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/js/modal-alerts.js.gz
Normal file
BIN
weed/admin/static_gz/js/modal-alerts.js.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/js/s3tables.js.gz
Normal file
BIN
weed/admin/static_gz/js/s3tables.js.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/webfonts/fa-brands-400.ttf.gz
Normal file
BIN
weed/admin/static_gz/webfonts/fa-brands-400.ttf.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/webfonts/fa-brands-400.woff2.gz
Normal file
BIN
weed/admin/static_gz/webfonts/fa-brands-400.woff2.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/webfonts/fa-regular-400.ttf.gz
Normal file
BIN
weed/admin/static_gz/webfonts/fa-regular-400.ttf.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/webfonts/fa-regular-400.woff2.gz
Normal file
BIN
weed/admin/static_gz/webfonts/fa-regular-400.woff2.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/webfonts/fa-solid-900.ttf.gz
Normal file
BIN
weed/admin/static_gz/webfonts/fa-solid-900.ttf.gz
Normal file
Binary file not shown.
BIN
weed/admin/static_gz/webfonts/fa-solid-900.woff2.gz
Normal file
BIN
weed/admin/static_gz/webfonts/fa-solid-900.woff2.gz
Normal file
Binary file not shown.
116
weed/admin/static_handler.go
Normal file
116
weed/admin/static_handler.go
Normal file
@ -0,0 +1,116 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Assets only change with the binary; the ETag revalidates across upgrades.
|
||||
const staticCacheControl = "public, max-age=3600"
|
||||
|
||||
type staticAsset struct {
|
||||
name string // base name, drives Content-Type in http.ServeContent
|
||||
gz []byte
|
||||
etag string
|
||||
etagGz string
|
||||
}
|
||||
|
||||
// identity returns the uncompressed bytes.
|
||||
func (a *staticAsset) identity() ([]byte, error) {
|
||||
zr, err := gzip.NewReader(bytes.NewReader(a.gz))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
return io.ReadAll(zr)
|
||||
}
|
||||
|
||||
var staticAssets = sync.OnceValue(func() map[string]*staticAsset {
|
||||
assets := make(map[string]*staticAsset)
|
||||
err := fs.WalkDir(staticGzFS, "static_gz", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
gz, err := fs.ReadFile(staticGzFS, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := strings.TrimSuffix(strings.TrimPrefix(p, "static_gz/"), ".gz")
|
||||
sum := sha256.Sum256(gz)
|
||||
hash := hex.EncodeToString(sum[:8])
|
||||
assets[key] = &staticAsset{
|
||||
name: path.Base(key),
|
||||
gz: gz,
|
||||
etag: `"` + hash + `"`,
|
||||
etagGz: `"` + hash + `-gz"`,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
panic("walk embedded static assets: " + err.Error())
|
||||
}
|
||||
return assets
|
||||
})
|
||||
|
||||
// StaticHandler serves the embedded admin static assets, which are gzipped
|
||||
// at generation time. Gzip-capable clients get the compressed bytes as-is;
|
||||
// others get them decompressed on the fly.
|
||||
func StaticHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
asset, ok := staticAssets()[key]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Cache-Control", staticCacheControl)
|
||||
h.Add("Vary", "Accept-Encoding")
|
||||
if acceptsGzip(r) {
|
||||
h.Set("Content-Encoding", "gzip")
|
||||
h.Set("ETag", asset.etagGz)
|
||||
// ServeContent skips Content-Length when Content-Encoding is set
|
||||
if r.Header.Get("Range") == "" {
|
||||
h.Set("Content-Length", strconv.Itoa(len(asset.gz)))
|
||||
}
|
||||
http.ServeContent(w, r, asset.name, time.Time{}, bytes.NewReader(asset.gz))
|
||||
return
|
||||
}
|
||||
data, err := asset.identity()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.Set("ETag", asset.etag)
|
||||
http.ServeContent(w, r, asset.name, time.Time{}, bytes.NewReader(data))
|
||||
})
|
||||
}
|
||||
|
||||
func acceptsGzip(r *http.Request) bool {
|
||||
for _, part := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
|
||||
token, attr, hasAttr := strings.Cut(strings.TrimSpace(part), ";")
|
||||
if strings.TrimSpace(token) != "gzip" {
|
||||
continue
|
||||
}
|
||||
if !hasAttr {
|
||||
return true
|
||||
}
|
||||
q, ok := strings.CutPrefix(strings.TrimSpace(attr), "q=")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
v, err := strconv.ParseFloat(q, 64)
|
||||
return err == nil && v > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
215
weed/admin/static_handler_test.go
Normal file
215
weed/admin/static_handler_test.go
Normal file
@ -0,0 +1,215 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStaticGzMirror fails when static_gz/ is out of sync with static/.
|
||||
// Regenerate with: go generate ./weed/admin
|
||||
func TestStaticGzMirror(t *testing.T) {
|
||||
sources := make(map[string][]byte)
|
||||
err := filepath.WalkDir("static", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel("static", p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sources[filepath.ToSlash(rel)] = data
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
t.Fatal("no files under static/")
|
||||
}
|
||||
|
||||
mirrored := 0
|
||||
err = filepath.WalkDir("static_gz", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel("static_gz", p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := strings.TrimSuffix(filepath.ToSlash(rel), ".gz")
|
||||
want, ok := sources[key]
|
||||
if !ok {
|
||||
t.Errorf("static_gz/%s has no source in static/", rel)
|
||||
return nil
|
||||
}
|
||||
mirrored++
|
||||
gz, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(gz))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
got, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("static_gz/%s does not match static/%s", rel, key)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mirrored != len(sources) {
|
||||
t.Errorf("static_gz mirrors %d of %d files under static/", mirrored, len(sources))
|
||||
}
|
||||
}
|
||||
|
||||
func fetchStatic(t *testing.T, path string, header map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
for k, v := range header {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
StaticHandler().ServeHTTP(w, req)
|
||||
return w.Result()
|
||||
}
|
||||
|
||||
func TestStaticHandlerGzip(t *testing.T) {
|
||||
resp := fetchStatic(t, "/css/bootstrap.min.css", map[string]string{"Accept-Encoding": "gzip, deflate, br"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
|
||||
t.Fatalf("Content-Encoding %q", got)
|
||||
}
|
||||
if resp.Header.Get("Cache-Control") != staticCacheControl {
|
||||
t.Errorf("Cache-Control %q", resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
if resp.Header.Get("Vary") != "Accept-Encoding" {
|
||||
t.Errorf("Vary %q", resp.Header.Get("Vary"))
|
||||
}
|
||||
if resp.Header.Get("ETag") == "" {
|
||||
t.Error("missing ETag")
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/css") {
|
||||
t.Errorf("Content-Type %q", got)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Header.Get("Content-Length") == "" {
|
||||
t.Error("missing Content-Length")
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer zr.Close()
|
||||
got, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := os.ReadFile("static/css/bootstrap.min.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Error("gzip body does not decompress to the source file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHandlerIdentity(t *testing.T) {
|
||||
resp := fetchStatic(t, "/css/bootstrap.min.css", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Encoding"); got != "" {
|
||||
t.Fatalf("Content-Encoding %q", got)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := os.ReadFile("static/css/bootstrap.min.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(body, want) {
|
||||
t.Error("identity body does not match the source file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHandlerNotModified(t *testing.T) {
|
||||
first := fetchStatic(t, "/css/admin.css", map[string]string{"Accept-Encoding": "gzip"})
|
||||
etag := first.Header.Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatal("missing ETag")
|
||||
}
|
||||
resp := fetchStatic(t, "/css/admin.css", map[string]string{"Accept-Encoding": "gzip", "If-None-Match": etag})
|
||||
if resp.StatusCode != http.StatusNotModified {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if resp.Header.Get("Content-Length") != "" {
|
||||
t.Errorf("304 carries Content-Length %q", resp.Header.Get("Content-Length"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHandlerVariantETags(t *testing.T) {
|
||||
gz := fetchStatic(t, "/css/admin.css", map[string]string{"Accept-Encoding": "gzip"})
|
||||
id := fetchStatic(t, "/css/admin.css", nil)
|
||||
if gz.Header.Get("ETag") == id.Header.Get("ETag") {
|
||||
t.Error("gzip and identity variants share an ETag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHandlerNotFound(t *testing.T) {
|
||||
for _, p := range []string{"/css/missing.css", "/css/", "/", "/../static_embed.go"} {
|
||||
if resp := fetchStatic(t, p, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s: status %d", p, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptsGzip(t *testing.T) {
|
||||
cases := []struct {
|
||||
header string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"gzip", true},
|
||||
{"gzip, deflate, br", true},
|
||||
{"br;q=1.0, gzip;q=0.8", true},
|
||||
{"gzip;q=0", false},
|
||||
{"gzip;q=0.0", false},
|
||||
{"deflate", false},
|
||||
{"x-gzip-like", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
if c.header != "" {
|
||||
r.Header.Set("Accept-Encoding", c.header)
|
||||
}
|
||||
if got := acceptsGzip(r); got != c.want {
|
||||
t.Errorf("acceptsGzip(%q) = %v, want %v", c.header, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -360,15 +360,9 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
// Static files - serve from embedded filesystem
|
||||
staticFS, err := admin.GetStaticFS()
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load embedded static files: %v", err)
|
||||
} else {
|
||||
staticHandler := http.FileServer(http.FS(staticFS))
|
||||
r.Handle("/static", http.RedirectHandler("/static/", http.StatusMovedPermanently))
|
||||
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", staticHandler))
|
||||
}
|
||||
// Static files - pre-gzipped and embedded in the binary
|
||||
r.Handle("/static", http.RedirectHandler("/static/", http.StatusMovedPermanently))
|
||||
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", admin.StaticHandler()))
|
||||
|
||||
// Create admin server (plugin is always enabled)
|
||||
adminServer := dash.NewAdminServer(*options.master, nil, dataDir, icebergPort)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user