fork of whitequark.org/git-pages with mods for tangled

Serve compressed content directly if client indicates support.

miyuko d6a7a72e aa965c5a

Changed files
+130 -12
src
+89
src/http.go
··· 1 + package git_pages 2 + 3 + import ( 4 + "cmp" 5 + "regexp" 6 + "slices" 7 + "strconv" 8 + "strings" 9 + ) 10 + 11 + var httpAcceptEncodingRegexp = regexp.MustCompile(`` + 12 + // token optionally prefixed by whitespace 13 + `^[ \t]*([a-zA-Z0-9$!#$%&'*+.^_\x60|~-]+)` + 14 + // quality value prefixed by a semicolon optionally surrounded by whitespace 15 + `(?:[ \t]*;[ \t]*q=(0(?:\.[0-9]{1,3})?|1(?:\.0{1,3})?))?` + 16 + // optional whitespace followed by comma or end of line 17 + `[ \t]*(?:,|$)`, 18 + ) 19 + 20 + type httpEncoding struct { 21 + code string 22 + qval float64 23 + } 24 + 25 + type httpEncodings struct { 26 + encodings []httpEncoding 27 + } 28 + 29 + func parseHTTPEncodings(headerValue string) (result httpEncodings) { 30 + for headerValue != "" { 31 + matches := httpAcceptEncodingRegexp.FindStringSubmatch(headerValue) 32 + if matches == nil { 33 + return httpEncodings{} 34 + } 35 + enc := httpEncoding{strings.ToLower(matches[1]), 1.0} 36 + if matches[2] != "" { 37 + enc.qval, _ = strconv.ParseFloat(matches[2], 64) 38 + } 39 + result.encodings = append(result.encodings, enc) 40 + headerValue = headerValue[len(matches[0]):] 41 + } 42 + if len(result.encodings) == 0 { 43 + // RFC 9110 says (https://httpwg.org/specs/rfc9110.html#field.accept-encoding): 44 + // "If no Accept-Encoding header field is in the request, any content 45 + // coding is considered acceptable by the user agent." 46 + // In practice, no client expects to receive a compressed response 47 + // without having sent Accept-Encoding in the request. 48 + } 49 + return 50 + } 51 + 52 + // Negotiate returns the most preferred encoding that is acceptable by the 53 + // client, or an empty string if no encodings are acceptable. 54 + func (e *httpEncodings) Negotiate(codes ...string) string { 55 + prefs := make(map[string]float64, len(codes)) 56 + for _, code := range codes { 57 + prefs[code] = 0 58 + } 59 + implicitIdentity := true 60 + for _, enc := range e.encodings { 61 + if enc.code == "*" { 62 + for code := range prefs { 63 + prefs[code] = enc.qval 64 + } 65 + implicitIdentity = false 66 + } else if _, ok := prefs[enc.code]; ok { 67 + prefs[enc.code] = enc.qval 68 + } 69 + if enc.code == "*" || enc.code == "identity" { 70 + implicitIdentity = false 71 + } 72 + } 73 + if _, ok := prefs["identity"]; ok && implicitIdentity { 74 + prefs["identity"] = -1 // sort last 75 + } 76 + encs := make([]httpEncoding, len(codes)) 77 + for idx, code := range codes { 78 + encs[idx] = httpEncoding{code, prefs[code]} 79 + } 80 + slices.SortStableFunc(encs, func(a, b httpEncoding) int { 81 + return -cmp.Compare(a.qval, b.qval) 82 + }) 83 + for _, enc := range encs { 84 + if enc.qval != 0 { 85 + return enc.code 86 + } 87 + } 88 + return "" 89 + }
+41 -12
src/pages.go
··· 13 13 "net/url" 14 14 "os" 15 15 "path" 16 + "strconv" 16 17 "strings" 17 18 "time" 18 19 ··· 229 230 defer closer.Close() 230 231 } 231 232 233 + acceptedEncodings := parseHTTPEncodings(r.Header.Get("Accept-Encoding")) 234 + negotiatedEncoding := true 235 + 232 236 switch entry.GetTransform() { 233 237 case Transform_None: 234 - // nothing to do 238 + if acceptedEncodings.Negotiate("identity") != "identity" { 239 + negotiatedEncoding = false 240 + } 235 241 case Transform_Zstandard: 236 - // Ideally, we would serve zstd-compressed data to a client that indicates support with 237 - // an `Accept-Encoding: zstd` header. Unfortunately we can't because we rely on MIME 238 - // type detection done in `http.ServeContent`. 239 - compressedData, _ := io.ReadAll(reader) 240 - decompressedData, err := zstdDecoder.DecodeAll(compressedData, []byte{}) 241 - if err != nil { 242 - w.WriteHeader(http.StatusInternalServerError) 243 - fmt.Fprintf(w, "internal server error: %s\n", err) 244 - return err 242 + supported := []string{"zstd", "identity"} 243 + if entry.ContentType == nil { 244 + // If Content-Type is unset, `http.ServeContent` will try to sniff 245 + // the file contents. That won't work if it's compressed. 246 + supported = []string{"identity"} 245 247 } 246 - reader = bytes.NewReader(decompressedData) 248 + switch acceptedEncodings.Negotiate(supported...) { 249 + case "zstd": 250 + // Set Content-Length ourselves since `http.ServeContent` only sets 251 + // it if Content-Encoding is unset or if it's a range request. 252 + w.Header().Set("Content-Length", strconv.FormatInt(*entry.Size, 10)) 253 + w.Header().Set("Content-Encoding", "zstd") 254 + case "identity": 255 + compressedData, _ := io.ReadAll(reader) 256 + decompressedData, err := zstdDecoder.DecodeAll(compressedData, []byte{}) 257 + if err != nil { 258 + w.WriteHeader(http.StatusInternalServerError) 259 + fmt.Fprintf(w, "internal server error: %s\n", err) 260 + return err 261 + } 262 + reader = bytes.NewReader(decompressedData) 263 + default: 264 + negotiatedEncoding = false 265 + } 266 + } 267 + if !negotiatedEncoding { 268 + w.WriteHeader(http.StatusNotAcceptable) 269 + return fmt.Errorf("no supported content encodings (accept-encoding: %q)", 270 + r.Header.Get("Accept-Encoding")) 247 271 } 248 272 249 273 // decide on the HTTP status ··· 253 277 io.Copy(w, reader) 254 278 } 255 279 } else { 280 + if entry.ContentType != nil { 281 + // don't let http.ServeContent mime-sniff compressed data 282 + w.Header().Set("Content-Type", *entry.ContentType) 283 + } 284 + 256 285 // allow the use of multi-threading in WebAssembly 257 286 w.Header().Set("Cross-Origin-Embedder-Policy", "credentialless") 258 287 w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") ··· 265 294 w.Header().Set("Cache-Control", "max-age=60, stale-while-revalidate=3600") 266 295 // see https://web.dev/articles/stale-while-revalidate for details 267 296 268 - // http.ServeContent handles content type and caching 297 + // http.ServeContent handles conditional requests and range requests 269 298 http.ServeContent(w, r, entryPath, mtime, reader) 270 299 } 271 300 return nil