1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "bytes"
7 "compress/gzip"
8 "crypto/sha256"
9 "encoding/hex"
10 "io"
11 "net/http"
12 "strconv"
13 "strings"
14 "sync"
15)
16
17// minCompress is the size under which a page is sent as it is: the gzip
18// header and the CPU cost are not worth it for a few hundred bytes.
19const minCompress = 1024
20
21// gzipLevel is the level of the pages, compressed on each request: a good
22// ratio at a cost small enough for a render that already takes longer. The
23// assets, compressed once at start, use the best level.
24const gzipLevel = 5
25
26// gzipWriters recycles the writers of the pages: a gzip.Writer holds about
27// 800 KiB of state.
28var gzipWriters = sync.Pool{New: func() any {
29 zw, _ := gzip.NewWriterLevel(nil, gzipLevel) // the level is valid
30 return zw
31}}
32
33// gzipPage compresses b with a pooled writer.
34func gzipPage(b []byte) ([]byte, error) {
35 var out bytes.Buffer
36 zw := gzipWriters.Get().(*gzip.Writer)
37 defer func() {
38 // Drop the reference to out, so a pooled writer does not keep the
39 // last page alive.
40 zw.Reset(io.Discard)
41 gzipWriters.Put(zw)
42 }()
43 zw.Reset(&out)
44 if _, err := zw.Write(b); err != nil {
45 return nil, err
46 }
47 if err := zw.Close(); err != nil {
48 return nil, err
49 }
50 return out.Bytes(), nil
51}
52
53// gzipBest compresses b once, at the best level (for the assets).
54func gzipBest(b []byte) ([]byte, error) {
55 var out bytes.Buffer
56 zw, err := gzip.NewWriterLevel(&out, gzip.BestCompression)
57 if err != nil {
58 return nil, err
59 }
60 if _, err := zw.Write(b); err != nil {
61 return nil, err
62 }
63 if err := zw.Close(); err != nil {
64 return nil, err
65 }
66 return out.Bytes(), nil
67}
68
69// acceptsGzip reports whether the Accept-Encoding of a request allows gzip
70// (RFC 9110 §12.5.3): gzip, or its alias x-gzip, with a quality above 0, or
71// "*" when gzip is not named. A quality of 0 refuses the coding; a malformed
72// quality is a refusal as well.
73func acceptsGzip(h http.Header) bool {
74 star, starSet := false, false
75 for _, line := range h.Values("Accept-Encoding") {
76 for part := range strings.SplitSeq(line, ",") {
77 coding, params, _ := strings.Cut(part, ";")
78 coding = strings.ToLower(strings.TrimSpace(coding))
79 if coding != "gzip" && coding != "x-gzip" && coding != "*" {
80 continue
81 }
82 ok := qualityPositive(params)
83 switch coding {
84 case "*":
85 star, starSet = ok, true
86 default:
87 return ok
88 }
89 }
90 }
91 return starSet && star
92}
93
94// qualityPositive reads the q parameter of a coding (1 when absent) and
95// reports whether it is above 0.
96func qualityPositive(params string) bool {
97 for p := range strings.SplitSeq(params, ";") {
98 k, v, found := strings.Cut(p, "=")
99 if !found || !strings.EqualFold(strings.TrimSpace(k), "q") {
100 continue
101 }
102 q, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
103 return err == nil && q > 0
104 }
105 return true
106}
107
108// addVary adds a field to Vary unless it is already there (or Vary is *).
109func addVary(h http.Header, field string) {
110 for _, v := range h.Values("Vary") {
111 for f := range strings.SplitSeq(v, ",") {
112 if t := strings.TrimSpace(f); t == "*" || strings.EqualFold(t, field) {
113 return
114 }
115 }
116 }
117 h.Add("Vary", field)
118}
119
120// noTransform tells whether Cache-Control forbids changing the body.
121func noTransform(h http.Header) bool {
122 return hasCacheDirective(h, "no-transform")
123}
124
125// hasCacheDirective tells whether Cache-Control holds the directive.
126func hasCacheDirective(h http.Header, name string) bool {
127 for _, v := range h.Values("Cache-Control") {
128 for d := range strings.SplitSeq(v, ",") {
129 if strings.EqualFold(strings.TrimSpace(d), name) {
130 return true
131 }
132 }
133 }
134 return false
135}
136
137// pageETag is the strong validator of a rendered page: a hash of its
138// identity bytes and of the session state, so that the page of the owner
139// never shares a tag with the page of a visitor. The gzip representation
140// has a tag of its own (RFC 9110 §8.8.3).
141func pageETag(body []byte, signedIn, gzipped bool) string {
142 h := sha256.New()
143 if signedIn {
144 h.Write([]byte{1})
145 } else {
146 h.Write([]byte{0})
147 }
148 h.Write(body)
149 tag := hex.EncodeToString(h.Sum(nil)[:16])
150 if gzipped {
151 tag += "-gz"
152 }
153 return `"` + tag + `"`
154}
155
156// etagMatches implements the weak comparison of If-None-Match (RFC 9110
157// §13.1.2): a list of entity tags, each optionally weak, or "*".
158func etagMatches(header, etag string) bool {
159 want := strings.TrimPrefix(etag, "W/")
160 s := header
161 for {
162 s = strings.TrimLeft(s, " \t,")
163 if s == "" {
164 return false
165 }
166 if s[0] == '*' {
167 return true
168 }
169 s = strings.TrimPrefix(s, "W/")
170 if s == "" || s[0] != '"' {
171 return false // malformed: nothing after this can be trusted
172 }
173 end := strings.IndexByte(s[1:], '"')
174 if end < 0 {
175 return false
176 }
177 if s[:end+2] == want {
178 return true
179 }
180 s = s[end+2:]
181 }
182}