perf(web): gzip pages and assets, add ETag revalidation to public pages
- text assets precompressed once at load and served by Accept-Encoding - rendered pages over 1 KiB gzipped with pooled writers - strong ETag from the uncompressed body and sign-in state, -gz for gzip - If-None-Match answers 304; owner and no-store pages never revalidate
- commit
- e42fec575cfa6e2d99efeb01531dc667bec41b81
- author
- brice · · 2026-10-11 18:37 UTC
- parent
- a2fc532
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +552 -8
M cmd/ermite/web_mr_test.go +2 -2
| @@ -67,7 +67,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||
| 67 | 67 | anon := b.get("/proj/compare/feat/search", nil) | |
| 68 | 68 | require.Equal(t, 200, anon.status, anon.body) | |
| 69 | 69 | assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control")) | |
| 70 | - | assert.Empty(t, anon.hdr.Values("Vary")) | |
| 70 | + | assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary")) | |
| 71 | 71 | assert.Contains(t, anon.body, "feat/search") | |
| 72 | 72 | assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit") | |
| 73 | 73 | assert.NotContains(t, anon.body, "<form class=\"cform create\"") | |
| @@ -129,7 +129,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||
| 129 | 129 | mr := b.get("/proj/merge-requests/1", nil) | |
| 130 | 130 | require.Equal(t, 200, mr.status, mr.body) | |
| 131 | 131 | assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control")) | |
| 132 | - | assert.Empty(t, mr.hdr.Values("Vary")) | |
| 132 | + | assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary")) | |
| 133 | 133 | assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add <b>search</b></h1>") | |
| 134 | 134 | assert.NotContains(t, mr.body, "<b>search</b>") | |
| 135 | 135 | assert.Contains(t, mr.body, "feat/search") | |
| @@ -67,7 +67,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||||
| 67 | anon := b.get("/proj/compare/feat/search", nil) | 67 | anon := b.get("/proj/compare/feat/search", nil) | ||
| 68 | require.Equal(t, 200, anon.status, anon.body) | 68 | require.Equal(t, 200, anon.status, anon.body) | ||
| 69 | assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control")) | 69 | assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control")) | ||
| 70 | - | assert.Empty(t, anon.hdr.Values("Vary")) | 70 | + | assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary")) |
| 71 | assert.Contains(t, anon.body, "feat/search") | 71 | assert.Contains(t, anon.body, "feat/search") | ||
| 72 | assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit") | 72 | assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit") | ||
| 73 | assert.NotContains(t, anon.body, "<form class=\"cform create\"") | 73 | assert.NotContains(t, anon.body, "<form class=\"cform create\"") | ||
| @@ -129,7 +129,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||||
| 129 | mr := b.get("/proj/merge-requests/1", nil) | 129 | mr := b.get("/proj/merge-requests/1", nil) | ||
| 130 | require.Equal(t, 200, mr.status, mr.body) | 130 | require.Equal(t, 200, mr.status, mr.body) | ||
| 131 | assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control")) | 131 | assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control")) | ||
| 132 | - | assert.Empty(t, mr.hdr.Values("Vary")) | 132 | + | assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary")) |
| 133 | assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add <b>search</b></h1>") | 133 | assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add <b>search</b></h1>") | ||
| 134 | assert.NotContains(t, mr.body, "<b>search</b>") | 134 | assert.NotContains(t, mr.body, "<b>search</b>") | ||
| 135 | assert.Contains(t, mr.body, "feat/search") | 135 | assert.Contains(t, mr.body, "feat/search") | ||
M cmd/ermite/web_session_test.go +1 -1
| @@ -115,7 +115,7 @@ func TestWebLoginEndToEnd(t *testing.T) { | |||
| 115 | 115 | assert.Contains(t, home.body, "pub") | |
| 116 | 116 | assert.NotContains(t, home.body, "secret") | |
| 117 | 117 | assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) | |
| 118 | - | assert.Empty(t, home.hdr.Values("Vary")) | |
| 118 | + | assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page") | |
| 119 | 119 | assert.NotContains(t, home.body, `action="/-/logout"`) | |
| 120 | 120 | assert.Equal(t, 404, b.get("/secret", nil).status) | |
| 121 | 121 | ||
| @@ -115,7 +115,7 @@ func TestWebLoginEndToEnd(t *testing.T) { | |||||
| 115 | assert.Contains(t, home.body, "pub") | 115 | assert.Contains(t, home.body, "pub") | ||
| 116 | assert.NotContains(t, home.body, "secret") | 116 | assert.NotContains(t, home.body, "secret") | ||
| 117 | assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) | 117 | assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) | ||
| 118 | - | assert.Empty(t, home.hdr.Values("Vary")) | 118 | + | assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page") |
| 119 | assert.NotContains(t, home.body, `action="/-/logout"`) | 119 | assert.NotContains(t, home.body, `action="/-/logout"`) | ||
| 120 | assert.Equal(t, 404, b.get("/secret", nil).status) | 120 | assert.Equal(t, 404, b.get("/secret", nil).status) | ||
| 121 | 121 | ||||
M internal/adapters/web/assets.go +29 -3
| @@ -34,6 +34,9 @@ type Assets struct { | |||
| 34 | 34 | type asset struct { | |
| 35 | 35 | body []byte | |
| 36 | 36 | typ string | |
| 37 | + | // gz is the gzip form of body, compressed once at load; nil when the type | |
| 38 | + | // is already compressed or gzip does not make the body smaller. | |
| 39 | + | gz []byte | |
| 37 | 40 | } | |
| 38 | 41 | ||
| 39 | 42 | // assetSet is the embedded files by hashed URL path (under StaticPrefix). | |
| @@ -70,7 +73,13 @@ func loadAssets() (*assetSet, error) { | |||
| 70 | 73 | sum := sha256.Sum256(body) | |
| 71 | 74 | ext := path.Ext(file) | |
| 72 | 75 | name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext | |
| 73 | - | s.byName[name] = asset{body: body, typ: typ} | |
| 76 | + | a := asset{body: body, typ: typ} | |
| 77 | + | if compressible(typ) { | |
| 78 | + | if z, err := gzipBest(body); err == nil && len(z) < len(body) { | |
| 79 | + | a.gz = z | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | s.byName[name] = a | |
| 74 | 83 | return StaticPrefix + name | |
| 75 | 84 | } | |
| 76 | 85 | ||
| @@ -148,8 +157,25 @@ func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) { | |||
| 148 | 157 | h := w.Header() | |
| 149 | 158 | h.Set("Content-Type", a.typ) | |
| 150 | 159 | h.Set("Cache-Control", "public, max-age=31536000, immutable") | |
| 151 | - | h.Set("Content-Length", itoa(len(a.body))) | |
| 160 | + | body := a.body | |
| 161 | + | if a.gz != nil { | |
| 162 | + | addVary(h, "Accept-Encoding") | |
| 163 | + | if acceptsGzip(r.Header) { | |
| 164 | + | body = a.gz | |
| 165 | + | h.Set("Content-Encoding", "gzip") | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | h.Set("Content-Length", itoa(len(body))) | |
| 152 | 169 | if r.Method != http.MethodHead { | |
| 153 | - | _, _ = w.Write(a.body) | |
| 170 | + | _, _ = w.Write(body) | |
| 154 | 171 | } | |
| 155 | 172 | } | |
| 173 | + | ||
| 174 | + | // compressible tells whether a content type is worth gzip: text and | |
| 175 | + | // structured text. Fonts (woff2) and raster images are compressed already. | |
| 176 | + | func compressible(typ string) bool { | |
| 177 | + | mt, _, _ := strings.Cut(typ, ";") | |
| 178 | + | mt = strings.TrimSpace(strings.ToLower(mt)) | |
| 179 | + | return strings.HasPrefix(mt, "text/") || mt == "image/svg+xml" || | |
| 180 | + | mt == "application/javascript" || strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "/json") | |
| 181 | + | } | |
| @@ -34,6 +34,9 @@ type Assets struct { | |||||
| 34 | type asset struct { | 34 | type asset struct { | ||
| 35 | body []byte | 35 | body []byte | ||
| 36 | typ string | 36 | typ string | ||
| 37 | + | // gz is the gzip form of body, compressed once at load; nil when the type | |||
| 38 | + | // is already compressed or gzip does not make the body smaller. | |||
| 39 | + | gz []byte | |||
| 37 | } | 40 | } | ||
| 38 | 41 | ||||
| 39 | // assetSet is the embedded files by hashed URL path (under StaticPrefix). | 42 | // assetSet is the embedded files by hashed URL path (under StaticPrefix). | ||
| @@ -70,7 +73,13 @@ func loadAssets() (*assetSet, error) { | |||||
| 70 | sum := sha256.Sum256(body) | 73 | sum := sha256.Sum256(body) | ||
| 71 | ext := path.Ext(file) | 74 | ext := path.Ext(file) | ||
| 72 | name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext | 75 | name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext | ||
| 73 | - | s.byName[name] = asset{body: body, typ: typ} | 76 | + | a := asset{body: body, typ: typ} |
| 77 | + | if compressible(typ) { | |||
| 78 | + | if z, err := gzipBest(body); err == nil && len(z) < len(body) { | |||
| 79 | + | a.gz = z | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | s.byName[name] = a | |||
| 74 | return StaticPrefix + name | 83 | return StaticPrefix + name | ||
| 75 | } | 84 | } | ||
| 76 | 85 | ||||
| @@ -148,8 +157,25 @@ func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) { | |||||
| 148 | h := w.Header() | 157 | h := w.Header() | ||
| 149 | h.Set("Content-Type", a.typ) | 158 | h.Set("Content-Type", a.typ) | ||
| 150 | h.Set("Cache-Control", "public, max-age=31536000, immutable") | 159 | h.Set("Cache-Control", "public, max-age=31536000, immutable") | ||
| 151 | - | h.Set("Content-Length", itoa(len(a.body))) | 160 | + | body := a.body |
| 161 | + | if a.gz != nil { | |||
| 162 | + | addVary(h, "Accept-Encoding") | |||
| 163 | + | if acceptsGzip(r.Header) { | |||
| 164 | + | body = a.gz | |||
| 165 | + | h.Set("Content-Encoding", "gzip") | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | h.Set("Content-Length", itoa(len(body))) | |||
| 152 | if r.Method != http.MethodHead { | 169 | if r.Method != http.MethodHead { | ||
| 153 | - | _, _ = w.Write(a.body) | 170 | + | _, _ = w.Write(body) |
| 154 | } | 171 | } | ||
| 155 | } | 172 | } | ||
| 173 | + | ||||
| 174 | + | // compressible tells whether a content type is worth gzip: text and | |||
| 175 | + | // structured text. Fonts (woff2) and raster images are compressed already. | |||
| 176 | + | func compressible(typ string) bool { | |||
| 177 | + | mt, _, _ := strings.Cut(typ, ";") | |||
| 178 | + | mt = strings.TrimSpace(strings.ToLower(mt)) | |||
| 179 | + | return strings.HasPrefix(mt, "text/") || mt == "image/svg+xml" || | |||
| 180 | + | mt == "application/javascript" || strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "/json") | |||
| 181 | + | } | |||
A internal/adapters/web/compress.go +176 -0
| @@ -0,0 +1,176 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "compress/gzip" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/hex" | |
| 10 | + | "net/http" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "sync" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // minCompress is the size under which a page is sent as it is: the gzip | |
| 17 | + | // header and the CPU cost are not worth it for a few hundred bytes. | |
| 18 | + | const minCompress = 1024 | |
| 19 | + | ||
| 20 | + | // gzipLevel is the level of the pages, compressed on each request: a good | |
| 21 | + | // ratio at a cost small enough for a render that already takes longer. The | |
| 22 | + | // assets, compressed once at start, use the best level. | |
| 23 | + | const gzipLevel = 5 | |
| 24 | + | ||
| 25 | + | // gzipWriters recycles the writers of the pages: a gzip.Writer holds about | |
| 26 | + | // 800 KiB of state. | |
| 27 | + | var gzipWriters = sync.Pool{New: func() any { | |
| 28 | + | zw, _ := gzip.NewWriterLevel(nil, gzipLevel) // the level is valid | |
| 29 | + | return zw | |
| 30 | + | }} | |
| 31 | + | ||
| 32 | + | // gzipPage compresses b with a pooled writer. | |
| 33 | + | func gzipPage(b []byte) ([]byte, error) { | |
| 34 | + | var out bytes.Buffer | |
| 35 | + | zw := gzipWriters.Get().(*gzip.Writer) | |
| 36 | + | defer gzipWriters.Put(zw) | |
| 37 | + | zw.Reset(&out) | |
| 38 | + | if _, err := zw.Write(b); err != nil { | |
| 39 | + | return nil, err | |
| 40 | + | } | |
| 41 | + | if err := zw.Close(); err != nil { | |
| 42 | + | return nil, err | |
| 43 | + | } | |
| 44 | + | return out.Bytes(), nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // gzipBest compresses b once, at the best level (for the assets). | |
| 48 | + | func gzipBest(b []byte) ([]byte, error) { | |
| 49 | + | var out bytes.Buffer | |
| 50 | + | zw, err := gzip.NewWriterLevel(&out, gzip.BestCompression) | |
| 51 | + | if err != nil { | |
| 52 | + | return nil, err | |
| 53 | + | } | |
| 54 | + | if _, err := zw.Write(b); err != nil { | |
| 55 | + | return nil, err | |
| 56 | + | } | |
| 57 | + | if err := zw.Close(); err != nil { | |
| 58 | + | return nil, err | |
| 59 | + | } | |
| 60 | + | return out.Bytes(), nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // acceptsGzip reports whether the Accept-Encoding of a request allows gzip | |
| 64 | + | // (RFC 9110 §12.5.3): gzip, or its alias x-gzip, with a quality above 0, or | |
| 65 | + | // "*" when gzip is not named. A quality of 0 refuses the coding; a malformed | |
| 66 | + | // quality is a refusal as well. | |
| 67 | + | func acceptsGzip(h http.Header) bool { | |
| 68 | + | star, starSet := false, false | |
| 69 | + | for _, line := range h.Values("Accept-Encoding") { | |
| 70 | + | for part := range strings.SplitSeq(line, ",") { | |
| 71 | + | coding, params, _ := strings.Cut(part, ";") | |
| 72 | + | coding = strings.ToLower(strings.TrimSpace(coding)) | |
| 73 | + | if coding != "gzip" && coding != "x-gzip" && coding != "*" { | |
| 74 | + | continue | |
| 75 | + | } | |
| 76 | + | ok := qualityPositive(params) | |
| 77 | + | switch coding { | |
| 78 | + | case "*": | |
| 79 | + | star, starSet = ok, true | |
| 80 | + | default: | |
| 81 | + | return ok | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | return starSet && star | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // qualityPositive reads the q parameter of a coding (1 when absent) and | |
| 89 | + | // reports whether it is above 0. | |
| 90 | + | func qualityPositive(params string) bool { | |
| 91 | + | for p := range strings.SplitSeq(params, ";") { | |
| 92 | + | k, v, found := strings.Cut(p, "=") | |
| 93 | + | if !found || !strings.EqualFold(strings.TrimSpace(k), "q") { | |
| 94 | + | continue | |
| 95 | + | } | |
| 96 | + | q, err := strconv.ParseFloat(strings.TrimSpace(v), 64) | |
| 97 | + | return err == nil && q > 0 | |
| 98 | + | } | |
| 99 | + | return true | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // addVary adds a field to Vary unless it is already there (or Vary is *). | |
| 103 | + | func addVary(h http.Header, field string) { | |
| 104 | + | for _, v := range h.Values("Vary") { | |
| 105 | + | for f := range strings.SplitSeq(v, ",") { | |
| 106 | + | if t := strings.TrimSpace(f); t == "*" || strings.EqualFold(t, field) { | |
| 107 | + | return | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | h.Add("Vary", field) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // noTransform tells whether Cache-Control forbids changing the body. | |
| 115 | + | func noTransform(h http.Header) bool { | |
| 116 | + | return hasCacheDirective(h, "no-transform") | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // hasCacheDirective tells whether Cache-Control holds the directive. | |
| 120 | + | func hasCacheDirective(h http.Header, name string) bool { | |
| 121 | + | for _, v := range h.Values("Cache-Control") { | |
| 122 | + | for d := range strings.SplitSeq(v, ",") { | |
| 123 | + | if strings.EqualFold(strings.TrimSpace(d), name) { | |
| 124 | + | return true | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // pageETag is the strong validator of a rendered page: a hash of its | |
| 132 | + | // identity bytes and of the session state, so that the page of the owner | |
| 133 | + | // never shares a tag with the page of a visitor. The gzip representation | |
| 134 | + | // has a tag of its own (RFC 9110 §8.8.3). | |
| 135 | + | func pageETag(body []byte, signedIn, gzipped bool) string { | |
| 136 | + | h := sha256.New() | |
| 137 | + | if signedIn { | |
| 138 | + | h.Write([]byte{1}) | |
| 139 | + | } else { | |
| 140 | + | h.Write([]byte{0}) | |
| 141 | + | } | |
| 142 | + | h.Write(body) | |
| 143 | + | tag := hex.EncodeToString(h.Sum(nil)[:16]) | |
| 144 | + | if gzipped { | |
| 145 | + | tag += "-gz" | |
| 146 | + | } | |
| 147 | + | return `"` + tag + `"` | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // etagMatches implements the weak comparison of If-None-Match (RFC 9110 | |
| 151 | + | // §13.1.2): a list of entity tags, each optionally weak, or "*". | |
| 152 | + | func etagMatches(header, etag string) bool { | |
| 153 | + | want := strings.TrimPrefix(etag, "W/") | |
| 154 | + | s := header | |
| 155 | + | for { | |
| 156 | + | s = strings.TrimLeft(s, " \t,") | |
| 157 | + | if s == "" { | |
| 158 | + | return false | |
| 159 | + | } | |
| 160 | + | if s[0] == '*' { | |
| 161 | + | return true | |
| 162 | + | } | |
| 163 | + | s = strings.TrimPrefix(s, "W/") | |
| 164 | + | if s == "" || s[0] != '"' { | |
| 165 | + | return false // malformed: nothing after this can be trusted | |
| 166 | + | } | |
| 167 | + | end := strings.IndexByte(s[1:], '"') | |
| 168 | + | if end < 0 { | |
| 169 | + | return false | |
| 170 | + | } | |
| 171 | + | if s[:end+2] == want { | |
| 172 | + | return true | |
| 173 | + | } | |
| 174 | + | s = s[end+2:] | |
| 175 | + | } | |
| 176 | + | } | |
| @@ -0,0 +1,176 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "compress/gzip" | |||
| 8 | + | "crypto/sha256" | |||
| 9 | + | "encoding/hex" | |||
| 10 | + | "net/http" | |||
| 11 | + | "strconv" | |||
| 12 | + | "strings" | |||
| 13 | + | "sync" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // minCompress is the size under which a page is sent as it is: the gzip | |||
| 17 | + | // header and the CPU cost are not worth it for a few hundred bytes. | |||
| 18 | + | const minCompress = 1024 | |||
| 19 | + | ||||
| 20 | + | // gzipLevel is the level of the pages, compressed on each request: a good | |||
| 21 | + | // ratio at a cost small enough for a render that already takes longer. The | |||
| 22 | + | // assets, compressed once at start, use the best level. | |||
| 23 | + | const gzipLevel = 5 | |||
| 24 | + | ||||
| 25 | + | // gzipWriters recycles the writers of the pages: a gzip.Writer holds about | |||
| 26 | + | // 800 KiB of state. | |||
| 27 | + | var gzipWriters = sync.Pool{New: func() any { | |||
| 28 | + | zw, _ := gzip.NewWriterLevel(nil, gzipLevel) // the level is valid | |||
| 29 | + | return zw | |||
| 30 | + | }} | |||
| 31 | + | ||||
| 32 | + | // gzipPage compresses b with a pooled writer. | |||
| 33 | + | func gzipPage(b []byte) ([]byte, error) { | |||
| 34 | + | var out bytes.Buffer | |||
| 35 | + | zw := gzipWriters.Get().(*gzip.Writer) | |||
| 36 | + | defer gzipWriters.Put(zw) | |||
| 37 | + | zw.Reset(&out) | |||
| 38 | + | if _, err := zw.Write(b); err != nil { | |||
| 39 | + | return nil, err | |||
| 40 | + | } | |||
| 41 | + | if err := zw.Close(); err != nil { | |||
| 42 | + | return nil, err | |||
| 43 | + | } | |||
| 44 | + | return out.Bytes(), nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // gzipBest compresses b once, at the best level (for the assets). | |||
| 48 | + | func gzipBest(b []byte) ([]byte, error) { | |||
| 49 | + | var out bytes.Buffer | |||
| 50 | + | zw, err := gzip.NewWriterLevel(&out, gzip.BestCompression) | |||
| 51 | + | if err != nil { | |||
| 52 | + | return nil, err | |||
| 53 | + | } | |||
| 54 | + | if _, err := zw.Write(b); err != nil { | |||
| 55 | + | return nil, err | |||
| 56 | + | } | |||
| 57 | + | if err := zw.Close(); err != nil { | |||
| 58 | + | return nil, err | |||
| 59 | + | } | |||
| 60 | + | return out.Bytes(), nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // acceptsGzip reports whether the Accept-Encoding of a request allows gzip | |||
| 64 | + | // (RFC 9110 §12.5.3): gzip, or its alias x-gzip, with a quality above 0, or | |||
| 65 | + | // "*" when gzip is not named. A quality of 0 refuses the coding; a malformed | |||
| 66 | + | // quality is a refusal as well. | |||
| 67 | + | func acceptsGzip(h http.Header) bool { | |||
| 68 | + | star, starSet := false, false | |||
| 69 | + | for _, line := range h.Values("Accept-Encoding") { | |||
| 70 | + | for part := range strings.SplitSeq(line, ",") { | |||
| 71 | + | coding, params, _ := strings.Cut(part, ";") | |||
| 72 | + | coding = strings.ToLower(strings.TrimSpace(coding)) | |||
| 73 | + | if coding != "gzip" && coding != "x-gzip" && coding != "*" { | |||
| 74 | + | continue | |||
| 75 | + | } | |||
| 76 | + | ok := qualityPositive(params) | |||
| 77 | + | switch coding { | |||
| 78 | + | case "*": | |||
| 79 | + | star, starSet = ok, true | |||
| 80 | + | default: | |||
| 81 | + | return ok | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | return starSet && star | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // qualityPositive reads the q parameter of a coding (1 when absent) and | |||
| 89 | + | // reports whether it is above 0. | |||
| 90 | + | func qualityPositive(params string) bool { | |||
| 91 | + | for p := range strings.SplitSeq(params, ";") { | |||
| 92 | + | k, v, found := strings.Cut(p, "=") | |||
| 93 | + | if !found || !strings.EqualFold(strings.TrimSpace(k), "q") { | |||
| 94 | + | continue | |||
| 95 | + | } | |||
| 96 | + | q, err := strconv.ParseFloat(strings.TrimSpace(v), 64) | |||
| 97 | + | return err == nil && q > 0 | |||
| 98 | + | } | |||
| 99 | + | return true | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // addVary adds a field to Vary unless it is already there (or Vary is *). | |||
| 103 | + | func addVary(h http.Header, field string) { | |||
| 104 | + | for _, v := range h.Values("Vary") { | |||
| 105 | + | for f := range strings.SplitSeq(v, ",") { | |||
| 106 | + | if t := strings.TrimSpace(f); t == "*" || strings.EqualFold(t, field) { | |||
| 107 | + | return | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | h.Add("Vary", field) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // noTransform tells whether Cache-Control forbids changing the body. | |||
| 115 | + | func noTransform(h http.Header) bool { | |||
| 116 | + | return hasCacheDirective(h, "no-transform") | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // hasCacheDirective tells whether Cache-Control holds the directive. | |||
| 120 | + | func hasCacheDirective(h http.Header, name string) bool { | |||
| 121 | + | for _, v := range h.Values("Cache-Control") { | |||
| 122 | + | for d := range strings.SplitSeq(v, ",") { | |||
| 123 | + | if strings.EqualFold(strings.TrimSpace(d), name) { | |||
| 124 | + | return true | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | return false | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // pageETag is the strong validator of a rendered page: a hash of its | |||
| 132 | + | // identity bytes and of the session state, so that the page of the owner | |||
| 133 | + | // never shares a tag with the page of a visitor. The gzip representation | |||
| 134 | + | // has a tag of its own (RFC 9110 §8.8.3). | |||
| 135 | + | func pageETag(body []byte, signedIn, gzipped bool) string { | |||
| 136 | + | h := sha256.New() | |||
| 137 | + | if signedIn { | |||
| 138 | + | h.Write([]byte{1}) | |||
| 139 | + | } else { | |||
| 140 | + | h.Write([]byte{0}) | |||
| 141 | + | } | |||
| 142 | + | h.Write(body) | |||
| 143 | + | tag := hex.EncodeToString(h.Sum(nil)[:16]) | |||
| 144 | + | if gzipped { | |||
| 145 | + | tag += "-gz" | |||
| 146 | + | } | |||
| 147 | + | return `"` + tag + `"` | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // etagMatches implements the weak comparison of If-None-Match (RFC 9110 | |||
| 151 | + | // §13.1.2): a list of entity tags, each optionally weak, or "*". | |||
| 152 | + | func etagMatches(header, etag string) bool { | |||
| 153 | + | want := strings.TrimPrefix(etag, "W/") | |||
| 154 | + | s := header | |||
| 155 | + | for { | |||
| 156 | + | s = strings.TrimLeft(s, " \t,") | |||
| 157 | + | if s == "" { | |||
| 158 | + | return false | |||
| 159 | + | } | |||
| 160 | + | if s[0] == '*' { | |||
| 161 | + | return true | |||
| 162 | + | } | |||
| 163 | + | s = strings.TrimPrefix(s, "W/") | |||
| 164 | + | if s == "" || s[0] != '"' { | |||
| 165 | + | return false // malformed: nothing after this can be trusted | |||
| 166 | + | } | |||
| 167 | + | end := strings.IndexByte(s[1:], '"') | |||
| 168 | + | if end < 0 { | |||
| 169 | + | return false | |||
| 170 | + | } | |||
| 171 | + | if s[:end+2] == want { | |||
| 172 | + | return true | |||
| 173 | + | } | |||
| 174 | + | s = s[end+2:] | |||
| 175 | + | } | |||
| 176 | + | } | |||
A internal/adapters/web/compress_test.go +316 -0
| @@ -0,0 +1,316 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "compress/gzip" | |
| 8 | + | "context" | |
| 9 | + | "errors" | |
| 10 | + | "io" | |
| 11 | + | "net/http" | |
| 12 | + | "net/http/httptest" | |
| 13 | + | "strings" | |
| 14 | + | "testing" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/a-h/templ" | |
| 18 | + | "github.com/stretchr/testify/assert" | |
| 19 | + | "github.com/stretchr/testify/require" | |
| 20 | + | ||
| 21 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | func TestAcceptsGzip(t *testing.T) { | |
| 25 | + | for _, tc := range []struct { | |
| 26 | + | name string | |
| 27 | + | header []string | |
| 28 | + | want bool | |
| 29 | + | }{ | |
| 30 | + | {"absent", nil, false}, | |
| 31 | + | {"gzip", []string{"gzip"}, true}, | |
| 32 | + | {"list", []string{"deflate, gzip, br"}, true}, | |
| 33 | + | {"case", []string{"GZip"}, true}, | |
| 34 | + | {"x-gzip alias", []string{"x-gzip"}, true}, | |
| 35 | + | {"q=1", []string{"gzip;q=1.0"}, true}, | |
| 36 | + | {"q spaces", []string{"gzip ; q=0.5"}, true}, | |
| 37 | + | {"q=0 refuses", []string{"gzip;q=0"}, false}, | |
| 38 | + | {"q=0.0 refuses", []string{"br, gzip;q=0.0"}, false}, | |
| 39 | + | {"bad q refuses", []string{"gzip;q=abc"}, false}, | |
| 40 | + | {"identity only", []string{"identity"}, false}, | |
| 41 | + | {"br only", []string{"br"}, false}, | |
| 42 | + | {"star", []string{"*"}, true}, | |
| 43 | + | {"star q=0", []string{"*;q=0"}, false}, | |
| 44 | + | {"gzip refused beats star", []string{"gzip;q=0, *"}, false}, | |
| 45 | + | {"gzip accepted beats star q=0", []string{"*;q=0, gzip"}, true}, | |
| 46 | + | {"several lines", []string{"br", "gzip"}, true}, | |
| 47 | + | {"substring is not a coding", []string{"gzipped"}, false}, | |
| 48 | + | } { | |
| 49 | + | t.Run(tc.name, func(t *testing.T) { | |
| 50 | + | h := http.Header{} | |
| 51 | + | for _, v := range tc.header { | |
| 52 | + | h.Add("Accept-Encoding", v) | |
| 53 | + | } | |
| 54 | + | assert.Equal(t, tc.want, acceptsGzip(h)) | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestEtagMatches(t *testing.T) { | |
| 60 | + | const tag = `"abc"` | |
| 61 | + | for _, tc := range []struct { | |
| 62 | + | name, header, etag string | |
| 63 | + | want bool | |
| 64 | + | }{ | |
| 65 | + | {"empty", "", tag, false}, | |
| 66 | + | {"same", `"abc"`, tag, true}, | |
| 67 | + | {"other", `"abd"`, tag, false}, | |
| 68 | + | {"weak client tag", `W/"abc"`, tag, true}, | |
| 69 | + | {"list", `"x", "abc", "y"`, tag, true}, | |
| 70 | + | {"list no space", `"x","abc"`, tag, true}, | |
| 71 | + | {"list without it", `"x", W/"y"`, tag, false}, | |
| 72 | + | {"star", `*`, tag, true}, | |
| 73 | + | {"unquoted", `abc`, tag, false}, | |
| 74 | + | {"unterminated", `"abc`, tag, false}, | |
| 75 | + | {"prefix is not equal", `"ab"`, tag, false}, | |
| 76 | + | {"weak server tag", `"abc"`, `W/"abc"`, true}, | |
| 77 | + | {"comma in a tag", `"a,b", "abc"`, tag, true}, | |
| 78 | + | } { | |
| 79 | + | t.Run(tc.name, func(t *testing.T) { | |
| 80 | + | assert.Equal(t, tc.want, etagMatches(tc.header, tc.etag)) | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestPageETag(t *testing.T) { | |
| 86 | + | body := []byte("<html>x</html>") | |
| 87 | + | a := pageETag(body, false, false) | |
| 88 | + | assert.Equal(t, a, pageETag(body, false, false), "stable") | |
| 89 | + | assert.NotEqual(t, a, pageETag(body, true, false), "owner and visitor do not share a tag") | |
| 90 | + | assert.NotEqual(t, a, pageETag(body, false, true), "gzip is another representation") | |
| 91 | + | assert.NotEqual(t, a, pageETag([]byte("<html>y</html>"), false, false)) | |
| 92 | + | assert.True(t, strings.HasPrefix(a, `"`) && strings.HasSuffix(a, `"`)) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func newBareServer(t *testing.T) *Server { | |
| 96 | + | t.Helper() | |
| 97 | + | srv, err := New(Config{ | |
| 98 | + | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 99 | + | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |
| 100 | + | }) | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | return srv | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func gunzip(t *testing.T, b []byte) string { | |
| 106 | + | t.Helper() | |
| 107 | + | zr, err := gzip.NewReader(bytes.NewReader(b)) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | out, err := io.ReadAll(zr) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | return string(out) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // write: gzip by size and Accept-Encoding, validators, 304. | |
| 115 | + | func TestWritePage(t *testing.T) { | |
| 116 | + | big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>" | |
| 117 | + | small := "<p>tiny</p>" | |
| 118 | + | ||
| 119 | + | for _, tc := range []struct { | |
| 120 | + | name string | |
| 121 | + | method string | |
| 122 | + | ae string | |
| 123 | + | body string | |
| 124 | + | status int | |
| 125 | + | cc string // Cache-Control set by the handler beforehand | |
| 126 | + | wantGzip bool | |
| 127 | + | wantTag bool | |
| 128 | + | }{ | |
| 129 | + | {"big page, gzip", "GET", "gzip", big, 200, "", true, true}, | |
| 130 | + | {"big page, no Accept-Encoding", "GET", "", big, 200, "", false, true}, | |
| 131 | + | {"big page, gzip refused", "GET", "gzip;q=0", big, 200, "", false, true}, | |
| 132 | + | {"big page, br only", "GET", "br", big, 200, "", false, true}, | |
| 133 | + | {"small page not compressed", "GET", "gzip", small, 200, "", false, true}, | |
| 134 | + | {"HEAD keeps headers, no body", "HEAD", "gzip", big, 200, "", true, true}, | |
| 135 | + | {"404 page compressed, no tag", "GET", "gzip", big, 404, "", true, false}, | |
| 136 | + | {"no-store handler: no tag", "GET", "gzip", big, 200, "no-store", true, false}, | |
| 137 | + | {"no-transform: untouched", "GET", "gzip", big, 200, "no-transform", false, true}, | |
| 138 | + | } { | |
| 139 | + | t.Run(tc.name, func(t *testing.T) { | |
| 140 | + | srv := newBareServer(t) | |
| 141 | + | rec := httptest.NewRecorder() | |
| 142 | + | r := httptest.NewRequestWithContext(context.Background(), tc.method, "/", nil) | |
| 143 | + | if tc.ae != "" { | |
| 144 | + | r.Header.Set("Accept-Encoding", tc.ae) | |
| 145 | + | } | |
| 146 | + | if tc.cc != "" { | |
| 147 | + | rec.Header().Set("Cache-Control", tc.cc) | |
| 148 | + | } | |
| 149 | + | srv.write(r.Context(), rec, r, tc.status, templ.Raw(tc.body)) | |
| 150 | + | ||
| 151 | + | res := rec.Result() | |
| 152 | + | t.Cleanup(func() { _ = res.Body.Close() }) | |
| 153 | + | raw, _ := io.ReadAll(res.Body) | |
| 154 | + | assert.Equal(t, tc.status, res.StatusCode) | |
| 155 | + | assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding") | |
| 156 | + | assert.Equal(t, tc.wantTag, res.Header.Get("ETag") != "") | |
| 157 | + | switch { | |
| 158 | + | case tc.method == "HEAD": | |
| 159 | + | assert.Empty(t, raw) | |
| 160 | + | assert.Equal(t, tc.wantGzip, res.Header.Get("Content-Encoding") == "gzip") | |
| 161 | + | case tc.wantGzip: | |
| 162 | + | assert.Equal(t, "gzip", res.Header.Get("Content-Encoding")) | |
| 163 | + | assert.Equal(t, itoa(len(raw)), res.Header.Get("Content-Length")) | |
| 164 | + | assert.Less(t, len(raw), len(tc.body)) | |
| 165 | + | assert.Equal(t, tc.body, gunzip(t, raw)) | |
| 166 | + | default: | |
| 167 | + | assert.Empty(t, res.Header.Get("Content-Encoding")) | |
| 168 | + | assert.Equal(t, tc.body, string(raw)) | |
| 169 | + | } | |
| 170 | + | }) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestWritePageConditional(t *testing.T) { | |
| 175 | + | big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>" | |
| 176 | + | srv := newBareServer(t) | |
| 177 | + | do := func(ae, inm string) *http.Response { | |
| 178 | + | rec := httptest.NewRecorder() | |
| 179 | + | r := httptest.NewRequestWithContext(context.Background(), "GET", "/", nil) | |
| 180 | + | if ae != "" { | |
| 181 | + | r.Header.Set("Accept-Encoding", ae) | |
| 182 | + | } | |
| 183 | + | if inm != "" { | |
| 184 | + | r.Header.Set("If-None-Match", inm) | |
| 185 | + | } | |
| 186 | + | srv.write(r.Context(), rec, r, 200, templ.Raw(big)) | |
| 187 | + | res := rec.Result() | |
| 188 | + | t.Cleanup(func() { _ = res.Body.Close() }) | |
| 189 | + | return res | |
| 190 | + | } | |
| 191 | + | plain, gz := do("", "").Header.Get("ETag"), do("gzip", "").Header.Get("ETag") | |
| 192 | + | require.NotEmpty(t, plain) | |
| 193 | + | require.NotEmpty(t, gz) | |
| 194 | + | assert.NotEqual(t, plain, gz, "identity and gzip are different representations") | |
| 195 | + | ||
| 196 | + | for _, tc := range []struct { | |
| 197 | + | name, ae, inm string | |
| 198 | + | want int | |
| 199 | + | }{ | |
| 200 | + | {"identity match", "", plain, 304}, | |
| 201 | + | {"gzip match", "gzip", gz, 304}, | |
| 202 | + | {"list", "gzip", `"nope", ` + gz, 304}, | |
| 203 | + | {"weak prefix", "", "W/" + plain, 304}, | |
| 204 | + | {"star", "", "*", 304}, | |
| 205 | + | {"identity tag for gzip request", "gzip", plain, 200}, | |
| 206 | + | {"gzip tag for identity request", "", gz, 200}, | |
| 207 | + | {"stale", "", `"stale"`, 200}, | |
| 208 | + | } { | |
| 209 | + | t.Run(tc.name, func(t *testing.T) { | |
| 210 | + | res := do(tc.ae, tc.inm) | |
| 211 | + | assert.Equal(t, tc.want, res.StatusCode) | |
| 212 | + | b, _ := io.ReadAll(res.Body) | |
| 213 | + | if tc.want == 304 { | |
| 214 | + | assert.Empty(t, b) | |
| 215 | + | assert.NotEmpty(t, res.Header.Get("ETag")) | |
| 216 | + | assert.Equal(t, "no-cache", res.Header.Get("Cache-Control")) | |
| 217 | + | assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding") | |
| 218 | + | assert.Empty(t, res.Header.Get("Content-Encoding")) | |
| 219 | + | } else { | |
| 220 | + | assert.NotEmpty(t, b) | |
| 221 | + | } | |
| 222 | + | }) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // The owner's page is private and never revalidated, whatever the client sends. | |
| 227 | + | func TestWritePageOwnerHasNoValidator(t *testing.T) { | |
| 228 | + | srv := newBareServer(t) | |
| 229 | + | ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{}) | |
| 230 | + | rec := httptest.NewRecorder() | |
| 231 | + | r := httptest.NewRequestWithContext(ctx, "GET", "/", nil) | |
| 232 | + | r.Header.Set("If-None-Match", "*") | |
| 233 | + | srv.write(ctx, rec, r, 200, templ.Raw("<p>mine</p>")) | |
| 234 | + | assert.Equal(t, 200, rec.Code) | |
| 235 | + | assert.Equal(t, sessionCache, rec.Header().Get("Cache-Control")) | |
| 236 | + | assert.Empty(t, rec.Header().Get("ETag")) | |
| 237 | + | assert.Contains(t, rec.Header().Values("Vary"), "Cookie") | |
| 238 | + | assert.Contains(t, rec.Header().Values("Vary"), "Accept-Encoding") | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // The assets: gzip once at load, chosen by Accept-Encoding, not for fonts. | |
| 242 | + | func TestAssetsGzip(t *testing.T) { | |
| 243 | + | srv := newBareServer(t) | |
| 244 | + | get := func(url, ae string) *httptest.ResponseRecorder { | |
| 245 | + | rec := httptest.NewRecorder() | |
| 246 | + | r := httptest.NewRequestWithContext(context.Background(), "GET", url, nil) | |
| 247 | + | if ae != "" { | |
| 248 | + | r.Header.Set("Accept-Encoding", ae) | |
| 249 | + | } | |
| 250 | + | srv.ServeHTTP(rec, r) | |
| 251 | + | return rec | |
| 252 | + | } | |
| 253 | + | u := srv.assets.urls | |
| 254 | + | for _, tc := range []struct { | |
| 255 | + | name, url, ae string | |
| 256 | + | wantGzip bool | |
| 257 | + | vary bool | |
| 258 | + | }{ | |
| 259 | + | {"css gzip", u.CSS, "gzip, br", true, true}, | |
| 260 | + | {"css identity", u.CSS, "", false, true}, | |
| 261 | + | {"css refused", u.CSS, "gzip;q=0", false, true}, | |
| 262 | + | {"js gzip", u.HTMX, "gzip", true, true}, | |
| 263 | + | {"rules gzip", u.Rules, "gzip", true, true}, | |
| 264 | + | {"svg gzip", u.Icon, "gzip", true, true}, | |
| 265 | + | {"font never", u.Font, "gzip", false, false}, | |
| 266 | + | } { | |
| 267 | + | t.Run(tc.name, func(t *testing.T) { | |
| 268 | + | rec := get(tc.url, tc.ae) | |
| 269 | + | require.Equal(t, 200, rec.Code) | |
| 270 | + | assert.Equal(t, "public, max-age=31536000, immutable", rec.Header().Get("Cache-Control")) | |
| 271 | + | assert.Equal(t, tc.vary, strings.Contains(strings.Join(rec.Header().Values("Vary"), ","), "Accept-Encoding")) | |
| 272 | + | assert.Equal(t, itoa(rec.Body.Len()), rec.Header().Get("Content-Length")) | |
| 273 | + | plain := srv.assets.byName[strings.TrimPrefix(tc.url, StaticPrefix)].body | |
| 274 | + | if tc.wantGzip { | |
| 275 | + | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | |
| 276 | + | assert.Less(t, rec.Body.Len(), len(plain)) | |
| 277 | + | assert.Equal(t, string(plain), gunzip(t, rec.Body.Bytes())) | |
| 278 | + | } else { | |
| 279 | + | assert.Empty(t, rec.Header().Get("Content-Encoding")) | |
| 280 | + | assert.Equal(t, string(plain), rec.Body.String()) | |
| 281 | + | } | |
| 282 | + | }) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | t.Run("HEAD has the length of the encoded body", func(t *testing.T) { | |
| 286 | + | rec := httptest.NewRecorder() | |
| 287 | + | r := httptest.NewRequestWithContext(context.Background(), "HEAD", u.CSS, nil) | |
| 288 | + | r.Header.Set("Accept-Encoding", "gzip") | |
| 289 | + | srv.ServeHTTP(rec, r) | |
| 290 | + | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | |
| 291 | + | assert.Equal(t, itoa(len(srv.assets.byName[strings.TrimPrefix(u.CSS, StaticPrefix)].gz)), rec.Header().Get("Content-Length")) | |
| 292 | + | assert.Zero(t, rec.Body.Len()) | |
| 293 | + | }) | |
| 294 | + | ||
| 295 | + | t.Run("compressible types", func(t *testing.T) { | |
| 296 | + | for typ, want := range map[string]bool{ | |
| 297 | + | "text/css; charset=utf-8": true, "text/javascript; charset=utf-8": true, | |
| 298 | + | "application/speculationrules+json": true, "image/svg+xml": true, | |
| 299 | + | "font/woff2": false, "image/png": false, | |
| 300 | + | } { | |
| 301 | + | assert.Equal(t, want, compressible(typ), typ) | |
| 302 | + | } | |
| 303 | + | }) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // The event stream is never compressed, even for a client that accepts gzip. | |
| 307 | + | func TestEventsNotCompressed(t *testing.T) { | |
| 308 | + | f := &fakeEvents{release: make(chan struct{})} | |
| 309 | + | ts := eventsServer(t, f, 20*time.Millisecond, 0) | |
| 310 | + | resp, _ := open(t, ts, "topic=home", map[string]string{"Accept-Encoding": "gzip"}) | |
| 311 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 312 | + | assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) | |
| 313 | + | assert.Empty(t, resp.Header.Get("Content-Encoding")) | |
| 314 | + | assert.Empty(t, resp.Header.Get("ETag")) | |
| 315 | + | assert.Contains(t, resp.Header.Get("Cache-Control"), "no-transform") | |
| 316 | + | } | |
| @@ -0,0 +1,316 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "compress/gzip" | |||
| 8 | + | "context" | |||
| 9 | + | "errors" | |||
| 10 | + | "io" | |||
| 11 | + | "net/http" | |||
| 12 | + | "net/http/httptest" | |||
| 13 | + | "strings" | |||
| 14 | + | "testing" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/a-h/templ" | |||
| 18 | + | "github.com/stretchr/testify/assert" | |||
| 19 | + | "github.com/stretchr/testify/require" | |||
| 20 | + | ||||
| 21 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | func TestAcceptsGzip(t *testing.T) { | |||
| 25 | + | for _, tc := range []struct { | |||
| 26 | + | name string | |||
| 27 | + | header []string | |||
| 28 | + | want bool | |||
| 29 | + | }{ | |||
| 30 | + | {"absent", nil, false}, | |||
| 31 | + | {"gzip", []string{"gzip"}, true}, | |||
| 32 | + | {"list", []string{"deflate, gzip, br"}, true}, | |||
| 33 | + | {"case", []string{"GZip"}, true}, | |||
| 34 | + | {"x-gzip alias", []string{"x-gzip"}, true}, | |||
| 35 | + | {"q=1", []string{"gzip;q=1.0"}, true}, | |||
| 36 | + | {"q spaces", []string{"gzip ; q=0.5"}, true}, | |||
| 37 | + | {"q=0 refuses", []string{"gzip;q=0"}, false}, | |||
| 38 | + | {"q=0.0 refuses", []string{"br, gzip;q=0.0"}, false}, | |||
| 39 | + | {"bad q refuses", []string{"gzip;q=abc"}, false}, | |||
| 40 | + | {"identity only", []string{"identity"}, false}, | |||
| 41 | + | {"br only", []string{"br"}, false}, | |||
| 42 | + | {"star", []string{"*"}, true}, | |||
| 43 | + | {"star q=0", []string{"*;q=0"}, false}, | |||
| 44 | + | {"gzip refused beats star", []string{"gzip;q=0, *"}, false}, | |||
| 45 | + | {"gzip accepted beats star q=0", []string{"*;q=0, gzip"}, true}, | |||
| 46 | + | {"several lines", []string{"br", "gzip"}, true}, | |||
| 47 | + | {"substring is not a coding", []string{"gzipped"}, false}, | |||
| 48 | + | } { | |||
| 49 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 50 | + | h := http.Header{} | |||
| 51 | + | for _, v := range tc.header { | |||
| 52 | + | h.Add("Accept-Encoding", v) | |||
| 53 | + | } | |||
| 54 | + | assert.Equal(t, tc.want, acceptsGzip(h)) | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestEtagMatches(t *testing.T) { | |||
| 60 | + | const tag = `"abc"` | |||
| 61 | + | for _, tc := range []struct { | |||
| 62 | + | name, header, etag string | |||
| 63 | + | want bool | |||
| 64 | + | }{ | |||
| 65 | + | {"empty", "", tag, false}, | |||
| 66 | + | {"same", `"abc"`, tag, true}, | |||
| 67 | + | {"other", `"abd"`, tag, false}, | |||
| 68 | + | {"weak client tag", `W/"abc"`, tag, true}, | |||
| 69 | + | {"list", `"x", "abc", "y"`, tag, true}, | |||
| 70 | + | {"list no space", `"x","abc"`, tag, true}, | |||
| 71 | + | {"list without it", `"x", W/"y"`, tag, false}, | |||
| 72 | + | {"star", `*`, tag, true}, | |||
| 73 | + | {"unquoted", `abc`, tag, false}, | |||
| 74 | + | {"unterminated", `"abc`, tag, false}, | |||
| 75 | + | {"prefix is not equal", `"ab"`, tag, false}, | |||
| 76 | + | {"weak server tag", `"abc"`, `W/"abc"`, true}, | |||
| 77 | + | {"comma in a tag", `"a,b", "abc"`, tag, true}, | |||
| 78 | + | } { | |||
| 79 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 80 | + | assert.Equal(t, tc.want, etagMatches(tc.header, tc.etag)) | |||
| 81 | + | }) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestPageETag(t *testing.T) { | |||
| 86 | + | body := []byte("<html>x</html>") | |||
| 87 | + | a := pageETag(body, false, false) | |||
| 88 | + | assert.Equal(t, a, pageETag(body, false, false), "stable") | |||
| 89 | + | assert.NotEqual(t, a, pageETag(body, true, false), "owner and visitor do not share a tag") | |||
| 90 | + | assert.NotEqual(t, a, pageETag(body, false, true), "gzip is another representation") | |||
| 91 | + | assert.NotEqual(t, a, pageETag([]byte("<html>y</html>"), false, false)) | |||
| 92 | + | assert.True(t, strings.HasPrefix(a, `"`) && strings.HasSuffix(a, `"`)) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func newBareServer(t *testing.T) *Server { | |||
| 96 | + | t.Helper() | |||
| 97 | + | srv, err := New(Config{ | |||
| 98 | + | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |||
| 99 | + | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 100 | + | }) | |||
| 101 | + | require.NoError(t, err) | |||
| 102 | + | return srv | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func gunzip(t *testing.T, b []byte) string { | |||
| 106 | + | t.Helper() | |||
| 107 | + | zr, err := gzip.NewReader(bytes.NewReader(b)) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | out, err := io.ReadAll(zr) | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | return string(out) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // write: gzip by size and Accept-Encoding, validators, 304. | |||
| 115 | + | func TestWritePage(t *testing.T) { | |||
| 116 | + | big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>" | |||
| 117 | + | small := "<p>tiny</p>" | |||
| 118 | + | ||||
| 119 | + | for _, tc := range []struct { | |||
| 120 | + | name string | |||
| 121 | + | method string | |||
| 122 | + | ae string | |||
| 123 | + | body string | |||
| 124 | + | status int | |||
| 125 | + | cc string // Cache-Control set by the handler beforehand | |||
| 126 | + | wantGzip bool | |||
| 127 | + | wantTag bool | |||
| 128 | + | }{ | |||
| 129 | + | {"big page, gzip", "GET", "gzip", big, 200, "", true, true}, | |||
| 130 | + | {"big page, no Accept-Encoding", "GET", "", big, 200, "", false, true}, | |||
| 131 | + | {"big page, gzip refused", "GET", "gzip;q=0", big, 200, "", false, true}, | |||
| 132 | + | {"big page, br only", "GET", "br", big, 200, "", false, true}, | |||
| 133 | + | {"small page not compressed", "GET", "gzip", small, 200, "", false, true}, | |||
| 134 | + | {"HEAD keeps headers, no body", "HEAD", "gzip", big, 200, "", true, true}, | |||
| 135 | + | {"404 page compressed, no tag", "GET", "gzip", big, 404, "", true, false}, | |||
| 136 | + | {"no-store handler: no tag", "GET", "gzip", big, 200, "no-store", true, false}, | |||
| 137 | + | {"no-transform: untouched", "GET", "gzip", big, 200, "no-transform", false, true}, | |||
| 138 | + | } { | |||
| 139 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 140 | + | srv := newBareServer(t) | |||
| 141 | + | rec := httptest.NewRecorder() | |||
| 142 | + | r := httptest.NewRequestWithContext(context.Background(), tc.method, "/", nil) | |||
| 143 | + | if tc.ae != "" { | |||
| 144 | + | r.Header.Set("Accept-Encoding", tc.ae) | |||
| 145 | + | } | |||
| 146 | + | if tc.cc != "" { | |||
| 147 | + | rec.Header().Set("Cache-Control", tc.cc) | |||
| 148 | + | } | |||
| 149 | + | srv.write(r.Context(), rec, r, tc.status, templ.Raw(tc.body)) | |||
| 150 | + | ||||
| 151 | + | res := rec.Result() | |||
| 152 | + | t.Cleanup(func() { _ = res.Body.Close() }) | |||
| 153 | + | raw, _ := io.ReadAll(res.Body) | |||
| 154 | + | assert.Equal(t, tc.status, res.StatusCode) | |||
| 155 | + | assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding") | |||
| 156 | + | assert.Equal(t, tc.wantTag, res.Header.Get("ETag") != "") | |||
| 157 | + | switch { | |||
| 158 | + | case tc.method == "HEAD": | |||
| 159 | + | assert.Empty(t, raw) | |||
| 160 | + | assert.Equal(t, tc.wantGzip, res.Header.Get("Content-Encoding") == "gzip") | |||
| 161 | + | case tc.wantGzip: | |||
| 162 | + | assert.Equal(t, "gzip", res.Header.Get("Content-Encoding")) | |||
| 163 | + | assert.Equal(t, itoa(len(raw)), res.Header.Get("Content-Length")) | |||
| 164 | + | assert.Less(t, len(raw), len(tc.body)) | |||
| 165 | + | assert.Equal(t, tc.body, gunzip(t, raw)) | |||
| 166 | + | default: | |||
| 167 | + | assert.Empty(t, res.Header.Get("Content-Encoding")) | |||
| 168 | + | assert.Equal(t, tc.body, string(raw)) | |||
| 169 | + | } | |||
| 170 | + | }) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestWritePageConditional(t *testing.T) { | |||
| 175 | + | big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>" | |||
| 176 | + | srv := newBareServer(t) | |||
| 177 | + | do := func(ae, inm string) *http.Response { | |||
| 178 | + | rec := httptest.NewRecorder() | |||
| 179 | + | r := httptest.NewRequestWithContext(context.Background(), "GET", "/", nil) | |||
| 180 | + | if ae != "" { | |||
| 181 | + | r.Header.Set("Accept-Encoding", ae) | |||
| 182 | + | } | |||
| 183 | + | if inm != "" { | |||
| 184 | + | r.Header.Set("If-None-Match", inm) | |||
| 185 | + | } | |||
| 186 | + | srv.write(r.Context(), rec, r, 200, templ.Raw(big)) | |||
| 187 | + | res := rec.Result() | |||
| 188 | + | t.Cleanup(func() { _ = res.Body.Close() }) | |||
| 189 | + | return res | |||
| 190 | + | } | |||
| 191 | + | plain, gz := do("", "").Header.Get("ETag"), do("gzip", "").Header.Get("ETag") | |||
| 192 | + | require.NotEmpty(t, plain) | |||
| 193 | + | require.NotEmpty(t, gz) | |||
| 194 | + | assert.NotEqual(t, plain, gz, "identity and gzip are different representations") | |||
| 195 | + | ||||
| 196 | + | for _, tc := range []struct { | |||
| 197 | + | name, ae, inm string | |||
| 198 | + | want int | |||
| 199 | + | }{ | |||
| 200 | + | {"identity match", "", plain, 304}, | |||
| 201 | + | {"gzip match", "gzip", gz, 304}, | |||
| 202 | + | {"list", "gzip", `"nope", ` + gz, 304}, | |||
| 203 | + | {"weak prefix", "", "W/" + plain, 304}, | |||
| 204 | + | {"star", "", "*", 304}, | |||
| 205 | + | {"identity tag for gzip request", "gzip", plain, 200}, | |||
| 206 | + | {"gzip tag for identity request", "", gz, 200}, | |||
| 207 | + | {"stale", "", `"stale"`, 200}, | |||
| 208 | + | } { | |||
| 209 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 210 | + | res := do(tc.ae, tc.inm) | |||
| 211 | + | assert.Equal(t, tc.want, res.StatusCode) | |||
| 212 | + | b, _ := io.ReadAll(res.Body) | |||
| 213 | + | if tc.want == 304 { | |||
| 214 | + | assert.Empty(t, b) | |||
| 215 | + | assert.NotEmpty(t, res.Header.Get("ETag")) | |||
| 216 | + | assert.Equal(t, "no-cache", res.Header.Get("Cache-Control")) | |||
| 217 | + | assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding") | |||
| 218 | + | assert.Empty(t, res.Header.Get("Content-Encoding")) | |||
| 219 | + | } else { | |||
| 220 | + | assert.NotEmpty(t, b) | |||
| 221 | + | } | |||
| 222 | + | }) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // The owner's page is private and never revalidated, whatever the client sends. | |||
| 227 | + | func TestWritePageOwnerHasNoValidator(t *testing.T) { | |||
| 228 | + | srv := newBareServer(t) | |||
| 229 | + | ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{}) | |||
| 230 | + | rec := httptest.NewRecorder() | |||
| 231 | + | r := httptest.NewRequestWithContext(ctx, "GET", "/", nil) | |||
| 232 | + | r.Header.Set("If-None-Match", "*") | |||
| 233 | + | srv.write(ctx, rec, r, 200, templ.Raw("<p>mine</p>")) | |||
| 234 | + | assert.Equal(t, 200, rec.Code) | |||
| 235 | + | assert.Equal(t, sessionCache, rec.Header().Get("Cache-Control")) | |||
| 236 | + | assert.Empty(t, rec.Header().Get("ETag")) | |||
| 237 | + | assert.Contains(t, rec.Header().Values("Vary"), "Cookie") | |||
| 238 | + | assert.Contains(t, rec.Header().Values("Vary"), "Accept-Encoding") | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // The assets: gzip once at load, chosen by Accept-Encoding, not for fonts. | |||
| 242 | + | func TestAssetsGzip(t *testing.T) { | |||
| 243 | + | srv := newBareServer(t) | |||
| 244 | + | get := func(url, ae string) *httptest.ResponseRecorder { | |||
| 245 | + | rec := httptest.NewRecorder() | |||
| 246 | + | r := httptest.NewRequestWithContext(context.Background(), "GET", url, nil) | |||
| 247 | + | if ae != "" { | |||
| 248 | + | r.Header.Set("Accept-Encoding", ae) | |||
| 249 | + | } | |||
| 250 | + | srv.ServeHTTP(rec, r) | |||
| 251 | + | return rec | |||
| 252 | + | } | |||
| 253 | + | u := srv.assets.urls | |||
| 254 | + | for _, tc := range []struct { | |||
| 255 | + | name, url, ae string | |||
| 256 | + | wantGzip bool | |||
| 257 | + | vary bool | |||
| 258 | + | }{ | |||
| 259 | + | {"css gzip", u.CSS, "gzip, br", true, true}, | |||
| 260 | + | {"css identity", u.CSS, "", false, true}, | |||
| 261 | + | {"css refused", u.CSS, "gzip;q=0", false, true}, | |||
| 262 | + | {"js gzip", u.HTMX, "gzip", true, true}, | |||
| 263 | + | {"rules gzip", u.Rules, "gzip", true, true}, | |||
| 264 | + | {"svg gzip", u.Icon, "gzip", true, true}, | |||
| 265 | + | {"font never", u.Font, "gzip", false, false}, | |||
| 266 | + | } { | |||
| 267 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 268 | + | rec := get(tc.url, tc.ae) | |||
| 269 | + | require.Equal(t, 200, rec.Code) | |||
| 270 | + | assert.Equal(t, "public, max-age=31536000, immutable", rec.Header().Get("Cache-Control")) | |||
| 271 | + | assert.Equal(t, tc.vary, strings.Contains(strings.Join(rec.Header().Values("Vary"), ","), "Accept-Encoding")) | |||
| 272 | + | assert.Equal(t, itoa(rec.Body.Len()), rec.Header().Get("Content-Length")) | |||
| 273 | + | plain := srv.assets.byName[strings.TrimPrefix(tc.url, StaticPrefix)].body | |||
| 274 | + | if tc.wantGzip { | |||
| 275 | + | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | |||
| 276 | + | assert.Less(t, rec.Body.Len(), len(plain)) | |||
| 277 | + | assert.Equal(t, string(plain), gunzip(t, rec.Body.Bytes())) | |||
| 278 | + | } else { | |||
| 279 | + | assert.Empty(t, rec.Header().Get("Content-Encoding")) | |||
| 280 | + | assert.Equal(t, string(plain), rec.Body.String()) | |||
| 281 | + | } | |||
| 282 | + | }) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | t.Run("HEAD has the length of the encoded body", func(t *testing.T) { | |||
| 286 | + | rec := httptest.NewRecorder() | |||
| 287 | + | r := httptest.NewRequestWithContext(context.Background(), "HEAD", u.CSS, nil) | |||
| 288 | + | r.Header.Set("Accept-Encoding", "gzip") | |||
| 289 | + | srv.ServeHTTP(rec, r) | |||
| 290 | + | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | |||
| 291 | + | assert.Equal(t, itoa(len(srv.assets.byName[strings.TrimPrefix(u.CSS, StaticPrefix)].gz)), rec.Header().Get("Content-Length")) | |||
| 292 | + | assert.Zero(t, rec.Body.Len()) | |||
| 293 | + | }) | |||
| 294 | + | ||||
| 295 | + | t.Run("compressible types", func(t *testing.T) { | |||
| 296 | + | for typ, want := range map[string]bool{ | |||
| 297 | + | "text/css; charset=utf-8": true, "text/javascript; charset=utf-8": true, | |||
| 298 | + | "application/speculationrules+json": true, "image/svg+xml": true, | |||
| 299 | + | "font/woff2": false, "image/png": false, | |||
| 300 | + | } { | |||
| 301 | + | assert.Equal(t, want, compressible(typ), typ) | |||
| 302 | + | } | |||
| 303 | + | }) | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // The event stream is never compressed, even for a client that accepts gzip. | |||
| 307 | + | func TestEventsNotCompressed(t *testing.T) { | |||
| 308 | + | f := &fakeEvents{release: make(chan struct{})} | |||
| 309 | + | ts := eventsServer(t, f, 20*time.Millisecond, 0) | |||
| 310 | + | resp, _ := open(t, ts, "topic=home", map[string]string{"Accept-Encoding": "gzip"}) | |||
| 311 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 312 | + | assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) | |||
| 313 | + | assert.Empty(t, resp.Header.Get("Content-Encoding")) | |||
| 314 | + | assert.Empty(t, resp.Header.Get("ETag")) | |||
| 315 | + | assert.Contains(t, resp.Header.Get("Cache-Control"), "no-transform") | |||
| 316 | + | } | |||
M internal/adapters/web/session_test.go +1 -1
| @@ -226,7 +226,7 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | |||
| 226 | 226 | rec := httptest.NewRecorder() | |
| 227 | 227 | srv.ServeHTTP(rec, req) | |
| 228 | 228 | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target) | |
| 229 | - | assert.Equal(t, []string{"Cookie"}, rec.Header().Values("Vary"), target) | |
| 229 | + | assert.Equal(t, []string{"Cookie", "Accept-Encoding"}, rec.Header().Values("Vary"), target) | |
| 230 | 230 | assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target) | |
| 231 | 231 | } | |
| 232 | 232 | ||
| @@ -226,7 +226,7 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | |||||
| 226 | rec := httptest.NewRecorder() | 226 | rec := httptest.NewRecorder() | ||
| 227 | srv.ServeHTTP(rec, req) | 227 | srv.ServeHTTP(rec, req) | ||
| 228 | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target) | 228 | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target) | ||
| 229 | - | assert.Equal(t, []string{"Cookie"}, rec.Header().Values("Vary"), target) | 229 | + | assert.Equal(t, []string{"Cookie", "Accept-Encoding"}, rec.Header().Values("Vary"), target) |
| 230 | assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target) | 230 | assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target) | ||
| 231 | } | 231 | } | ||
| 232 | 232 | ||||
M internal/adapters/web/web.go +27 -1
| @@ -205,9 +205,35 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque | |||
| 205 | 205 | if h.Get("Cache-Control") == "" { | |
| 206 | 206 | cacheHeaders(ctx, h, "no-cache", sessionCache) | |
| 207 | 207 | } | |
| 208 | + | body := buf.Bytes() | |
| 209 | + | // Whether the body is sent gzipped follows from its size and the request | |
| 210 | + | // alone, so that the tag is known before the compression is spent. | |
| 211 | + | gz := len(body) >= minCompress && acceptsGzip(r.Header) && | |
| 212 | + | !noTransform(h) && h.Get("Content-Encoding") == "" | |
| 213 | + | addVary(h, "Accept-Encoding") | |
| 214 | + | readOnly := r.Method == http.MethodGet || r.Method == http.MethodHead | |
| 215 | + | // A page that may be stored gets a validator: the browser then | |
| 216 | + | // revalidates with If-None-Match and a page that did not change costs a | |
| 217 | + | // 304. A no-store page (the owner's) is never revalidated. | |
| 218 | + | if readOnly && status == http.StatusOK && h.Get("ETag") == "" && !hasCacheDirective(h, noStore) { | |
| 219 | + | tag := pageETag(body, SignedIn(ctx), gz) | |
| 220 | + | h.Set("ETag", tag) | |
| 221 | + | if etagMatches(strings.Join(r.Header.Values("If-None-Match"), ","), tag) { | |
| 222 | + | h.Del("Content-Type") | |
| 223 | + | w.WriteHeader(http.StatusNotModified) | |
| 224 | + | return | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | if gz { | |
| 228 | + | if z, err := gzipPage(body); err == nil && len(z) < len(body) { | |
| 229 | + | body = z | |
| 230 | + | h.Set("Content-Encoding", "gzip") | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | h.Set("Content-Length", itoa(len(body))) | |
| 208 | 234 | w.WriteHeader(status) | |
| 209 | 235 | if r.Method != http.MethodHead { | |
| 210 | - | _, _ = w.Write(buf.Bytes()) | |
| 236 | + | _, _ = w.Write(body) | |
| 211 | 237 | } | |
| 212 | 238 | } | |
| 213 | 239 | ||
| @@ -205,9 +205,35 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque | |||||
| 205 | if h.Get("Cache-Control") == "" { | 205 | if h.Get("Cache-Control") == "" { | ||
| 206 | cacheHeaders(ctx, h, "no-cache", sessionCache) | 206 | cacheHeaders(ctx, h, "no-cache", sessionCache) | ||
| 207 | } | 207 | } | ||
| 208 | + | body := buf.Bytes() | |||
| 209 | + | // Whether the body is sent gzipped follows from its size and the request | |||
| 210 | + | // alone, so that the tag is known before the compression is spent. | |||
| 211 | + | gz := len(body) >= minCompress && acceptsGzip(r.Header) && | |||
| 212 | + | !noTransform(h) && h.Get("Content-Encoding") == "" | |||
| 213 | + | addVary(h, "Accept-Encoding") | |||
| 214 | + | readOnly := r.Method == http.MethodGet || r.Method == http.MethodHead | |||
| 215 | + | // A page that may be stored gets a validator: the browser then | |||
| 216 | + | // revalidates with If-None-Match and a page that did not change costs a | |||
| 217 | + | // 304. A no-store page (the owner's) is never revalidated. | |||
| 218 | + | if readOnly && status == http.StatusOK && h.Get("ETag") == "" && !hasCacheDirective(h, noStore) { | |||
| 219 | + | tag := pageETag(body, SignedIn(ctx), gz) | |||
| 220 | + | h.Set("ETag", tag) | |||
| 221 | + | if etagMatches(strings.Join(r.Header.Values("If-None-Match"), ","), tag) { | |||
| 222 | + | h.Del("Content-Type") | |||
| 223 | + | w.WriteHeader(http.StatusNotModified) | |||
| 224 | + | return | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | if gz { | |||
| 228 | + | if z, err := gzipPage(body); err == nil && len(z) < len(body) { | |||
| 229 | + | body = z | |||
| 230 | + | h.Set("Content-Encoding", "gzip") | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | h.Set("Content-Length", itoa(len(body))) | |||
| 208 | w.WriteHeader(status) | 234 | w.WriteHeader(status) | ||
| 209 | if r.Method != http.MethodHead { | 235 | if r.Method != http.MethodHead { | ||
| 210 | - | _, _ = w.Write(buf.Bytes()) | 236 | + | _, _ = w.Write(body) |
| 211 | } | 237 | } | ||
| 212 | } | 238 | } | ||
| 213 | 239 | ||||