vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
a76b2f38
Keenan Nemetz
12d ago
|
1 | package compress |
|
306f36fc
Keenan Nemetz
12d ago
|
2 | |
|
a76b2f38
Keenan Nemetz
12d ago
|
3 | // Package compress gzips text responses. |
| 4 | // | |
| 5 | // It is shared by the two HTTP surfaces that serve bytes: the forge UI (wrapped | |
| 6 | // into its middleware, because it needs the writer inside its own panic and | |
| 7 | // page-view bookkeeping) and hosted sites (wrapped once around the site | |
| 8 | // dispatcher). One implementation so both answer the same negotiation and the | |
| 9 | // same header rules. | |
|
306f36fc
Keenan Nemetz
12d ago
|
10 | // |
| 11 | // The web surface sends HTML, CSS, JavaScript and source blobs, all of which | |
| 12 | // compress to a fraction of their size: the vendored Prism bundle alone is | |
| 13 | // 545 KB and lands near 200 KB gzipped, and a rendered page about a fifth of its | |
| 14 | // size. There is no brotli here on purpose — it would add a dependency for a few | |
| 15 | // points of ratio, and Go's gzip needs none. | |
| 16 | // | |
| 17 | // The rules are narrow, because the same handler chain also streams git | |
| 18 | // responses, release archives and LFS objects: | |
| 19 | // | |
| 20 | // - only when the client sent `Accept-Encoding: gzip` with q > 0; | |
| 21 | // - only text-like content types, never a body that is already encoded; | |
| 22 | // - never a Range request (the compressed body has no meaningful ranges); | |
| 23 | // - never a body the handler has already sized below compressMinBytes; | |
| 24 | // - never a 1xx, 204, 206, 304 or redirect (nothing to gain, and 304 must | |
| 25 | // stay bodiless). | |
| 26 | // | |
| 27 | // The writer is transparent otherwise: it passes Header through, keeps Flush | |
| 28 | // working for the streaming handlers, and exposes Unwrap so a handler can reach | |
| 29 | // the underlying ResponseWriter. | |
| 30 | ||
| 31 | import ( | |
| 32 | "compress/gzip" | |
| 33 | "net/http" | |
| 34 | "strconv" | |
| 35 | "strings" | |
| 36 | ) | |
| 37 | ||
| 38 | // compressMinBytes is the smallest declared body worth a gzip header and | |
| 39 | // trailer. Below it the compressed form is usually larger than the source. | |
| 40 | const compressMinBytes = 256 | |
| 41 | ||
|
a76b2f38
Keenan Nemetz
12d ago
|
42 | // Writer decides per response whether to gzip, and lazily starts the stream on |
| 43 | // the first write so a header-only response never opens one. | |
| 44 | type Writer struct { | |
|
306f36fc
Keenan Nemetz
12d ago
|
45 | rw http.ResponseWriter |
| 46 | r *http.Request | |
| 47 | gz *gzip.Writer | |
| 48 | status int | |
| 49 | wroteHeader bool | |
| 50 | gzipping bool | |
| 51 | } | |
| 52 | ||
|
a76b2f38
Keenan Nemetz
12d ago
|
53 | // New wraps rw for one request. The caller must Close it when the handler has |
| 54 | // returned; Handler does that for a whole handler tree. | |
| 55 | func New(rw http.ResponseWriter, r *http.Request) *Writer { | |
| 56 | return &Writer{rw: rw, r: r} | |
| 57 | } | |
| 58 | ||
| 59 | // Handler wraps next so every response it writes is compressed by the rules in | |
| 60 | // New. It is the whole of the site surface's compression support. | |
| 61 | func Handler(next http.Handler) http.Handler { | |
| 62 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { | |
| 63 | w := New(rw, r) | |
| 64 | next.ServeHTTP(w, r) | |
| 65 | w.Close() | |
| 66 | }) | |
|
306f36fc
Keenan Nemetz
12d ago
|
67 | } |
| 68 | ||
|
a76b2f38
Keenan Nemetz
12d ago
|
69 | func (c *Writer) Header() http.Header { return c.rw.Header() } |
|
306f36fc
Keenan Nemetz
12d ago
|
70 | |
|
a76b2f38
Keenan Nemetz
12d ago
|
71 | func (c *Writer) WriteHeader(status int) { |
|
306f36fc
Keenan Nemetz
12d ago
|
72 | if c.wroteHeader { |
| 73 | return | |
| 74 | } | |
| 75 | c.wroteHeader = true | |
| 76 | c.status = status | |
| 77 | h := c.rw.Header() | |
| 78 | if c.acceptsGzip() && compressibleStatus(status) && compressibleHeader(h, c.r) { | |
| 79 | c.gzipping = true | |
| 80 | h.Set("Content-Encoding", "gzip") | |
| 81 | h.Del("Content-Length") | |
| 82 | h.Add("Vary", "Accept-Encoding") | |
| 83 | } | |
| 84 | c.rw.WriteHeader(status) | |
| 85 | } | |
| 86 | ||
|
a76b2f38
Keenan Nemetz
12d ago
|
87 | func (c *Writer) Write(b []byte) (int, error) { |
|
306f36fc
Keenan Nemetz
12d ago
|
88 | if !c.wroteHeader { |
| 89 | c.WriteHeader(http.StatusOK) | |
| 90 | } | |
| 91 | if !c.gzipping { | |
| 92 | return c.rw.Write(b) | |
| 93 | } | |
| 94 | if c.gz == nil { | |
| 95 | c.gz = gzip.NewWriter(c.rw) | |
| 96 | } | |
| 97 | return c.gz.Write(b) | |
| 98 | } | |
| 99 | ||
| 100 | // Flush keeps streaming handlers working: the header is settled, the gzip | |
| 101 | // buffer is emptied, and the underlying writer is flushed if it can be. | |
|
a76b2f38
Keenan Nemetz
12d ago
|
102 | func (c *Writer) Flush() { |
|
306f36fc
Keenan Nemetz
12d ago
|
103 | if !c.wroteHeader { |
| 104 | c.WriteHeader(http.StatusOK) | |
| 105 | } | |
| 106 | if c.gz != nil { | |
| 107 | _ = c.gz.Flush() | |
| 108 | } | |
| 109 | if f, ok := c.rw.(http.Flusher); ok { | |
| 110 | f.Flush() | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | // Close finishes the gzip stream. It is called once by the middleware after the | |
| 115 | // handler returns, never by a handler, but it is idempotent enough for the | |
| 116 | // panic path that may have written nothing at all. | |
|
a76b2f38
Keenan Nemetz
12d ago
|
117 | func (c *Writer) Close() { |
|
306f36fc
Keenan Nemetz
12d ago
|
118 | if c.gz != nil { |
| 119 | _ = c.gz.Close() | |
| 120 | c.gz = nil | |
| 121 | } | |
| 122 | } | |
| 123 | ||
|
a76b2f38
Keenan Nemetz
12d ago
|
124 | func (c *Writer) Unwrap() http.ResponseWriter { return c.rw } |
|
306f36fc
Keenan Nemetz
12d ago
|
125 | |
| 126 | // acceptsGzip reports whether the client asked for gzip and would accept it: | |
| 127 | // `gzip` with a q-value of zero is a refusal, not a request. | |
|
a76b2f38
Keenan Nemetz
12d ago
|
128 | func (c *Writer) acceptsGzip() bool { |
|
306f36fc
Keenan Nemetz
12d ago
|
129 | if c.r.Method == http.MethodHead { |
| 130 | return false | |
| 131 | } | |
| 132 | for _, part := range strings.Split(c.r.Header.Get("Accept-Encoding"), ",") { | |
| 133 | name, params, _ := strings.Cut(strings.TrimSpace(part), ";") | |
| 134 | if !strings.EqualFold(strings.TrimSpace(name), "gzip") { | |
| 135 | continue | |
| 136 | } | |
| 137 | q := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(params), "q=")) | |
| 138 | if q == "" { | |
| 139 | return true | |
| 140 | } | |
| 141 | if v, err := strconv.ParseFloat(q, 64); err == nil { | |
| 142 | return v > 0 | |
| 143 | } | |
| 144 | return true | |
| 145 | } | |
| 146 | return false | |
| 147 | } | |
| 148 | ||
| 149 | // compressibleStatus covers the bodies worth compressing: a rendered page, a | |
| 150 | // fragment, and the styled error page. Redirects are a sentence, a 304 must stay | |
| 151 | // bodiless, and a 206 is already a byte range. | |
| 152 | func compressibleStatus(status int) bool { | |
| 153 | switch status { | |
| 154 | case http.StatusNoContent, http.StatusNotModified, http.StatusPartialContent: | |
| 155 | return false | |
| 156 | } | |
| 157 | return (status >= 200 && status < 300) || status >= 400 | |
| 158 | } | |
| 159 | ||
| 160 | // compressibleHeader applies the remaining rules: a body that is already | |
| 161 | // encoded stays as it is, a Range request is answered with bytes, and a body | |
| 162 | // the handler already sized as tiny is not worth compressing. | |
| 163 | func compressibleHeader(h http.Header, r *http.Request) bool { | |
| 164 | if h.Get("Content-Encoding") != "" || h.Get("Content-Range") != "" { | |
| 165 | return false | |
| 166 | } | |
| 167 | // A handler that promised exact bytes (a raw download with its Content-Length) | |
| 168 | // opts out with the standard directive rather than a private header. | |
| 169 | if strings.Contains(strings.ToLower(h.Get("Cache-Control")), "no-transform") { | |
| 170 | return false | |
| 171 | } | |
| 172 | if r.Header.Get("Range") != "" { | |
| 173 | return false | |
| 174 | } | |
| 175 | if n, err := strconv.Atoi(h.Get("Content-Length")); err == nil && n < compressMinBytes { | |
| 176 | return false | |
| 177 | } | |
| 178 | return compressibleType(h.Get("Content-Type")) | |
| 179 | } | |
| 180 | ||
| 181 | // compressibleType is the allow-list of types worth gzipping. Anything else — | |
| 182 | // images, archives, packs, LFS objects, git protocol output — is already | |
| 183 | // compressed or is a byte range where compression cannot help. | |
| 184 | func compressibleType(contentType string) bool { | |
| 185 | ct := strings.ToLower(strings.TrimSpace(contentType)) | |
| 186 | if i := strings.IndexByte(ct, ';'); i >= 0 { | |
| 187 | ct = strings.TrimSpace(ct[:i]) | |
| 188 | } | |
| 189 | switch ct { | |
| 190 | case "text/event-stream": | |
| 191 | return false | |
| 192 | case "image/svg+xml", | |
| 193 | "application/json", | |
| 194 | "application/javascript", | |
| 195 | "application/x-javascript", | |
| 196 | "application/xml", | |
| 197 | "application/xhtml+xml", | |
| 198 | "application/manifest+json", | |
| 199 | "application/rss+xml", | |
| 200 | "application/atom+xml": | |
| 201 | return true | |
| 202 | } | |
| 203 | return strings.HasPrefix(ct, "text/") || | |
| 204 | strings.HasSuffix(ct, "+json") || | |
| 205 | strings.HasSuffix(ct, "+xml") | |
| 206 | } |