diff --git a/internal/http/response/builder.go b/internal/http/response/builder.go index a78389d08da..d3175e64af8 100644 --- a/internal/http/response/builder.go +++ b/internal/http/response/builder.go @@ -136,9 +136,10 @@ func (b *Builder) writeHeaders() { func (b *Builder) compress(data []byte) { if b.enableCompression && len(data) > compressionThreshold { b.headers.Set("Vary", "Accept-Encoding") - acceptEncoding := b.r.Header.Get("Accept-Encoding") - switch { - case strings.Contains(acceptEncoding, "br"): + + encoding := AcceptEncoding(b.r.Header.Get("Accept-Encoding")) + switch encoding { + case "br": b.headers.Set("Content-Encoding", "br") b.writeHeaders() @@ -146,7 +147,7 @@ func (b *Builder) compress(data []byte) { brotliWriter.Write(data) brotliWriter.Close() return - case strings.Contains(acceptEncoding, "gzip"): + case "gzip": b.headers.Set("Content-Encoding", "gzip") b.writeHeaders() @@ -154,7 +155,7 @@ func (b *Builder) compress(data []byte) { gzipWriter.Write(data) gzipWriter.Close() return - case strings.Contains(acceptEncoding, "deflate"): + case "deflate": b.headers.Set("Content-Encoding", "deflate") b.writeHeaders() diff --git a/internal/http/response/builder_test.go b/internal/http/response/builder_test.go index 3ded73dc4ac..6274f2c8e1b 100644 --- a/internal/http/response/builder_test.go +++ b/internal/http/response/builder_test.go @@ -358,7 +358,7 @@ func TestIfNoneMatch(t *testing.T) { func TestBuildResponseWithBrotliCompression(t *testing.T) { body := strings.Repeat("a", compressionThreshold+1) r, err := http.NewRequest("GET", "/", nil) - r.Header.Set("Accept-Encoding", "gzip, deflate, br") + r.Header.Set("Accept-Encoding", "br, gzip, deflate") if err != nil { t.Fatal(err) } diff --git a/internal/http/response/encoding.go b/internal/http/response/encoding.go new file mode 100644 index 00000000000..018319a1129 --- /dev/null +++ b/internal/http/response/encoding.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package response + +import ( + "slices" + "strconv" + "strings" + "sync" +) + +// encodingCache stores mapping between raw Accept-Encoding value +// and chosen encoding from this value. +var encodingCache sync.Map + +// AcceptEncoding parses input string according to [HTTP Semantics] +// and returns first encoding that can be understood by us. +// +// Currently this function ignores set weights other than q=0. +// Encodings with q=0 will not be considered. +// +// If string is empty or no encoding was accepted function returns "identity". +// +// For "identity;q=0" and "*;q=0" function returns an empty string. In that case, +// if no other encoding was accepted, 406 Not Acceptable should be returned. +// +// [HTTP Semantics]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding. +func AcceptEncoding(acceptEncoding string) string { + acceptEncoding = strings.TrimSpace(acceptEncoding) + if v, ok := encodingCache.Load(acceptEncoding); ok { + return v.(string) + } + + accepted := "identity" + + for enc := range strings.SplitSeq(acceptEncoding, ",") { + enc = strings.TrimSpace(enc) + + if qi := strings.IndexByte(enc, ';'); qi > -1 { + qstr := strings.TrimPrefix(enc[qi:], ";q=") + + q, err := strconv.ParseFloat(qstr, 64) + if err != nil { + continue // Ignore weird float values. + } + + enc = enc[:qi] + + if q == 0 && slices.Contains([]string{"identity", "*"}, enc) { + accepted = "" // Explicitly disabled, so can't be used as fallback. + continue + } + + if q == 0 { + continue // Skipping unwanted. + } + } + + // List should be in sync with [Builder.Write]. + if !slices.Contains([]string{"br", "gzip", "deflate"}, enc) { + continue // Skipping unsupported. + } + + accepted = strings.ToLower(enc) + break + } + + // Store selection as it won't change for given header value. + if v, ok := encodingCache.LoadOrStore(acceptEncoding, accepted); ok { + return v.(string) + } + + return accepted +} diff --git a/internal/http/response/encoding_test.go b/internal/http/response/encoding_test.go new file mode 100644 index 00000000000..ce88cdf22c8 --- /dev/null +++ b/internal/http/response/encoding_test.go @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package response_test + +import ( + "crypto/rand" + "testing" + + "miniflux.app/v2/internal/http/response" +) + +func TestAcceptEncoding(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + acceptEncoding string + want string + }{ + { + name: "Empty input", + acceptEncoding: "", + want: "identity", + }, + { + name: "q=0 and identity", + acceptEncoding: "identity;q=0", + want: "", + }, + { + name: "q=0 and *", + acceptEncoding: "*;q=0", + want: "", + }, + { + name: "gzip", + acceptEncoding: "gzip", + want: "gzip", + }, + { + name: "gzip and br", + acceptEncoding: "gzip,br", + want: "gzip", + }, + { + name: "br and gzip", + acceptEncoding: "br,gzip,deflate", + want: "br", + }, + { + name: "unsupported encoding", + acceptEncoding: "unknown", + want: "identity", + }, + { + name: "empty encoding", + acceptEncoding: ",", + want: "identity", + }, + { + name: "multiple encodings and q=0", + acceptEncoding: "gzip;q=0,br;q=0", + want: "identity", + }, + { + // We want br here but weights are not supported. + name: "multiple encodings and q values", + acceptEncoding: "gzip;q=0.5,br;q=0.8", + want: "gzip", + }, + { + name: "multiple encodings and wildcard", + acceptEncoding: "*;q=0,gzip,br", + want: "gzip", + }, + { + name: "multiple encodings and wildcard and q=0", + acceptEncoding: "*;q=0,gzip,br;q=0", + want: "gzip", + }, + { + // We want br here but weights are not supported. + name: "multiple encodings and wildcard and q values", + acceptEncoding: "*;q=0.5,gzip;q=0.8,br", + want: "gzip", + }, + { + name: "multiple encodings and wildcard and q values and q=0", + acceptEncoding: "*;q=0.5,gzip;q=0.8,br;q=0", + want: "gzip", + }, + { + name: "invalid q value", + acceptEncoding: "gzip;q=abc,deflate", + want: "deflate", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got := response.AcceptEncoding(test.acceptEncoding) + if got != test.want { + t.Errorf("AcceptEncoding() = %q, want %q", got, test.want) + } + }) + } +} + +func TestAcceptEncodingCache(t *testing.T) { + encoding := "identity;q=0,gzip," + rand.Text() // rand for avoid clashes with other test cases + expected := "gzip" + + got := response.AcceptEncoding(encoding) + if got != expected { + t.Errorf("AcceptEncoding() = %q, want %q", got, expected) + } + + got = response.AcceptEncoding(encoding) + if got != expected { + t.Errorf("AcceptEncoding() = %q, want %q", got, expected) + } +}