Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
internal/core/server.go
8d1bb5bfdd2cba82488775b46308efed740132a8
· 17.6 KB · 528 lines
raw
| 1 | package core |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io/fs" |
| 10 | "log/slog" |
| 11 | "net/http" |
| 12 | "strconv" |
| 13 | "strings" |
| 14 | "sync" |
| 15 | "time" |
| 16 | |
| 17 | "golang.org/x/sync/singleflight" |
| 18 | |
| 19 | "gsg/internal/gitx" |
| 20 | "gsg/internal/session" |
| 21 | ) |
| 22 | |
| 23 | type Server struct { |
| 24 | Site *Site |
| 25 | Routes []Route |
| 26 | Log *slog.Logger |
| 27 | Timeout time.Duration // per-request git budget |
| 28 | MaxGit int64 // per-command output cap, bytes |
| 29 | |
| 30 | // Sessions verifies the gsg_user login cookie. Nil-safe: an unset |
| 31 | // Sessions makes every request resolve as anonymous, i.e. today's |
| 32 | // behavior. |
| 33 | Sessions *session.Manager |
| 34 | |
| 35 | // TemplateDir, when set, re-parses templates on every request. Theme |
| 36 | // iteration without a restart; leave empty in production. Also disables |
| 37 | // the response cache below, so a live-edited template is never masked |
| 38 | // by a stale cached response. |
| 39 | TemplateDir string |
| 40 | TemplateFS fs.FS |
| 41 | |
| 42 | // StaticTag is a content hash of the embedded static assets (set once |
| 43 | // at startup in main.go via HashFS), threaded onto every Ctx so the |
| 44 | // `static` template func can build cache-busting asset URLs. |
| 45 | StaticTag string |
| 46 | |
| 47 | mu sync.RWMutex |
| 48 | templates TemplateSet |
| 49 | tmplTag string // changes when templates change; part of every ETag |
| 50 | cache Store |
| 51 | |
| 52 | // respCache holds full rendered responses for CacheableAnon routes, |
| 53 | // keyed on the request (route+path+query, see cacheEligible) rather |
| 54 | // than content identity. sf collapses every request that arrives while |
| 55 | // a key is cold into one fill instead of one per request -- the thing |
| 56 | // that actually matters during a traffic spike is many distinct |
| 57 | // anonymous visitors hitting the same hot page, not just requests that |
| 58 | // happen to land in the same instant. |
| 59 | respCache *LRU |
| 60 | sf singleflight.Group |
| 61 | } |
| 62 | |
| 63 | func NewServer(site *Site, routes []Route, tfs fs.FS, cacheSize int) (*Server, error) { |
| 64 | s := &Server{ |
| 65 | Site: site, Routes: routes, TemplateFS: tfs, |
| 66 | Log: slog.Default(), |
| 67 | Timeout: 10 * time.Second, |
| 68 | MaxGit: 16 << 20, // headroom above modules.maxPreviewSize (10 MiB) |
| 69 | cache: NewLRU(cacheSize), |
| 70 | respCache: NewLRU(cacheSize), |
| 71 | } |
| 72 | if err := s.loadTemplates(); err != nil { |
| 73 | return nil, err |
| 74 | } |
| 75 | return s, nil |
| 76 | } |
| 77 | |
| 78 | func (s *Server) loadTemplates() error { |
| 79 | fsys := s.TemplateFS |
| 80 | set, err := LoadTemplates(fsys) |
| 81 | if err != nil { |
| 82 | return err |
| 83 | } |
| 84 | tag, err := HashFS(fsys) |
| 85 | if err != nil { |
| 86 | return err |
| 87 | } |
| 88 | s.mu.Lock() |
| 89 | s.templates, s.tmplTag = set, tag |
| 90 | s.mu.Unlock() |
| 91 | return nil |
| 92 | } |
| 93 | |
| 94 | // HashFS digests every file in fsys, path and contents both, into a short |
| 95 | // stable tag. Used for the template set (so editing the theme invalidates |
| 96 | // every ETag at once -- without this a restyled page keeps serving 304s) |
| 97 | // and, via main.go, for the embedded static assets, so a CSS/JS edit mints |
| 98 | // a new cache-busting URL instead of being masked by a year-long immutable |
| 99 | // Cache-Control. |
| 100 | func HashFS(fsys fs.FS) (string, error) { |
| 101 | h := sha256.New() |
| 102 | err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { |
| 103 | if err != nil || d.IsDir() { |
| 104 | return err |
| 105 | } |
| 106 | b, err := fs.ReadFile(fsys, p) |
| 107 | if err != nil { |
| 108 | return err |
| 109 | } |
| 110 | fmt.Fprintf(h, "%s:%x\n", p, sha256.Sum256(b)) |
| 111 | return nil |
| 112 | }) |
| 113 | return hex.EncodeToString(h.Sum(nil))[:12], err |
| 114 | } |
| 115 | |
| 116 | func (s *Server) Handler() http.Handler { |
| 117 | mux := http.NewServeMux() |
| 118 | for _, rt := range s.Routes { |
| 119 | rt := rt |
| 120 | mux.HandleFunc(rt.Pattern, func(w http.ResponseWriter, r *http.Request) { |
| 121 | s.serve(rt, w, r) |
| 122 | }) |
| 123 | } |
| 124 | return mux |
| 125 | } |
| 126 | |
| 127 | // cachedResponse is a full rendered response, header and body both, as |
| 128 | // stored in respCache. expiresAt is only meaningful for entries actually |
| 129 | // stored there. |
| 130 | type cachedResponse struct { |
| 131 | header http.Header |
| 132 | body []byte |
| 133 | expiresAt time.Time |
| 134 | } |
| 135 | |
| 136 | func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) { |
| 137 | if s.TemplateDir != "" { |
| 138 | if err := s.loadTemplates(); err != nil { |
| 139 | s.fail(w, r, rt, err) |
| 140 | return |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | if key, ok := s.cacheEligible(rt, r); ok { |
| 145 | if cr, ok := s.respCacheGet(key); ok { |
| 146 | writeResponse(w, r, cr) |
| 147 | return |
| 148 | } |
| 149 | // singleflight collapses every request that arrives while this key |
| 150 | // is cold into one fill. That fill runs on a context detached from |
| 151 | // whichever request happened to trigger it: without that, the |
| 152 | // first caller to disconnect or time out would kill the shared |
| 153 | // git/render work and fail every other request that coalesced onto |
| 154 | // it too, even ones with their own deadline still well in the |
| 155 | // future. Bounded by s.Timeout so a genuinely stuck fill still |
| 156 | // gets reaped. |
| 157 | // |
| 158 | // The closure below captures r, the *triggering* (leader) request |
| 159 | // -- every follower that coalesces onto this Do gets a response |
| 160 | // built from the leader's request, not their own. That's only safe |
| 161 | // because cacheEligible already pinned every request-varying input |
| 162 | // that matters (user == "", no share cookie) and the key covers |
| 163 | // path+query, so leader and followers are asking an identical |
| 164 | // question. A future CacheableAnon route whose render depends on |
| 165 | // anything else request-specific (a header, say) would silently |
| 166 | // break this. |
| 167 | v, err, _ := s.sf.Do(key, func() (any, error) { |
| 168 | fillCtx, cancel := context.WithTimeout(context.Background(), s.Timeout) |
| 169 | defer cancel() |
| 170 | return s.fillResponse(reqCtx{fillCtx}, rt, r) |
| 171 | }) |
| 172 | if err != nil { |
| 173 | s.fail(w, r, rt, err) |
| 174 | return |
| 175 | } |
| 176 | cr := v.(*cachedResponse) |
| 177 | s.respCache.Put(key, cr) |
| 178 | writeResponse(w, r, cr) |
| 179 | return |
| 180 | } |
| 181 | |
| 182 | ctx, cancel := contextWithTimeout(r, s.Timeout) |
| 183 | defer cancel() |
| 184 | |
| 185 | c, header, err := s.buildResponseHead(ctx, rt, r) |
| 186 | if err != nil { |
| 187 | s.fail(w, r, rt, err) |
| 188 | return |
| 189 | } |
| 190 | copyHeader(w.Header(), header) |
| 191 | if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, header.Get("ETag")) { |
| 192 | w.WriteHeader(http.StatusNotModified) |
| 193 | return |
| 194 | } |
| 195 | |
| 196 | s.mu.RLock() |
| 197 | ts := s.templates |
| 198 | s.mu.RUnlock() |
| 199 | body, err := rt.Render.Render(c, ts) |
| 200 | if err != nil { |
| 201 | s.fail(w, r, rt, err) |
| 202 | return |
| 203 | } |
| 204 | w.Header().Set("Content-Type", rt.Render.ContentType()) |
| 205 | if hs, ok := rt.Render.(HeaderSetter); ok { |
| 206 | hs.Headers(c, w.Header()) |
| 207 | } |
| 208 | w.Write(body) |
| 209 | } |
| 210 | |
| 211 | // cacheEligible reports whether rt/r may be served from, and stored in, |
| 212 | // the shared response cache, and if so, the key to use. Eligibility is |
| 213 | // decided entirely from cheap, git-free signals -- cookies and |
| 214 | // already-loaded repo config -- so a cache hit costs nothing beyond a map |
| 215 | // lookup, and a miss doesn't cost anything extra either before falling |
| 216 | // through to the real render. |
| 217 | // |
| 218 | // Two independent conditions gate it, deliberately redundant: the request |
| 219 | // must carry neither a session nor a share cookie (the same |
| 220 | // anonymous-and-shareless condition the Cache-Control split in |
| 221 | // buildResponseHead already trusts), AND, for a repo-scoped route, that |
| 222 | // repo must not be Hidden -- checked directly from RepoInfo, with no |
| 223 | // reference to any cookie at all. A cache entry is shared state replayed |
| 224 | // to whoever asks next, so a false negative here has a bigger blast |
| 225 | // radius than a single mis-issued header would: the Hidden check means a |
| 226 | // bug in cookie detection alone can never leak a hidden repo's content, |
| 227 | // since that repo's responses are never eligible for the shared cache in |
| 228 | // the first place. |
| 229 | func (s *Server) cacheEligible(rt Route, r *http.Request) (string, bool) { |
| 230 | if !rt.CacheableAnon || s.TemplateDir != "" { |
| 231 | return "", false |
| 232 | } |
| 233 | if s.Sessions.Verify(r) != "" { |
| 234 | return "", false |
| 235 | } |
| 236 | if _, ok := s.Sessions.VerifyShareCookie(r); ok { |
| 237 | return "", false |
| 238 | } |
| 239 | if rt.Scope == ScopeRepo { |
| 240 | h, ok := s.Site.Lookup(r.PathValue("repo")) |
| 241 | if !ok || h.Info.Hidden { |
| 242 | return "", false |
| 243 | } |
| 244 | } |
| 245 | return rt.Name + "\x00" + r.URL.Path + "\x00" + r.URL.RawQuery, true |
| 246 | } |
| 247 | |
| 248 | func (s *Server) respCacheGet(key string) (*cachedResponse, bool) { |
| 249 | v, ok := s.respCache.Get(key) |
| 250 | if !ok { |
| 251 | return nil, false |
| 252 | } |
| 253 | cr := v.(*cachedResponse) |
| 254 | if time.Now().After(cr.expiresAt) { |
| 255 | return nil, false |
| 256 | } |
| 257 | return cr, true |
| 258 | } |
| 259 | |
| 260 | // fillResponse builds a full response for storage in respCache: header and |
| 261 | // body both, since an entry has to be complete before anyone else can be |
| 262 | // served from it. |
| 263 | func (s *Server) fillResponse(ctx contexter, rt Route, r *http.Request) (*cachedResponse, error) { |
| 264 | c, header, err := s.buildResponseHead(ctx, rt, r) |
| 265 | if err != nil { |
| 266 | return nil, err |
| 267 | } |
| 268 | s.mu.RLock() |
| 269 | ts := s.templates |
| 270 | s.mu.RUnlock() |
| 271 | body, err := rt.Render.Render(c, ts) |
| 272 | if err != nil { |
| 273 | return nil, err |
| 274 | } |
| 275 | header.Set("Content-Type", rt.Render.ContentType()) |
| 276 | if hs, ok := rt.Render.(HeaderSetter); ok { |
| 277 | hs.Headers(c, header) |
| 278 | } |
| 279 | return &cachedResponse{header: header, body: body, expiresAt: time.Now().Add(c.cacheMaxAge())}, nil |
| 280 | } |
| 281 | |
| 282 | // buildResponseHead resolves the Ctx and computes the ETag/Cache-Control |
| 283 | // header, without executing the template -- split out so a matching |
| 284 | // If-None-Match can skip rendering the body entirely. |
| 285 | // |
| 286 | // ETag is derived from the resolved commit SHA and every other input that |
| 287 | // can change what's rendered, not the response body, so a conditional |
| 288 | // request costs one ref lookup instead of a render. |
| 289 | func (s *Server) buildResponseHead(ctx contexter, rt Route, r *http.Request) (*Ctx, http.Header, error) { |
| 290 | c, err := s.buildCtx(ctx, rt, r) |
| 291 | if err != nil { |
| 292 | return nil, nil, err |
| 293 | } |
| 294 | c.Nav = BuildNav(s.Routes, rt.Name, c.Info.Slug, c.Ref) |
| 295 | |
| 296 | s.mu.RLock() |
| 297 | tag := s.tmplTag |
| 298 | s.mu.RUnlock() |
| 299 | |
| 300 | // contentIdentity carries the commit, keyring, and every Var (e.g. |
| 301 | // compare's base ref) -- the same fragment module results are keyed |
| 302 | // on, so this can't drift out of sync with what CacheKey covers. |
| 303 | // |
| 304 | // c.User and the raw gsg_share cookie value are folded in here, |
| 305 | // deliberately not into contentIdentity itself: contentIdentity also |
| 306 | // drives Ctx.CacheKey, the cross-request module Store key, and module |
| 307 | // results (tree/blob/log/...) aren't user-dependent -- authorization |
| 308 | // happens in buildCtx's Lookup gate, not inside a module. Keying the |
| 309 | // shared LRU per grant would fragment it for no benefit. The ETag, |
| 310 | // unlike the module cache, must vary by identity: without c.User, |
| 311 | // logging in or out keeps the same ETag as the anonymous response and a |
| 312 | // browser's stale If-None-Match earns a 304 with the wrong body. The |
| 313 | // share cookie needs the same treatment for the same reason: two |
| 314 | // anonymous visitors holding different share grants both have |
| 315 | // c.User == "", so without it they'd collide on one ETag and one |
| 316 | // share-holder's listing could be served to another. The cookie's own |
| 317 | // signature bytes already uniquely identify the grant, and folding them |
| 318 | // into a hash never echoes them back in a header. |
| 319 | // |
| 320 | // s.StaticTag is folded in too: a CSS/JS-only edit changes every |
| 321 | // rendered `static "..."` URL, so page ETags have to move with it. |
| 322 | shareCookie := "" |
| 323 | if ck, err := r.Cookie(session.ShareCookieName); err == nil { |
| 324 | shareCookie = ck.Value |
| 325 | } |
| 326 | etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, s.StaticTag, c.contentIdentity(), c.User, shareCookie) + `"` |
| 327 | |
| 328 | header := http.Header{} |
| 329 | header.Set("ETag", etag) |
| 330 | if c.Immutable { |
| 331 | header.Set("Cache-Control", "public, max-age=31536000, immutable") |
| 332 | } else { |
| 333 | // Vary: Cookie applies to every response here, not just personalized |
| 334 | // ones -- the *absence* of gsg_user is also a cache dimension. Without |
| 335 | // it on the anonymous branch too, a shared cache could store an |
| 336 | // anonymous response keyed on URL alone and later serve it back to a |
| 337 | // request that does carry a session cookie. |
| 338 | header.Add("Vary", "Cookie") |
| 339 | if c.User != "" || len(c.SharedSlugs) > 0 { |
| 340 | // No shared/intermediary cache (proxy, CDN, or gsg's own |
| 341 | // respCache) may ever store this response, only the requesting |
| 342 | // browser -- otherwise a misbehaving shared cache could serve |
| 343 | // one user's hidden-repo listing to a different visitor. Vary: |
| 344 | // Cookie above is defense in depth for any cache that honors |
| 345 | // Vary but not Cache-Control: private. |
| 346 | header.Set("Cache-Control", "private, no-cache") |
| 347 | } else { |
| 348 | // public, with a real max-age rather than no-cache: the ETag |
| 349 | // already folds in c.User and the share cookie (above), so the |
| 350 | // only failure mode for a shared cache that mishandles Vary is |
| 351 | // serving this anonymous view to a logged-in or share-holding |
| 352 | // visitor within the TTL -- they see fewer repos, or a 404 on a |
| 353 | // hidden one. A UX papercut, not a disclosure: the dangerous |
| 354 | // direction (private content served to an anonymous visitor) |
| 355 | // can't happen here, since that's the separate branch above. |
| 356 | header.Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(c.cacheMaxAge().Seconds()))) |
| 357 | } |
| 358 | } |
| 359 | return c, header, nil |
| 360 | } |
| 361 | |
| 362 | func copyHeader(dst, src http.Header) { |
| 363 | for k, vs := range src { |
| 364 | for _, v := range vs { |
| 365 | dst.Add(k, v) |
| 366 | } |
| 367 | } |
| 368 | } |
| 369 | |
| 370 | func writeResponse(w http.ResponseWriter, r *http.Request, cr *cachedResponse) { |
| 371 | copyHeader(w.Header(), cr.header) |
| 372 | if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, cr.header.Get("ETag")) { |
| 373 | w.WriteHeader(http.StatusNotModified) |
| 374 | return |
| 375 | } |
| 376 | w.Write(cr.body) |
| 377 | } |
| 378 | |
| 379 | func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) { |
| 380 | // Resolved once, before the scope branch, so the site-scoped early |
| 381 | // return (the index page -- the main surface a login or share link is |
| 382 | // meant to affect) doesn't skip it. |
| 383 | user := s.Sessions.Verify(r) |
| 384 | var sharedSlugs []string |
| 385 | var sharedIssuedAt time.Time |
| 386 | if claims, ok := s.Sessions.VerifyShareCookie(r); ok { |
| 387 | sharedSlugs, sharedIssuedAt = claims.Slugs, claims.IssuedAt() |
| 388 | } |
| 389 | |
| 390 | if rt.Scope == ScopeSite { |
| 391 | c := NewCtx(s.Site, nil, RepoInfo{}, "") |
| 392 | c.Store = s.cache |
| 393 | c.User = user |
| 394 | c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt |
| 395 | c.StaticTag = s.StaticTag |
| 396 | return c, nil |
| 397 | } |
| 398 | |
| 399 | slug := r.PathValue("repo") |
| 400 | h, ok := s.Site.Lookup(slug) |
| 401 | if !ok { |
| 402 | return nil, Errorf(http.StatusNotFound, "no such repository %q", slug) |
| 403 | } |
| 404 | // Hidden repos are 404, indistinguishable from nonexistent, unless the |
| 405 | // resolved identity is explicitly allowed to see this one, either as an |
| 406 | // owner (gsg.visible-to) or via an unrevoked share grant. |
| 407 | if h.Info.Hidden && !h.Info.CanView(user) && !h.Info.SharedVisible(sharedSlugs, sharedIssuedAt) { |
| 408 | return nil, Errorf(http.StatusNotFound, "no such repository %q", slug) |
| 409 | } |
| 410 | repo := h.Repo.WithContext(ctx.Ctx(), s.MaxGit) |
| 411 | |
| 412 | // {rest...} carries ref and path fused together; {rev} is a bare ref; |
| 413 | // {range} is "base..head" or "base...head". |
| 414 | ref, path := "", "" |
| 415 | var base string |
| 416 | dots := "2" |
| 417 | |
| 418 | switch { |
| 419 | case r.PathValue("range") != "": |
| 420 | rng := r.PathValue("range") |
| 421 | var head string |
| 422 | if b, hd, ok := strings.Cut(rng, "..."); ok { |
| 423 | base, head, dots = b, hd, "3" |
| 424 | } else if b, hd, ok := strings.Cut(rng, ".."); ok { |
| 425 | base, head = b, hd |
| 426 | } else { |
| 427 | return nil, Errorf(http.StatusBadRequest, "compare needs base..head") |
| 428 | } |
| 429 | ref = head |
| 430 | case r.PathValue("rest") != "": |
| 431 | ref, path = repo.SplitRefPath(r.PathValue("rest")) |
| 432 | case r.PathValue("rev") != "": |
| 433 | ref = r.PathValue("rev") |
| 434 | default: |
| 435 | ref = repo.HeadRef() |
| 436 | } |
| 437 | |
| 438 | c := NewCtx(s.Site, repo, h.Info, ref) |
| 439 | c.Path = path |
| 440 | c.Store = s.cache |
| 441 | c.User = user |
| 442 | c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt |
| 443 | c.StaticTag = s.StaticTag |
| 444 | |
| 445 | if !repo.Empty() { |
| 446 | sha, err := repo.Resolve(ref) |
| 447 | if err != nil { |
| 448 | return nil, Errorf(http.StatusNotFound, "%v", err) |
| 449 | } |
| 450 | c.Commit = sha |
| 451 | } |
| 452 | if base != "" { |
| 453 | baseSHA, err := repo.Resolve(base) |
| 454 | if err != nil { |
| 455 | return nil, Errorf(http.StatusNotFound, "%v", err) |
| 456 | } |
| 457 | headSHA := c.Commit |
| 458 | // Two-dot compares are undirected as far as the caller is concerned: |
| 459 | // whichever ref is the ancestor should read as "base", regardless of |
| 460 | // which side of ".." it was typed on. Three-dot is left alone -- |
| 461 | // its merge-base semantics are direction-sensitive by design. |
| 462 | if dots == "2" && baseSHA != headSHA { |
| 463 | if anc, err := repo.IsAncestor(headSHA, baseSHA); err == nil && anc { |
| 464 | base, ref = ref, base |
| 465 | baseSHA, headSHA = headSHA, baseSHA |
| 466 | } |
| 467 | } |
| 468 | c.Ref, c.Commit = ref, headSHA |
| 469 | c.Vars["base"], c.Vars["head"] = base, ref |
| 470 | c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, headSHA |
| 471 | c.Vars["dots"] = dots |
| 472 | } |
| 473 | if rt.Name == "log" || rt.Name == "graph" { |
| 474 | pageSize := LogPageSize |
| 475 | if rt.Name == "graph" { |
| 476 | pageSize = GraphPageSize |
| 477 | } |
| 478 | if v := r.URL.Query().Get("ofs"); v != "" { |
| 479 | if ofs, err := strconv.Atoi(v); err == nil && ofs > 0 { |
| 480 | ofs -= ofs % pageSize // snap to a page boundary before it can become a cache key |
| 481 | c.Vars["ofs"] = strconv.Itoa(ofs) |
| 482 | } |
| 483 | } |
| 484 | } |
| 485 | // One source of truth for the immutable policy: the cache header in |
| 486 | // buildResponseHead reads the same field, so page chrome that must not |
| 487 | // be frozen into a year-long response is suppressed by exactly the |
| 488 | // condition that makes the response year-long. |
| 489 | c.Immutable = rt.Immutable && c.Commit != "" |
| 490 | return c, nil |
| 491 | } |
| 492 | |
| 493 | func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err error) { |
| 494 | code := statusOf(err) |
| 495 | if errors.Is(err, gitx.ErrTruncated) { |
| 496 | code = http.StatusRequestEntityTooLarge |
| 497 | } |
| 498 | if code >= 500 { |
| 499 | s.Log.Error("render failed", "route", rt.Name, "path", r.URL.Path, "err", err) |
| 500 | } else { |
| 501 | s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err) |
| 502 | } |
| 503 | |
| 504 | // The cached-response and cache-fill paths in serve() only ever reach |
| 505 | // fail() before anything is written to w, so for those there is |
| 506 | // nothing to strip. But the plain (non-cacheable) path copies |
| 507 | // buildResponseHead's ETag/Cache-Control onto w *before* calling |
| 508 | // Render, on the assumption that Render will succeed -- if it doesn't, |
| 509 | // this Del/Set is still what prevents a failed response from going out |
| 510 | // with a stale ETag or, worse, the year-long immutable Cache-Control |
| 511 | // from a commit route. |
| 512 | w.Header().Del("ETag") |
| 513 | w.Header().Set("Cache-Control", "no-store") |
| 514 | |
| 515 | body, cType := renderErrorPage(code, s.Site.Name) |
| 516 | w.Header().Set("Content-Type", cType) |
| 517 | w.WriteHeader(code) |
| 518 | w.Write(body) |
| 519 | } |
| 520 | |
| 521 | func shortHash(parts ...string) string { |
| 522 | h := sha256.New() |
| 523 | for _, p := range parts { |
| 524 | h.Write([]byte(p)) |
| 525 | h.Write([]byte{0}) |
| 526 | } |
| 527 | return hex.EncodeToString(h.Sum(nil))[:16] |
| 528 | } |