Commit 4fdea28d08
4fdea28d0884e49302a8555e85ae70ec001d940b
parent: 305b676a8d
Verified · cmc ci/build: success ci/test: success ci/vuln: failure
cmc <hello@cleberg.net> · 2026-10-12 04:19 UTC
web: ETag and Cache-Control on raw and wiki/_raw
The ETag is the blob id; a matching If-None-Match gets a 304 before the blob is read. A branch or tag is no-cache, a full commit sha is immutable for a year, and a private repository's responses are private.
Closes #329
Layout: unified · split
internal/gitutil/read.go
+20
| @@ -88,6 +88,26 @@ func ReadBlob(dir, ref, path string, limit int64) ([]byte, error) { |
| 88 | 88 | return data, err |
| 89 | 89 | } |
| 90 | 90 | |
| 91 | // BlobID returns the object id of the blob at ref:path; errors if the path |
| 92 | // is absent or not a blob. |
| 93 | func BlobID(dir, ref, path string) (string, error) { |
| 94 | spec := ref + ":" + path |
| 95 | if strings.ContainsAny(spec, "\n\r") { |
| 96 | return "", fmt.Errorf("no blob %s", spec) |
| 97 | } |
| 98 | cmd := exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "--batch-check=%(objecttype) %(objectname)") |
| 99 | cmd.Stdin = strings.NewReader(spec + "\n") |
| 100 | out, err := cmd.Output() |
| 101 | if err != nil { |
| 102 | return "", fmt.Errorf("cat-file %s: %w", spec, err) |
| 103 | } |
| 104 | f := strings.Fields(string(out)) |
| 105 | if len(f) != 2 || f[0] != "blob" { |
| 106 | return "", fmt.Errorf("no blob %s", spec) |
| 107 | } |
| 108 | return f[1], nil |
| 109 | } |
| 110 | |
| 91 | 111 | // ResolveRef resolves a ref or sha to a full commit sha; errors if absent. |
| 92 | 112 | func ResolveRef(dir, ref string) (string, error) { |
| 93 | 113 | cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--quiet", "--end-of-options", ref+"^{commit}") |
internal/httpd/rawcache_test.go
added
+61
| @@ -0,0 +1,61 @@ |
| 1 | package httpd |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | |
| 6 | "gitbay.org/gitbay/internal/control" |
| 7 | "gitbay.org/gitbay/internal/gitutil" |
| 8 | ) |
| 9 | |
| 10 | // Raw content carries its blob id as an ETag: a branch is revalidated and |
| 11 | // answered with a 304 while the blob is unchanged, a commit sha is |
| 12 | // immutable, and a different blob gets a different tag. |
| 13 | func TestRawCaching(t *testing.T) { |
| 14 | s := symbolServer(t) |
| 15 | h := s.Handler() |
| 16 | |
| 17 | w := get(t, h, "/alice/app/raw/main/main.go", nil) |
| 18 | if w.Code != 200 || w.Body.Len() == 0 { |
| 19 | t.Fatalf("raw: %d, %d bytes", w.Code, w.Body.Len()) |
| 20 | } |
| 21 | etag := w.Header().Get("ETag") |
| 22 | if etag == "" { |
| 23 | t.Fatal("no ETag") |
| 24 | } |
| 25 | if got := w.Header().Get("Cache-Control"); got != "public, no-cache" { |
| 26 | t.Errorf("branch Cache-Control = %q", got) |
| 27 | } |
| 28 | |
| 29 | w = get(t, h, "/alice/app/raw/main/main.go", map[string]string{"If-None-Match": etag}) |
| 30 | if w.Code != 304 || w.Body.Len() != 0 { |
| 31 | t.Errorf("revalidation: %d, %d bytes", w.Code, w.Body.Len()) |
| 32 | } |
| 33 | w = get(t, h, "/alice/app/raw/main/main.go", map[string]string{"If-None-Match": `W/"0000", ` + etag}) |
| 34 | if w.Code != 304 { |
| 35 | t.Errorf("weak list revalidation: %d", w.Code) |
| 36 | } |
| 37 | |
| 38 | sha, err := gitutil.ResolveRef(control.RepoDir(s.cfg.Server.Root, "alice", "app"), "main") |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | w = get(t, h, "/alice/app/raw/"+sha+"/main.go", nil) |
| 43 | if w.Code != 200 { |
| 44 | t.Fatalf("raw at sha: %d", w.Code) |
| 45 | } |
| 46 | if got := w.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { |
| 47 | t.Errorf("sha Cache-Control = %q", got) |
| 48 | } |
| 49 | if w.Header().Get("ETag") != etag { |
| 50 | t.Errorf("same blob, different ETag: %q vs %q", w.Header().Get("ETag"), etag) |
| 51 | } |
| 52 | |
| 53 | w = get(t, h, "/alice/app/raw/main/a/dup.go", map[string]string{"If-None-Match": etag}) |
| 54 | if w.Code != 200 || w.Header().Get("ETag") == etag { |
| 55 | t.Errorf("another blob: %d, ETag %q", w.Code, w.Header().Get("ETag")) |
| 56 | } |
| 57 | |
| 58 | if w := get(t, h, "/alice/app/raw/main/a", nil); w.Code != 404 { |
| 59 | t.Errorf("a tree path: %d", w.Code) |
| 60 | } |
| 61 | } |
internal/httpd/web.go
+45
| @@ -1222,6 +1222,14 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { |
| 1222 | 1222 | return |
| 1223 | 1223 | } |
| 1224 | 1224 | filePath := strings.Trim(r.PathValue("path"), "/") |
| 1225 | oid, err := gitutil.BlobID(p.Dir, p.Ref, filePath) |
| 1226 | if err != nil { |
| 1227 | s.notFound(w, r) |
| 1228 | return |
| 1229 | } |
| 1230 | if blobNotModified(w, r, p.Repo, p.Ref, oid) { |
| 1231 | return |
| 1232 | } |
| 1225 | 1233 | data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes) |
| 1226 | 1234 | if err != nil { |
| 1227 | 1235 | s.notFound(w, r) |
| @@ -1239,6 +1247,43 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { |
| 1239 | 1247 | w.Write(data) |
| 1240 | 1248 | } |
| 1241 | 1249 | |
| 1250 | // blobNotModified sets the caching headers for blob oid served at ref and |
| 1251 | // writes a 304 when the request already holds it. The ETag is the blob id, |
| 1252 | // so a revalidation costs one object lookup. A full commit sha cannot |
| 1253 | // change what it names, so that response is immutable; a branch or tag is |
| 1254 | // revalidated on every use. Private content stays out of shared caches. |
| 1255 | func blobNotModified(w http.ResponseWriter, r *http.Request, repo store.Repo, ref, oid string) bool { |
| 1256 | scope := "public" |
| 1257 | if repo.Visibility == "private" { |
| 1258 | scope = "private" |
| 1259 | } |
| 1260 | if fullSHA(ref) { |
| 1261 | w.Header().Set("Cache-Control", scope+", max-age=31536000, immutable") |
| 1262 | } else { |
| 1263 | w.Header().Set("Cache-Control", scope+", no-cache") |
| 1264 | } |
| 1265 | etag := `"` + oid + `"` |
| 1266 | w.Header().Set("ETag", etag) |
| 1267 | if match := r.Header.Get("If-None-Match"); match != "" && etagMatches(match, etag) { |
| 1268 | w.WriteHeader(http.StatusNotModified) |
| 1269 | return true |
| 1270 | } |
| 1271 | return false |
| 1272 | } |
| 1273 | |
| 1274 | // fullSHA reports whether ref is a whole SHA-1 or SHA-256 object name. |
| 1275 | func fullSHA(ref string) bool { |
| 1276 | if len(ref) != 40 && len(ref) != 64 { |
| 1277 | return false |
| 1278 | } |
| 1279 | for i := 0; i < len(ref); i++ { |
| 1280 | if c := ref[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') { |
| 1281 | return false |
| 1282 | } |
| 1283 | } |
| 1284 | return true |
| 1285 | } |
| 1286 | |
| 1242 | 1287 | // imageTypes are the formats raw serves with a real content type and blob |
| 1243 | 1288 | // pages preview inline. |
| 1244 | 1289 | var imageTypes = map[string]string{ |
internal/httpd/wiki.go
+8
| @@ -173,6 +173,14 @@ func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) { |
| 173 | 173 | s.notFound(w, r) |
| 174 | 174 | return |
| 175 | 175 | } |
| 176 | oid, err := gitutil.BlobID(dir, branch, rel) |
| 177 | if err != nil { |
| 178 | s.notFound(w, r) |
| 179 | return |
| 180 | } |
| 181 | if blobNotModified(w, r, p.Repo, branch, oid) { |
| 182 | return |
| 183 | } |
| 176 | 184 | data, err := gitutil.ReadBlob(dir, branch, rel, s.cfg.Limits.MaxBlobBytes) |
| 177 | 185 | if err != nil { |
| 178 | 186 | s.notFound(w, r) |