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. |
| |
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 | // 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. |
| 92 | func ResolveRef(dir, ref string) (string, error) { |
112 | func 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 @@ |
| |
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 | 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. |
| |
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 | // 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. |
| 1244 | var imageTypes = map[string]string{ |
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 | 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) |