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 return data, err 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.
93func 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// ResolveRef resolves a ref or sha to a full commit sha; errors if absent. 111// ResolveRef resolves a ref or sha to a full commit sha; errors if absent.
92func ResolveRef(dir, ref string) (string, error) { 112func ResolveRef(dir, ref string) (string, error) {
93 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--quiet", "--end-of-options", ref+"^{commit}") 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 @@
1package httpd
2
3import (
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.
13func 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 return 1222 return
1223 } 1223 }
1224 filePath := strings.Trim(r.PathValue("path"), "/") 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 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes) 1233 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
1226 if err != nil { 1234 if err != nil {
1227 s.notFound(w, r) 1235 s.notFound(w, r)
@@ -1239,6 +1247,43 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
1239 w.Write(data) 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.
1255func 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.
1275func 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// imageTypes are the formats raw serves with a real content type and blob 1287// imageTypes are the formats raw serves with a real content type and blob
1243// pages preview inline. 1288// pages preview inline.
1244var imageTypes = map[string]string{ 1289var imageTypes = map[string]string{
internal/httpd/wiki.go +8
@@ -173,6 +173,14 @@ func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) {
173 s.notFound(w, r) 173 s.notFound(w, r)
174 return 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 data, err := gitutil.ReadBlob(dir, branch, rel, s.cfg.Limits.MaxBlobBytes) 184 data, err := gitutil.ReadBlob(dir, branch, rel, s.cfg.Limits.MaxBlobBytes)
177 if err != nil { 185 if err != nil {
178 s.notFound(w, r) 186 s.notFound(w, r)