diff --git a/go.mod b/go.mod
index 503bebb..96381fd 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,12 @@
module gsg
-go 1.23
+go 1.23.0
require (
github.com/alecthomas/chroma/v2 v2.24.1
github.com/fsnotify/fsnotify v1.10.1
github.com/yuin/goldmark v1.8.5
+ golang.org/x/sync v0.14.0
gopkg.in/yaml.v3 v3.0.1
)
diff --git a/go.sum b/go.sum
index 1a01f6d..f3f96d6 100644
--- a/go.sum
+++ b/go.sum
@@ -12,6 +12,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
+golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
diff --git a/internal/core/core.go b/internal/core/core.go
index f8e5ae1..04b1fc2 100644
--- a/internal/core/core.go
+++ b/internal/core/core.go
@@ -150,6 +150,7 @@ type Handle struct {
// section = infra
// gpgkeyring = /path/to/keys.asc
// visible-to = alice,bob
+// cache-max-age = 2m
//
// Set them with: git --git-dir=/srv/git/foo.git config gsg.title "Foo"
type RepoInfo struct {
@@ -187,6 +188,13 @@ type RepoInfo struct {
// templates don't advertise a verification column that isn't backed
// by anything.
GPGKeyring string
+
+ // CacheMaxAge is gsg.cache-max-age: how long an anonymous (no session,
+ // no share grant) response for this repo may be reused -- both by the
+ // requesting browser (Cache-Control: public, max-age=...) and by gsg's
+ // own in-memory response cache (Server.respCache) -- before either has
+ // to ask again. Falls back to defaultAnonCacheMaxAge when unset.
+ CacheMaxAge time.Duration
}
func LoadRepoInfo(r *gitx.Repo) RepoInfo {
@@ -210,6 +218,7 @@ func LoadRepoInfo(r *gitx.Repo) RepoInfo {
ShareMaxAge: parseShareMaxAge(r.Config("gsg.share-max-age")),
ShareRevokedAfter: parseUnixTime(r.Config("gsg.share-revoked-after")),
GPGKeyring: r.Config("gsg.gpgkeyring"),
+ CacheMaxAge: parseCacheMaxAge(r.Config("gsg.cache-max-age")),
}
}
@@ -228,6 +237,21 @@ func parseShareMaxAge(v string) time.Duration {
return d
}
+// defaultAnonCacheMaxAge is the ceiling/default applied when a repo doesn't
+// set gsg.cache-max-age.
+const defaultAnonCacheMaxAge = 2 * time.Minute
+
+func parseCacheMaxAge(v string) time.Duration {
+ if v == "" {
+ return defaultAnonCacheMaxAge
+ }
+ d, err := time.ParseDuration(v)
+ if err != nil || d <= 0 {
+ return defaultAnonCacheMaxAge
+ }
+ return d
+}
+
func parseUnixTime(v string) time.Time {
if v == "" {
return time.Time{}
@@ -342,6 +366,11 @@ type Ctx struct {
// Commit is set.
Store Store
+ // StaticTag is Server.StaticTag, threaded through so the `static`
+ // template func can build cache-busting asset URLs without a package
+ // import cycle back to Server.
+ StaticTag string
+
mu sync.Mutex
cache map[string]result
}
@@ -420,6 +449,18 @@ func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
func (c *Ctx) Var(k string) string { return c.Vars[k] }
+// cacheMaxAge is how long an anonymous response for this repo may be
+// reused before revalidating. Server.buildResponseHead (the browser-facing
+// Cache-Control header) and Server.fillResponse (the server-side response
+// cache's own TTL) both read this, so the two never disagree about
+// staleness.
+func (c *Ctx) cacheMaxAge() time.Duration {
+ if c.Info.CacheMaxAge > 0 {
+ return c.Info.CacheMaxAge
+ }
+ return defaultAnonCacheMaxAge
+}
+
// ---------------------------------------------------------------- registry
// Module computes one piece of a view. Anything a template or an endpoint
diff --git a/internal/core/funcs.go b/internal/core/funcs.go
index 9db614c..7414c5f 100644
--- a/internal/core/funcs.go
+++ b/internal/core/funcs.go
@@ -31,6 +31,14 @@ func FuncMap(c *Ctx) template.FuncMap {
"url": func(parts ...string) string {
return "/" + strings.Trim(strings.Join(parts, "/"), "/")
},
+ // static builds a cache-busting asset URL, e.g. "css/base.css" ->
+ // "/static/css/base.css?v=<tag>". The `v` param is what lets
+ // main.go's static handler hand out a year-long immutable
+ // Cache-Control instead of a short one -- a URL without it is
+ // still served, just not frozen for a year.
+ "static": func(p string) string {
+ return "/static/" + p + "?v=" + c.StaticTag
+ },
"dict": dict,
"date": func(t time.Time) string {
if t.IsZero() {
@@ -43,7 +51,7 @@ func FuncMap(c *Ctx) template.FuncMap {
if len(s) <= n {
return s
}
- return s[:n] + "\u2026"
+ return s[:n] + "…"
},
"bytes": humanBytes,
"patch": renderPatch,
diff --git a/internal/core/route.go b/internal/core/route.go
index dba5f32..25ca889 100644
--- a/internal/core/route.go
+++ b/internal/core/route.go
@@ -80,6 +80,16 @@ type Route struct {
// URL -- anything addressed by a commit SHA. Those get a year-long
// Cache-Control; ref-addressed URLs get revalidation instead.
Immutable bool
+ // CacheableAnon opts a route into Server's in-memory response cache for
+ // anonymous, shareless requests (see Server.cacheEligible) -- a full
+ // rendered response is stored and replayed to the next matching
+ // request within Ctx.cacheMaxAge, without touching git at all. Reserved
+ // for the small-HTML page routes most likely to be a spike's hot page;
+ // left off routes whose bodies can be large or unboundedly numerous
+ // (blob, raw, compare -- see routes.go) and the JSON api-* endpoints,
+ // to keep this pass's scope and memory footprint bounded: respCache is
+ // an LRU bounded by entry count, not bytes.
+ CacheableAnon bool
// NavLabel, when set on a ScopeRepo route, adds a tab for it to the
// repo-scoped navigation (see nav.go) -- the top tab bar and the
// page-nav chips both derive from this instead of hardcoding links.
diff --git a/internal/core/server.go b/internal/core/server.go
index ba6a9e8..352bea7 100644
--- a/internal/core/server.go
+++ b/internal/core/server.go
@@ -1,6 +1,7 @@
package core
import (
+ "context"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -13,6 +14,8 @@ import (
"sync"
"time"
+ "golang.org/x/sync/singleflight"
+
"gsg/internal/gitx"
"gsg/internal/session"
)
@@ -30,23 +33,41 @@ type Server struct {
Sessions *session.Manager
// TemplateDir, when set, re-parses templates on every request. Theme
- // iteration without a restart; leave empty in production.
+ // iteration without a restart; leave empty in production. Also disables
+ // the response cache below, so a live-edited template is never masked
+ // by a stale cached response.
TemplateDir string
TemplateFS fs.FS
+ // StaticTag is a content hash of the embedded static assets (set once
+ // at startup in main.go via HashFS), threaded onto every Ctx so the
+ // `static` template func can build cache-busting asset URLs.
+ StaticTag string
+
mu sync.RWMutex
templates TemplateSet
tmplTag string // changes when templates change; part of every ETag
cache Store
+
+ // respCache holds full rendered responses for CacheableAnon routes,
+ // keyed on the request (route+path+query, see cacheEligible) rather
+ // than content identity. sf collapses every request that arrives while
+ // a key is cold into one fill instead of one per request -- the thing
+ // that actually matters during a traffic spike is many distinct
+ // anonymous visitors hitting the same hot page, not just requests that
+ // happen to land in the same instant.
+ respCache *LRU
+ sf singleflight.Group
}
func NewServer(site *Site, routes []Route, tfs fs.FS, cacheSize int) (*Server, error) {
s := &Server{
Site: site, Routes: routes, TemplateFS: tfs,
- Log: slog.Default(),
- Timeout: 10 * time.Second,
- MaxGit: 16 << 20, // headroom above modules.maxPreviewSize (10 MiB)
- cache: NewLRU(cacheSize),
+ Log: slog.Default(),
+ Timeout: 10 * time.Second,
+ MaxGit: 16 << 20, // headroom above modules.maxPreviewSize (10 MiB)
+ cache: NewLRU(cacheSize),
+ respCache: NewLRU(cacheSize),
}
if err := s.loadTemplates(); err != nil {
return nil, err
@@ -60,7 +81,7 @@ func (s *Server) loadTemplates() error {
if err != nil {
return err
}
- tag, err := hashFS(fsys)
+ tag, err := HashFS(fsys)
if err != nil {
return err
}
@@ -70,9 +91,13 @@ func (s *Server) loadTemplates() error {
return nil
}
-// hashFS digests every template so that editing the theme invalidates every
-// ETag at once. Without this, a restyled page keeps serving 304s.
-func hashFS(fsys fs.FS) (string, error) {
+// HashFS digests every file in fsys, path and contents both, into a short
+// stable tag. Used for the template set (so editing the theme invalidates
+// every ETag at once -- without this a restyled page keeps serving 304s)
+// and, via main.go, for the embedded static assets, so a CSS/JS edit mints
+// a new cache-busting URL instead of being masked by a year-long immutable
+// Cache-Control.
+func HashFS(fsys fs.FS) (string, error) {
h := sha256.New()
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
@@ -99,6 +124,15 @@ func (s *Server) Handler() http.Handler {
return mux
}
+// cachedResponse is a full rendered response, header and body both, as
+// stored in respCache. expiresAt is only meaningful for entries actually
+// stored there.
+type cachedResponse struct {
+ header http.Header
+ body []byte
+ expiresAt time.Time
+}
+
func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
if s.TemplateDir != "" {
if err := s.loadTemplates(); err != nil {
@@ -107,21 +141,160 @@ func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
}
}
+ if key, ok := s.cacheEligible(rt, r); ok {
+ if cr, ok := s.respCacheGet(key); ok {
+ writeResponse(w, r, cr)
+ return
+ }
+ // singleflight collapses every request that arrives while this key
+ // is cold into one fill. That fill runs on a context detached from
+ // whichever request happened to trigger it: without that, the
+ // first caller to disconnect or time out would kill the shared
+ // git/render work and fail every other request that coalesced onto
+ // it too, even ones with their own deadline still well in the
+ // future. Bounded by s.Timeout so a genuinely stuck fill still
+ // gets reaped.
+ //
+ // The closure below captures r, the *triggering* (leader) request
+ // -- every follower that coalesces onto this Do gets a response
+ // built from the leader's request, not their own. That's only safe
+ // because cacheEligible already pinned every request-varying input
+ // that matters (user == "", no share cookie) and the key covers
+ // path+query, so leader and followers are asking an identical
+ // question. A future CacheableAnon route whose render depends on
+ // anything else request-specific (a header, say) would silently
+ // break this.
+ v, err, _ := s.sf.Do(key, func() (any, error) {
+ fillCtx, cancel := context.WithTimeout(context.Background(), s.Timeout)
+ defer cancel()
+ return s.fillResponse(reqCtx{fillCtx}, rt, r)
+ })
+ if err != nil {
+ s.fail(w, r, rt, err)
+ return
+ }
+ cr := v.(*cachedResponse)
+ s.respCache.Put(key, cr)
+ writeResponse(w, r, cr)
+ return
+ }
+
ctx, cancel := contextWithTimeout(r, s.Timeout)
defer cancel()
- c, err := s.buildCtx(ctx, rt, r)
+ c, header, err := s.buildResponseHead(ctx, rt, r)
if err != nil {
s.fail(w, r, rt, err)
return
}
+ copyHeader(w.Header(), header)
+ if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, header.Get("ETag")) {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ s.mu.RLock()
+ ts := s.templates
+ s.mu.RUnlock()
+ body, err := rt.Render.Render(c, ts)
+ if err != nil {
+ s.fail(w, r, rt, err)
+ return
+ }
+ w.Header().Set("Content-Type", rt.Render.ContentType())
+ if hs, ok := rt.Render.(HeaderSetter); ok {
+ hs.Headers(c, w.Header())
+ }
+ w.Write(body)
+}
+
+// cacheEligible reports whether rt/r may be served from, and stored in,
+// the shared response cache, and if so, the key to use. Eligibility is
+// decided entirely from cheap, git-free signals -- cookies and
+// already-loaded repo config -- so a cache hit costs nothing beyond a map
+// lookup, and a miss doesn't cost anything extra either before falling
+// through to the real render.
+//
+// Two independent conditions gate it, deliberately redundant: the request
+// must carry neither a session nor a share cookie (the same
+// anonymous-and-shareless condition the Cache-Control split in
+// buildResponseHead already trusts), AND, for a repo-scoped route, that
+// repo must not be Hidden -- checked directly from RepoInfo, with no
+// reference to any cookie at all. A cache entry is shared state replayed
+// to whoever asks next, so a false negative here has a bigger blast
+// radius than a single mis-issued header would: the Hidden check means a
+// bug in cookie detection alone can never leak a hidden repo's content,
+// since that repo's responses are never eligible for the shared cache in
+// the first place.
+func (s *Server) cacheEligible(rt Route, r *http.Request) (string, bool) {
+ if !rt.CacheableAnon || s.TemplateDir != "" {
+ return "", false
+ }
+ if s.Sessions.Verify(r) != "" {
+ return "", false
+ }
+ if _, ok := s.Sessions.VerifyShareCookie(r); ok {
+ return "", false
+ }
+ if rt.Scope == ScopeRepo {
+ h, ok := s.Site.Lookup(r.PathValue("repo"))
+ if !ok || h.Info.Hidden {
+ return "", false
+ }
+ }
+ return rt.Name + "\x00" + r.URL.Path + "\x00" + r.URL.RawQuery, true
+}
+
+func (s *Server) respCacheGet(key string) (*cachedResponse, bool) {
+ v, ok := s.respCache.Get(key)
+ if !ok {
+ return nil, false
+ }
+ cr := v.(*cachedResponse)
+ if time.Now().After(cr.expiresAt) {
+ return nil, false
+ }
+ return cr, true
+}
+
+// fillResponse builds a full response for storage in respCache: header and
+// body both, since an entry has to be complete before anyone else can be
+// served from it.
+func (s *Server) fillResponse(ctx contexter, rt Route, r *http.Request) (*cachedResponse, error) {
+ c, header, err := s.buildResponseHead(ctx, rt, r)
+ if err != nil {
+ return nil, err
+ }
+ s.mu.RLock()
+ ts := s.templates
+ s.mu.RUnlock()
+ body, err := rt.Render.Render(c, ts)
+ if err != nil {
+ return nil, err
+ }
+ header.Set("Content-Type", rt.Render.ContentType())
+ if hs, ok := rt.Render.(HeaderSetter); ok {
+ hs.Headers(c, header)
+ }
+ return &cachedResponse{header: header, body: body, expiresAt: time.Now().Add(c.cacheMaxAge())}, nil
+}
+
+// buildResponseHead resolves the Ctx and computes the ETag/Cache-Control
+// header, without executing the template -- split out so a matching
+// If-None-Match can skip rendering the body entirely.
+//
+// ETag is derived from the resolved commit SHA and every other input that
+// can change what's rendered, not the response body, so a conditional
+// request costs one ref lookup instead of a render.
+func (s *Server) buildResponseHead(ctx contexter, rt Route, r *http.Request) (*Ctx, http.Header, error) {
+ c, err := s.buildCtx(ctx, rt, r)
+ if err != nil {
+ return nil, nil, err
+ }
c.Nav = BuildNav(s.Routes, rt.Name, c.Info.Slug, c.Ref)
- // ETag is derived from the resolved commit SHA, not the response body,
- // so a conditional request costs one ref lookup instead of a render.
s.mu.RLock()
tag := s.tmplTag
- ts := s.templates
s.mu.RUnlock()
// contentIdentity carries the commit, keyring, and every Var (e.g.
@@ -143,48 +316,64 @@ func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
// share-holder's listing could be served to another. The cookie's own
// signature bytes already uniquely identify the grant, and folding them
// into a hash never echoes them back in a header.
+ //
+ // s.StaticTag is folded in too: a CSS/JS-only edit changes every
+ // rendered `static "..."` URL, so page ETags have to move with it.
shareCookie := ""
if ck, err := r.Cookie(session.ShareCookieName); err == nil {
shareCookie = ck.Value
}
- etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, c.contentIdentity(), c.User, shareCookie) + `"`
- w.Header().Set("ETag", etag)
+ etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, s.StaticTag, c.contentIdentity(), c.User, shareCookie) + `"`
+
+ header := http.Header{}
+ header.Set("ETag", etag)
if c.Immutable {
- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ header.Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
// Vary: Cookie applies to every response here, not just personalized
// ones -- the *absence* of gsg_user is also a cache dimension. Without
// it on the anonymous branch too, a shared cache could store an
// anonymous response keyed on URL alone and later serve it back to a
// request that does carry a session cookie.
- w.Header().Add("Vary", "Cookie")
+ header.Add("Vary", "Cookie")
if c.User != "" || len(c.SharedSlugs) > 0 {
- // No shared/intermediary cache (proxy, CDN) may ever store this
- // response, only the requesting browser -- otherwise a
- // misbehaving shared cache could serve one user's hidden-repo
- // listing to a different visitor. Vary: Cookie above is defense
- // in depth for any cache that honors Vary but not
- // Cache-Control: private.
- w.Header().Set("Cache-Control", "private, no-cache")
+ // No shared/intermediary cache (proxy, CDN, or gsg's own
+ // respCache) may ever store this response, only the requesting
+ // browser -- otherwise a misbehaving shared cache could serve
+ // one user's hidden-repo listing to a different visitor. Vary:
+ // Cookie above is defense in depth for any cache that honors
+ // Vary but not Cache-Control: private.
+ header.Set("Cache-Control", "private, no-cache")
} else {
- w.Header().Set("Cache-Control", "no-cache")
+ // public, with a real max-age rather than no-cache: the ETag
+ // already folds in c.User and the share cookie (above), so the
+ // only failure mode for a shared cache that mishandles Vary is
+ // serving this anonymous view to a logged-in or share-holding
+ // visitor within the TTL -- they see fewer repos, or a 404 on a
+ // hidden one. A UX papercut, not a disclosure: the dangerous
+ // direction (private content served to an anonymous visitor)
+ // can't happen here, since that's the separate branch above.
+ header.Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(c.cacheMaxAge().Seconds())))
}
}
- if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
- w.WriteHeader(http.StatusNotModified)
- return
+ return c, header, nil
+}
+
+func copyHeader(dst, src http.Header) {
+ for k, vs := range src {
+ for _, v := range vs {
+ dst.Add(k, v)
+ }
}
+}
- body, err := rt.Render.Render(c, ts)
- if err != nil {
- s.fail(w, r, rt, err)
+func writeResponse(w http.ResponseWriter, r *http.Request, cr *cachedResponse) {
+ copyHeader(w.Header(), cr.header)
+ if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, cr.header.Get("ETag")) {
+ w.WriteHeader(http.StatusNotModified)
return
}
- w.Header().Set("Content-Type", rt.Render.ContentType())
- if hs, ok := rt.Render.(HeaderSetter); ok {
- hs.Headers(c, w.Header())
- }
- w.Write(body)
+ w.Write(cr.body)
}
func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) {
@@ -203,6 +392,7 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
c.Store = s.cache
c.User = user
c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt
+ c.StaticTag = s.StaticTag
return c, nil
}
@@ -250,6 +440,7 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
c.Store = s.cache
c.User = user
c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt
+ c.StaticTag = s.StaticTag
if !repo.Empty() {
sha, err := repo.Resolve(ref)
@@ -292,9 +483,9 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
}
}
// One source of truth for the immutable policy: the cache header in
- // serve() reads the same field, so page chrome that must not be frozen
- // into a year-long response is suppressed by exactly the condition that
- // makes the response year-long.
+ // buildResponseHead reads the same field, so page chrome that must not
+ // be frozen into a year-long response is suppressed by exactly the
+ // condition that makes the response year-long.
c.Immutable = rt.Immutable && c.Commit != ""
return c, nil
}
@@ -310,9 +501,14 @@ func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err erro
s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err)
}
- // serve() sets ETag/Cache-Control before Render runs, assuming success.
- // An error response must never inherit those, especially the
- // year-long immutable Cache-Control on commit routes.
+ // The cached-response and cache-fill paths in serve() only ever reach
+ // fail() before anything is written to w, so for those there is
+ // nothing to strip. But the plain (non-cacheable) path copies
+ // buildResponseHead's ETag/Cache-Control onto w *before* calling
+ // Render, on the assumption that Render will succeed -- if it doesn't,
+ // this Del/Set is still what prevents a failed response from going out
+ // with a stale ETag or, worse, the year-long immutable Cache-Control
+ // from a commit route.
w.Header().Del("ETag")
w.Header().Set("Cache-Control", "no-store")
diff --git a/internal/site/routes.go b/internal/site/routes.go
index ee96fb2..494d28e 100644
--- a/internal/site/routes.go
+++ b/internal/site/routes.go
@@ -10,7 +10,7 @@ import (
func Routes() []core.Route {
return []core.Route{
// ---- site-scoped -------------------------------------------------
- {Name: "index", Scope: core.ScopeSite,
+ {Name: "index", Scope: core.ScopeSite, CacheableAnon: true,
Pattern: "GET /{$}",
Render: core.View{Template: "index.html"}},
@@ -21,7 +21,7 @@ func Routes() []core.Route {
// ---- per repository ----------------------------------------------
// NavLabel order below is the order tabs appear in the repo-scoped
// nav (top bar and page-nav chips) -- see core.BuildNav.
- {Name: "repo",
+ {Name: "repo", CacheableAnon: true,
Pattern: "GET /{repo}/{$}",
Render: core.View{Template: "tree.html"}},
@@ -31,11 +31,11 @@ func Routes() []core.Route {
// {rest...} fuses ref and path; SplitRefPath separates them by
// longest-matching ref, so feature/x/src/main.go works.
- {Name: "tree", NavLabel: "files",
+ {Name: "tree", NavLabel: "files", CacheableAnon: true,
Pattern: "GET /{repo}/tree/{rest...}",
Render: core.View{Template: "tree.html"}},
- {Name: "log", NavLabel: "log",
+ {Name: "log", NavLabel: "log", CacheableAnon: true,
Pattern: "GET /{repo}/log/{rest...}",
Render: core.View{Template: "log.html"}},
@@ -51,15 +51,21 @@ func Routes() []core.Route {
// The DAG across every ref. {rest...} carries only a ref (never a
// path, there's nothing to browse into) -- it exists purely so the
// selected branch survives navigating here and back out again.
- {Name: "graph", NavLabel: "graph",
+ {Name: "graph", NavLabel: "graph", CacheableAnon: true,
Pattern: "GET /{repo}/graph/{rest...}",
Render: core.View{Template: "graph.html"}},
- {Name: "refs", NavLabel: "refs",
+ {Name: "refs", NavLabel: "refs", CacheableAnon: true,
Pattern: "GET /{repo}/refs/{rest...}",
Render: core.View{Template: "refs.html"}},
- // Arbitrary two-commit diff: /demo/compare/main...feature
+ // Arbitrary two-commit diff: /demo/compare/main...feature. Not
+ // CacheableAnon: a diff body is bounded only by MaxGit (16 MiB,
+ // larger than a blob preview), and every valid ref pair is a
+ // distinct key -- a repo with N refs has ~N^2 reachable ones. The
+ // response cache has no size-aware eviction, so this route would
+ // risk filling it with a few huge entries for no real hit-rate
+ // benefit (see internal/core/route.go's CacheableAnon doc).
{Name: "compare",
Pattern: "GET /{repo}/compare/{range}",
Render: core.View{Template: "compare.html"}},
diff --git a/main.go b/main.go
index fdf3431..68a9ff4 100644
--- a/main.go
+++ b/main.go
@@ -64,7 +64,7 @@ func main() {
flag.BoolVar(&o.adminAllowRemote, "admin-allow-remote", false, "permit -admin-addr to bind a non-loopback address (required in containers)")
flag.StringVar(&o.adminGroup, "admin-group", "", "group required (via -admin-group-header) to reach the notices admin UI; empty allows anyone the reverse proxy lets through")
flag.StringVar(&o.adminGroupHeader, "admin-group-header", "X-Authentik-Groups", "header carrying the caller's \"|\"-delimited groups, checked against -admin-group")
- flag.IntVar(&o.cacheSize, "cache", 4096, "module cache entries")
+ flag.IntVar(&o.cacheSize, "cache", 4096, "entries per cache (module results and the anonymous response cache each get their own, sized identically)")
flag.DurationVar(&o.timeout, "timeout", 10*time.Second, "per-request git budget")
flag.Parse()
@@ -120,9 +120,18 @@ func run(o options) error {
if err != nil {
return err
}
+ // Content hash of the embedded assets, not a build timestamp, so it's
+ // stable across restarts that don't change any file and only changes
+ // when one actually does -- templates use it (via the `static` funcmap
+ // entry) to mint cache-busting URLs.
+ staticTag, err := core.HashFS(static)
+ if err != nil {
+ return err
+ }
+ srv.StaticTag = staticTag
mux := http.NewServeMux()
- mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
+ mux.Handle("/static/", cachedStatic(static))
// Logout only clears gsg's own cookie -- it needs no round trip through
// Authentik, so it lives on the public listener, not behind
// forward_auth like /auth/login. It's hand-mounted here rather than
@@ -191,7 +200,7 @@ func run(o options) error {
// is same-origin and its /static/ requests go to the public listener,
// but hitting the admin port directly (dev, curl) would otherwise 404
// every stylesheet. Static assets are public either way.
- amux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
+ amux.Handle("/static/", cachedStatic(static))
// /auth/login: reachable only through the same Caddy forward_auth
// block as /admin/*, so by the time a request lands here,
@@ -255,6 +264,27 @@ func run(o options) error {
return <-errc
}
+// cachedStatic serves fsys with a Cache-Control that depends on whether the
+// request carries the `v` cache-busting query param the `static` template
+// func mints (see internal/core/funcs.go). Requests that carry it are
+// addressed by content hash, so they're frozen for a year; everything else
+// -- share/notices/error-page assets, which don't go through that funcmap
+// and so never get a `v` -- still gets a real improvement over no
+// Cache-Control at all, just a short one, in case a template edit changes
+// what a bare URL should serve.
+func cachedStatic(fsys fs.FS) http.Handler {
+ fileServer := http.FileServer(http.FS(fsys))
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("v") != "" {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=600")
+ }
+ fileServer.ServeHTTP(w, r)
+ })
+ return http.StripPrefix("/static/", h)
+}
+
// safeNext returns next if it is a same-site relative path, else "/". next
// comes from a query parameter on a login redirect; without this check it
// would be an open redirect (?next=https://evil.example) off a trusted
diff --git a/templates/layouts/base.html b/templates/layouts/base.html
index 85856cb..b782245 100644
--- a/templates/layouts/base.html
+++ b/templates/layouts/base.html
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ with .Info.Title }}{{ . }} · {{ end }}{{ .Site.Name }}{{ end }}</title>
-<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="{{ static "css/base.css" }}">
{{ block "styles" . }}{{ end }}
</head>
<body>
@@ -18,7 +18,7 @@
<div class="footer-right">
<!--<span>served from bare repositories</span>
<span style="color:var(--line);margin:0 .5rem;">•</span>-->
- <span>{{ .Site.Name }} v1.1</span>
+ <span>{{ .Site.Name }} v1.3</span>
</div>
</footer>
{{ block "scripts" . }}{{ end }}
diff --git a/templates/views/blob.html b/templates/views/blob.html
index 93aeef9..22b89f9 100644
--- a/templates/views/blob.html
+++ b/templates/views/blob.html
@@ -1,5 +1,5 @@
{{ define "blob.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/blob.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/blob.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{ $b := mod "blob" }}
@@ -66,4 +66,4 @@
{{ end }}
{{ end }}
-{{ define "scripts" }}{{ if (mod "blob").Structured }}<script src="/static/js/structured.js" defer></script>{{ end }}{{ end }}
+{{ define "scripts" }}{{ if (mod "blob").Structured }}<script src="{{ static "js/structured.js" }}" defer></script>{{ end }}{{ end }}
diff --git a/templates/views/commit.html b/templates/views/commit.html
index 9522094..2a1f97b 100644
--- a/templates/views/commit.html
+++ b/templates/views/commit.html
@@ -1,7 +1,7 @@
{{ define "commit.html" }}{{ template "base" . }}{{ end }}
{{ define "styles" }}
-<link rel="stylesheet" href="/static/css/diff.css">
-<link rel="stylesheet" href="/static/css/readme.css">
+<link rel="stylesheet" href="{{ static "css/diff.css" }}">
+<link rel="stylesheet" href="{{ static "css/readme.css" }}">
{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
diff --git a/templates/views/compare.html b/templates/views/compare.html
index 8bdc698..8630cd0 100644
--- a/templates/views/compare.html
+++ b/templates/views/compare.html
@@ -1,5 +1,5 @@
{{ define "compare.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/diff.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/diff.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{ $c := mod "compare" }}
diff --git a/templates/views/graph.html b/templates/views/graph.html
index 57deca2..c52dd0d 100644
--- a/templates/views/graph.html
+++ b/templates/views/graph.html
@@ -1,5 +1,5 @@
{{ define "graph.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/graph.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/graph.css" }}">{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}
{{/* "150" here must match core.GraphPageSize -- buildCtx uses that
diff --git a/templates/views/index.html b/templates/views/index.html
index 8f26a42..6c31162 100644
--- a/templates/views/index.html
+++ b/templates/views/index.html
@@ -1,5 +1,5 @@
{{ define "index.html" }}{{ template "base" . }}{{ end }}
-{{ define "styles" }}<link rel="stylesheet" href="/static/css/repo-list.css">{{ end }}
+{{ define "styles" }}<link rel="stylesheet" href="{{ static "css/repo-list.css" }}">{{ end }}
{{ define "main" }}
{{ $secs := mod "sections" }}
diff --git a/templates/views/tree.html b/templates/views/tree.html
index c50b83f..181d8bb 100644
--- a/templates/views/tree.html
+++ b/templates/views/tree.html
@@ -1,7 +1,7 @@
{{ define "tree.html" }}{{ template "base" . }}{{ end }}
{{ define "styles" }}
-<link rel="stylesheet" href="/static/css/repo.css">
-<link rel="stylesheet" href="/static/css/readme.css">
+<link rel="stylesheet" href="{{ static "css/repo.css" }}">
+<link rel="stylesheet" href="{{ static "css/readme.css" }}">
{{ end }}
{{ define "main" }}
{{ $slug := print "/" .Info.Slug }}