Skip to content

Commit f35a7fa

Browse files
authored
encode,staticfiles: Content negotiation, precompressed files (#4045)
* encode: implement prefer setting * encode: minimum_length configurable via caddyfile * encode: configurable content-types which to encode * file_server: support precompressed files * encode: use ReponseMatcher for conditional encoding of content * linting error & documentation of encode.PrecompressedOrder * encode: allow just one response matcher also change the namespace of the encoders back, I accidently changed to precompressed >.> default matchers include a * to match to any charset, that may be appended * rounding of the PR * added integration tests for new caddyfile directives * improved various doc strings (punctuation and typos) * added json tag for file_server precompress order and encode matcher * file_server: add vary header, remove accept-ranges when serving precompressed files * encode: move Suffix implementation to precompressed modules
1 parent 75f797d commit f35a7fa

File tree

12 files changed

+768
-49
lines changed

12 files changed

+768
-49
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
:80
2+
3+
encode gzip zstd {
4+
minimum_length 256
5+
prefer zstd gzip
6+
match {
7+
status 2xx 4xx 500
8+
header Content-Type text/*
9+
header Content-Type application/json*
10+
header Content-Type application/javascript*
11+
header Content-Type application/xhtml+xml*
12+
header Content-Type application/atom+xml*
13+
header Content-Type application/rss+xml*
14+
header Content-Type image/svg+xml*
15+
}
16+
}
17+
----------
18+
{
19+
"apps": {
20+
"http": {
21+
"servers": {
22+
"srv0": {
23+
"listen": [
24+
":80"
25+
],
26+
"routes": [
27+
{
28+
"handle": [
29+
{
30+
"encodings": {
31+
"gzip": {},
32+
"zstd": {}
33+
},
34+
"handler": "encode",
35+
"match": {
36+
"headers": {
37+
"Content-Type": [
38+
"text/*",
39+
"application/json*",
40+
"application/javascript*",
41+
"application/xhtml+xml*",
42+
"application/atom+xml*",
43+
"application/rss+xml*",
44+
"image/svg+xml*"
45+
]
46+
},
47+
"status_code": [
48+
2,
49+
4,
50+
500
51+
]
52+
},
53+
"minimum_length": 256,
54+
"prefer": [
55+
"zstd",
56+
"gzip"
57+
]
58+
}
59+
]
60+
}
61+
]
62+
}
63+
}
64+
}
65+
}
66+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
:80
2+
3+
file_server {
4+
precompressed zstd br gzip
5+
}
6+
----------
7+
{
8+
"apps": {
9+
"http": {
10+
"servers": {
11+
"srv0": {
12+
"listen": [
13+
":80"
14+
],
15+
"routes": [
16+
{
17+
"handle": [
18+
{
19+
"handler": "file_server",
20+
"hide": [
21+
"./Caddyfile"
22+
],
23+
"precompressed": {
24+
"br": {},
25+
"gzip": {},
26+
"zstd": {}
27+
},
28+
"precompressed_order": [
29+
"zstd",
30+
"br",
31+
"gzip"
32+
]
33+
}
34+
]
35+
}
36+
]
37+
}
38+
}
39+
}
40+
}
41+
}

caddytest/integration/caddyfile_adapt_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package integration
22

33
import (
4+
jsonMod "encoding/json"
5+
"fmt"
46
"io/ioutil"
7+
"path/filepath"
58
"regexp"
69
"strings"
710
"testing"
@@ -39,6 +42,10 @@ func TestCaddyfileAdaptToJSON(t *testing.T) {
3942
// replace windows newlines in the json with unix newlines
4043
json = winNewlines.ReplaceAllString(json, "\n")
4144

45+
// replace os-specific default path for file_server's hide field
46+
replacePath, _ := jsonMod.Marshal(fmt.Sprint(".", string(filepath.Separator), "Caddyfile"))
47+
json = strings.ReplaceAll(json, `"./Caddyfile"`, string(replacePath))
48+
4249
// run the test
4350
ok := caddytest.CompareAdapt(t, filename, caddyfile, "caddyfile", json)
4451
if !ok {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package caddybrotli
2+
3+
import (
4+
"github.com/caddyserver/caddy/v2"
5+
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
6+
)
7+
8+
func init() {
9+
caddy.RegisterModule(BrotliPrecompressed{})
10+
}
11+
12+
// BrotliPrecompressed provides the file extension for files precompressed with brotli encoding.
13+
type BrotliPrecompressed struct{}
14+
15+
// CaddyModule returns the Caddy module information.
16+
func (BrotliPrecompressed) CaddyModule() caddy.ModuleInfo {
17+
return caddy.ModuleInfo{
18+
ID: "http.precompressed.br",
19+
New: func() caddy.Module { return new(BrotliPrecompressed) },
20+
}
21+
}
22+
23+
// AcceptEncoding returns the name of the encoding as
24+
// used in the Accept-Encoding request headers.
25+
func (BrotliPrecompressed) AcceptEncoding() string { return "br" }
26+
27+
// Suffix returns the filename suffix of precompressed files.
28+
func (BrotliPrecompressed) Suffix() string { return ".br" }
29+
30+
// Interface guards
31+
var _ encode.Precompressed = (*BrotliPrecompressed)(nil)

modules/caddyhttp/encode/caddyfile.go

Lines changed: 122 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
package encode
1616

1717
import (
18-
"fmt"
18+
"net/http"
19+
"strconv"
20+
"strings"
1921

2022
"github.com/caddyserver/caddy/v2"
2123
"github.com/caddyserver/caddy/v2/caddyconfig"
@@ -40,21 +42,31 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
4042
// UnmarshalCaddyfile sets up the handler from Caddyfile tokens. Syntax:
4143
//
4244
// encode [<matcher>] <formats...> {
43-
// gzip [<level>]
45+
// gzip [<level>]
4446
// zstd
47+
// minimum_length <length>
48+
// prefer <formats...>
49+
// # response matcher block
50+
// match {
51+
// status <code...>
52+
// header <field> [<value>]
53+
// }
54+
// # or response matcher single line syntax
55+
// match [header <field> [<value>]] | [status <code...>]
4556
// }
4657
//
4758
// Specifying the formats on the first line will use those formats' defaults.
4859
func (enc *Encode) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
60+
responseMatchers := make(map[string]caddyhttp.ResponseMatcher)
4961
for d.Next() {
5062
for _, arg := range d.RemainingArgs() {
5163
mod, err := caddy.GetModule("http.encoders." + arg)
5264
if err != nil {
53-
return fmt.Errorf("finding encoder module '%s': %v", mod, err)
65+
return d.Errf("finding encoder module '%s': %v", mod, err)
5466
}
5567
encoding, ok := mod.New().(Encoding)
5668
if !ok {
57-
return fmt.Errorf("module %s is not an HTTP encoding", mod)
69+
return d.Errf("module %s is not an HTTP encoding", mod)
5870
}
5971
if enc.EncodingsRaw == nil {
6072
enc.EncodingsRaw = make(caddy.ModuleMap)
@@ -63,25 +75,118 @@ func (enc *Encode) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
6375
}
6476

6577
for d.NextBlock(0) {
66-
name := d.Val()
67-
modID := "http.encoders." + name
68-
unm, err := caddyfile.UnmarshalModule(d, modID)
69-
if err != nil {
70-
return err
71-
}
72-
encoding, ok := unm.(Encoding)
73-
if !ok {
74-
return fmt.Errorf("module %s is not an HTTP encoding; is %T", modID, unm)
78+
switch d.Val() {
79+
case "minimum_length":
80+
if !d.NextArg() {
81+
return d.ArgErr()
82+
}
83+
minLength, err := strconv.Atoi(d.Val())
84+
if err != nil {
85+
return err
86+
}
87+
enc.MinLength = minLength
88+
case "prefer":
89+
var encs []string
90+
for d.NextArg() {
91+
encs = append(encs, d.Val())
92+
}
93+
if len(encs) == 0 {
94+
return d.ArgErr()
95+
}
96+
enc.Prefer = encs
97+
case "match":
98+
err := enc.parseNamedResponseMatcher(d.NewFromNextSegment(), responseMatchers)
99+
if err != nil {
100+
return err
101+
}
102+
matcher := responseMatchers["match"]
103+
enc.Matcher = &matcher
104+
default:
105+
name := d.Val()
106+
modID := "http.encoders." + name
107+
unm, err := caddyfile.UnmarshalModule(d, modID)
108+
if err != nil {
109+
return err
110+
}
111+
encoding, ok := unm.(Encoding)
112+
if !ok {
113+
return d.Errf("module %s is not an HTTP encoding; is %T", modID, unm)
114+
}
115+
if enc.EncodingsRaw == nil {
116+
enc.EncodingsRaw = make(caddy.ModuleMap)
117+
}
118+
enc.EncodingsRaw[name] = caddyconfig.JSON(encoding, nil)
75119
}
76-
if enc.EncodingsRaw == nil {
77-
enc.EncodingsRaw = make(caddy.ModuleMap)
78-
}
79-
enc.EncodingsRaw[name] = caddyconfig.JSON(encoding, nil)
80120
}
81121
}
82122

83123
return nil
84124
}
85125

126+
// Parse the tokens of a named response matcher.
127+
//
128+
// match {
129+
// header <field> [<value>]
130+
// status <code...>
131+
// }
132+
//
133+
// Or, single line syntax:
134+
//
135+
// match [header <field> [<value>]] | [status <code...>]
136+
//
137+
func (enc *Encode) parseNamedResponseMatcher(d *caddyfile.Dispenser, matchers map[string]caddyhttp.ResponseMatcher) error {
138+
for d.Next() {
139+
definitionName := d.Val()
140+
141+
if _, ok := matchers[definitionName]; ok {
142+
return d.Errf("matcher is defined more than once: %s", definitionName)
143+
}
144+
145+
matcher := caddyhttp.ResponseMatcher{}
146+
for nesting := d.Nesting(); d.NextArg() || d.NextBlock(nesting); {
147+
switch d.Val() {
148+
case "header":
149+
if matcher.Headers == nil {
150+
matcher.Headers = http.Header{}
151+
}
152+
153+
// reuse the header request matcher's unmarshaler
154+
headerMatcher := caddyhttp.MatchHeader(matcher.Headers)
155+
err := headerMatcher.UnmarshalCaddyfile(d.NewFromNextSegment())
156+
if err != nil {
157+
return err
158+
}
159+
160+
matcher.Headers = http.Header(headerMatcher)
161+
case "status":
162+
if matcher.StatusCode == nil {
163+
matcher.StatusCode = []int{}
164+
}
165+
166+
args := d.RemainingArgs()
167+
if len(args) == 0 {
168+
return d.ArgErr()
169+
}
170+
171+
for _, arg := range args {
172+
if len(arg) == 3 && strings.HasSuffix(arg, "xx") {
173+
arg = arg[:1]
174+
}
175+
statusNum, err := strconv.Atoi(arg)
176+
if err != nil {
177+
return d.Errf("bad status value '%s': %v", arg, err)
178+
}
179+
matcher.StatusCode = append(matcher.StatusCode, statusNum)
180+
}
181+
default:
182+
return d.Errf("unrecognized response matcher %s", d.Val())
183+
}
184+
}
185+
186+
matchers[definitionName] = matcher
187+
}
188+
return nil
189+
}
190+
86191
// Interface guard
87192
var _ caddyfile.Unmarshaler = (*Encode)(nil)

0 commit comments

Comments
 (0)