Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 13, 14:15 UTC
fileserver: Support glob expansion in file matcher (#4993)
* fileserver: Support glob expansion in file matcher * Fix tests * Fix bugs and tests * Attempt Windows fix, sigh * debug Windows, WIP * Continue debugging Windows * Another attempt at Windows * Plz Windows * Cmon... * Clean up, hope I didn't break anything
Matt Holt committed
Sep 5, 2022 at 13:53 UTC
d5ea43fb4b549dda6040b31ce472e7843dfc5077
7 files changed
+279
-143
modules/caddyhttp/fileserver/caddyfile.go
+41
-20
@@ -36,17 +36,16 @@ func init() {
36
// parseCaddyfile parses the file_server directive. It enables the static file
37
// server and configures it with this syntax:
38
//
39
-// file_server [<matcher>] [browse] {
40
-// fs <backend...>
41
-// root <path>
42
-// hide <files...>
43
-// index <files...>
44
-// browse [<template_file>]
45
-// precompressed <formats...>
46
-// status <status>
47
-// disable_canonical_uris
48
-// }
49
-//
39
+// file_server [<matcher>] [browse] {
40
+// fs <backend...>
41
+// root <path>
42
+// hide <files...>
43
+// index <files...>
44
+// browse [<template_file>]
45
+// precompressed <formats...>
46
+// status <status>
47
+// disable_canonical_uris
48
+// }
49
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
50
var fsrv FileServer
51
@@ -177,22 +176,23 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
176
// with a rewrite directive, so this is not a standard handler directive.
177
// A try_files directive has this syntax (notice no matcher tokens accepted):
178
//
180
-// try_files <files...>
179
+// try_files <files...> {
180
+// policy first_exist|smallest_size|largest_size|most_recently_modified
181
+// }
182
//
183
// and is basically shorthand for:
184
//
184
-// @try_files {
185
-// file {
186
-// try_files <files...>
187
-// }
188
-// }
189
-// rewrite @try_files {http.matchers.file.relative}
185
+// @try_files file {
186
+// try_files <files...>
187
+// policy first_exist|smallest_size|largest_size|most_recently_modified
188
+// }
189
+// rewrite @try_files {http.matchers.file.relative}
190
//
191
// This directive rewrites request paths only, preserving any other part
192
// of the URI, unless the part is explicitly given in the file list. For
193
// example, if any of the files in the list have a query string:
194
//
195
-// try_files {path} index.php?{query}&p={path}
195
+// try_files {path} index.php?{query}&p={path}
196
//
197
// then the query string will not be treated as part of the file name; and
198
// if that file matches, the given query string will replace any query string
@@ -207,6 +207,27 @@ func parseTryFiles(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
207
return nil, h.ArgErr()
208
}
209
210
+ // parse out the optional try policy
211
+ var tryPolicy string
212
+ for nesting := h.Nesting(); h.NextBlock(nesting); {
213
+ switch h.Val() {
214
+ case "policy":
215
+ if tryPolicy != "" {
216
+ return nil, h.Err("try policy already configured")
217
+ }
218
+ if !h.NextArg() {
219
+ return nil, h.ArgErr()
220
+ }
221
+ tryPolicy = h.Val()
222
+
223
+ switch tryPolicy {
224
+ case tryPolicyFirstExist, tryPolicyLargestSize, tryPolicySmallestSize, tryPolicyMostRecentlyMod:
225
+ default:
226
+ return nil, h.Errf("unrecognized try policy: %s", tryPolicy)
227
+ }
228
+ }
229
+ }
230
+
231
// makeRoute returns a route that tries the files listed in try
232
// and then rewrites to the matched file; userQueryString is
233
// appended to the rewrite rule.
@@ -215,7 +236,7 @@ func parseTryFiles(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
236
URI: "{http.matchers.file.relative}" + userQueryString,
237
}
238
matcherSet := caddy.ModuleMap{
218
- "file": h.JSON(MatchFile{TryFiles: try}),
239
+ "file": h.JSON(MatchFile{TryFiles: try, TryPolicy: tryPolicy}),
240
}
241
return h.NewRoute(matcherSet, handler)
242
}
modules/caddyhttp/fileserver/matcher.go
+156
-74
@@ -21,9 +21,10 @@ import (
21
"net/http"
22
"os"
23
"path"
24
+ "path/filepath"
25
+ "runtime"
26
"strconv"
27
"strings"
26
- "time"
28
29
"github.com/caddyserver/caddy/v2"
30
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
@@ -33,6 +34,7 @@ import (
34
"github.com/google/cel-go/common/operators"
35
"github.com/google/cel-go/common/types/ref"
36
"github.com/google/cel-go/parser"
37
+ "go.uber.org/zap"
38
exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
39
)
40
@@ -55,6 +57,9 @@ func init() {
57
// the matched file is a directory, "file" otherwise.
58
// - `{http.matchers.file.remainder}` Set to the remainder
59
// of the path if the path was split by `split_path`.
60
+//
61
+// Even though file matching may depend on the OS path
62
+// separator, the placeholder values always use /.
63
type MatchFile struct {
64
// The file system implementation to use. By default, the
65
// local disk file system will be used.
@@ -101,6 +106,8 @@ type MatchFile struct {
106
// Each delimiter must appear at the end of a URI path
107
// component in order to be used as a split delimiter.
108
SplitPath []string `json:"split_path,omitempty"`
109
+
110
+ logger *zap.Logger
111
}
112
113
// CaddyModule returns the Caddy module information.
@@ -113,12 +120,11 @@ func (MatchFile) CaddyModule() caddy.ModuleInfo {
120
121
// UnmarshalCaddyfile sets up the matcher from Caddyfile tokens. Syntax:
122
//
116
-// file <files...> {
117
-// root <path>
118
-// try_files <files...>
119
-// try_policy first_exist|smallest_size|largest_size|most_recently_modified
120
-// }
121
-//
123
+// file <files...> {
124
+// root <path>
125
+// try_files <files...>
126
+// try_policy first_exist|smallest_size|largest_size|most_recently_modified
127
+// }
128
func (m *MatchFile) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
129
for d.Next() {
130
m.TryFiles = append(m.TryFiles, d.RemainingArgs()...)
@@ -156,7 +162,8 @@ func (m *MatchFile) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
162
// expression matchers.
163
//
164
// Example:
159
-// expression file({'root': '/srv', 'try_files': [{http.request.uri.path}, '/index.php'], 'try_policy': 'first_exist', 'split_path': ['.php']})
165
+//
166
+// expression file({'root': '/srv', 'try_files': [{http.request.uri.path}, '/index.php'], 'try_policy': 'first_exist', 'split_path': ['.php']})
167
func (MatchFile) CELLibrary(ctx caddy.Context) (cel.Library, error) {
168
requestType := cel.ObjectType("http.Request")
169
@@ -249,6 +256,8 @@ func celFileMatcherMacroExpander() parser.MacroExpander {
256
257
// Provision sets up m's defaults.
258
func (m *MatchFile) Provision(ctx caddy.Context) error {
259
+ m.logger = ctx.Logger(m)
260
+
261
// establish the file system to use
262
if len(m.FileSystemRaw) > 0 {
263
mod, err := ctx.LoadModule(m, "FileSystemRaw")
@@ -290,10 +299,10 @@ func (m MatchFile) Validate() error {
299
// Match returns true if r matches m. Returns true
300
// if a file was matched. If so, four placeholders
301
// will be available:
293
-// - http.matchers.file.relative
294
-// - http.matchers.file.absolute
295
-// - http.matchers.file.type
296
-// - http.matchers.file.remainder
302
+// - http.matchers.file.relative: Path to file relative to site root
303
+// - http.matchers.file.absolute: Path to file including site root
304
+// - http.matchers.file.type: file or directory
305
+// - http.matchers.file.remainder: Portion remaining after splitting file path (if configured)
306
func (m MatchFile) Match(r *http.Request) bool {
307
return m.selectFile(r)
308
}
@@ -303,23 +312,80 @@ func (m MatchFile) Match(r *http.Request) bool {
312
func (m MatchFile) selectFile(r *http.Request) (matched bool) {
313
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
314
306
- root := repl.ReplaceAll(m.Root, ".")
315
+ root := filepath.Clean(repl.ReplaceAll(m.Root, "."))
316
+
317
+ type matchCandidate struct {
318
+ fullpath, relative, splitRemainder string
319
+ }
320
308
- // common preparation of the file into parts
309
- prepareFilePath := func(file string) (suffix, fullpath, remainder string) {
310
- suffix, remainder = m.firstSplit(path.Clean(repl.ReplaceAll(file, "")))
321
+ // makeCandidates evaluates placeholders in file and expands any glob expressions
322
+ // to build a list of file candidates. Special glob characters are escaped in
323
+ // placeholder replacements so globs cannot be expanded from placeholders, and
324
+ // globs are not evaluated on Windows because of its path separator character:
325
+ // escaping is not supported so we can't safely glob on Windows, or we can't
326
+ // support placeholders on Windows (pick one). (Actually, evaluating untrusted
327
+ // globs is not the end of the world since the file server will still hide any
328
+ // hidden files, it just might lead to unexpected behavior.)
329
+ makeCandidates := func(file string) []matchCandidate {
330
+ // first, evaluate placeholders in the file pattern
331
+ expandedFile, err := repl.ReplaceFunc(file, func(variable string, val any) (any, error) {
332
+ if runtime.GOOS == "windows" {
333
+ return val, nil
334
+ }
335
+ switch v := val.(type) {
336
+ case string:
337
+ return globSafeRepl.Replace(v), nil
338
+ case fmt.Stringer:
339
+ return globSafeRepl.Replace(v.String()), nil
340
+ }
341
+ return val, nil
342
+ })
343
+ if err != nil {
344
+ m.logger.Error("evaluating placeholders", zap.Error(err))
345
+ expandedFile = file // "oh well," I guess?
346
+ }
347
+
348
+ // clean the path and split, if configured -- we must split before
349
+ // globbing so that the file system doesn't include the remainder
350
+ // ("afterSplit") in the filename; be sure to restore trailing slash
351
+ beforeSplit, afterSplit := m.firstSplit(path.Clean(expandedFile))
352
if strings.HasSuffix(file, "/") {
312
- suffix += "/"
353
+ beforeSplit += "/"
354
+ }
355
+
356
+ // create the full path to the file by prepending the site root
357
+ fullPattern := caddyhttp.SanitizedPathJoin(root, beforeSplit)
358
+
359
+ // expand glob expressions, but not on Windows because Glob() doesn't
360
+ // support escaping on Windows due to path separator)
361
+ var globResults []string
362
+ if runtime.GOOS == "windows" {
363
+ globResults = []string{fullPattern} // precious Windows
364
+ } else {
365
+ globResults, err = fs.Glob(m.fileSystem, fullPattern)
366
+ if err != nil {
367
+ m.logger.Error("expanding glob", zap.Error(err))
368
+ }
369
}
314
- fullpath = caddyhttp.SanitizedPathJoin(root, suffix)
315
- return
370
+
371
+ // for each glob result, combine all the forms of the path
372
+ var candidates []matchCandidate
373
+ for _, result := range globResults {
374
+ candidates = append(candidates, matchCandidate{
375
+ fullpath: result,
376
+ relative: strings.TrimPrefix(result, root),
377
+ splitRemainder: afterSplit,
378
+ })
379
+ }
380
+
381
+ return candidates
382
}
383
318
- // sets up the placeholders for the matched file
319
- setPlaceholders := func(info os.FileInfo, rel string, abs string, remainder string) {
320
- repl.Set("http.matchers.file.relative", rel)
321
- repl.Set("http.matchers.file.absolute", abs)
322
- repl.Set("http.matchers.file.remainder", remainder)
384
+ // setPlaceholders creates the placeholders for the matched file
385
+ setPlaceholders := func(candidate matchCandidate, info fs.FileInfo) {
386
+ repl.Set("http.matchers.file.relative", filepath.ToSlash(candidate.relative))
387
+ repl.Set("http.matchers.file.absolute", filepath.ToSlash(candidate.fullpath))
388
+ repl.Set("http.matchers.file.remainder", filepath.ToSlash(candidate.splitRemainder))
389
390
fileType := "file"
391
if info.IsDir() {
@@ -328,76 +394,83 @@ func (m MatchFile) selectFile(r *http.Request) (matched bool) {
394
repl.Set("http.matchers.file.type", fileType)
395
}
396
397
+ // match file according to the configured policy
398
switch m.TryPolicy {
399
case "", tryPolicyFirstExist:
333
- for _, f := range m.TryFiles {
334
- if err := parseErrorCode(f); err != nil {
400
+ for _, pattern := range m.TryFiles {
401
+ if err := parseErrorCode(pattern); err != nil {
402
caddyhttp.SetVar(r.Context(), caddyhttp.MatcherErrorVarKey, err)
403
return
404
}
338
- suffix, fullpath, remainder := prepareFilePath(f)
339
- if info, exists := m.strictFileExists(fullpath); exists {
340
- setPlaceholders(info, suffix, fullpath, remainder)
341
- return true
405
+ candidates := makeCandidates(pattern)
406
+ for _, c := range candidates {
407
+ if info, exists := m.strictFileExists(c.fullpath); exists {
408
+ setPlaceholders(c, info)
409
+ return true
410
+ }
411
}
412
}
413
414
case tryPolicyLargestSize:
415
var largestSize int64
347
- var largestFilename string
348
- var largestSuffix string
349
- var remainder string
350
- var info os.FileInfo
351
- for _, f := range m.TryFiles {
352
- suffix, fullpath, splitRemainder := prepareFilePath(f)
353
- info, err := m.fileSystem.Stat(fullpath)
354
- if err == nil && info.Size() > largestSize {
355
- largestSize = info.Size()
356
- largestFilename = fullpath
357
- largestSuffix = suffix
358
- remainder = splitRemainder
416
+ var largest matchCandidate
417
+ var largestInfo os.FileInfo
418
+ for _, pattern := range m.TryFiles {
419
+ candidates := makeCandidates(pattern)
420
+ for _, c := range candidates {
421
+ info, err := m.fileSystem.Stat(c.fullpath)
422
+ if err == nil && info.Size() > largestSize {
423
+ largestSize = info.Size()
424
+ largest = c
425
+ largestInfo = info
426
+ }
427
}
428
}
361
- setPlaceholders(info, largestSuffix, largestFilename, remainder)
429
+ if largestInfo == nil {
430
+ return false
431
+ }
432
+ setPlaceholders(largest, largestInfo)
433
return true
434
435
case tryPolicySmallestSize:
436
var smallestSize int64
366
- var smallestFilename string
367
- var smallestSuffix string
368
- var remainder string
369
- var info os.FileInfo
370
- for _, f := range m.TryFiles {
371
- suffix, fullpath, splitRemainder := prepareFilePath(f)
372
- info, err := m.fileSystem.Stat(fullpath)
373
- if err == nil && (smallestSize == 0 || info.Size() < smallestSize) {
374
- smallestSize = info.Size()
375
- smallestFilename = fullpath
376
- smallestSuffix = suffix
377
- remainder = splitRemainder
437
+ var smallest matchCandidate
438
+ var smallestInfo os.FileInfo
439
+ for _, pattern := range m.TryFiles {
440
+ candidates := makeCandidates(pattern)
441
+ for _, c := range candidates {
442
+ info, err := m.fileSystem.Stat(c.fullpath)
443
+ if err == nil && (smallestSize == 0 || info.Size() < smallestSize) {
444
+ smallestSize = info.Size()
445
+ smallest = c
446
+ smallestInfo = info
447
+ }
448
}
449
}
380
- setPlaceholders(info, smallestSuffix, smallestFilename, remainder)
450
+ if smallestInfo == nil {
451
+ return false
452
+ }
453
+ setPlaceholders(smallest, smallestInfo)
454
return true
455
456
case tryPolicyMostRecentlyMod:
384
- var recentDate time.Time
385
- var recentFilename string
386
- var recentSuffix string
387
- var remainder string
388
- var info os.FileInfo
389
- for _, f := range m.TryFiles {
390
- suffix, fullpath, splitRemainder := prepareFilePath(f)
391
- info, err := m.fileSystem.Stat(fullpath)
392
- if err == nil &&
393
- (recentDate.IsZero() || info.ModTime().After(recentDate)) {
394
- recentDate = info.ModTime()
395
- recentFilename = fullpath
396
- recentSuffix = suffix
397
- remainder = splitRemainder
457
+ var recent matchCandidate
458
+ var recentInfo os.FileInfo
459
+ for _, pattern := range m.TryFiles {
460
+ candidates := makeCandidates(pattern)
461
+ for _, c := range candidates {
462
+ info, err := m.fileSystem.Stat(c.fullpath)
463
+ if err == nil &&
464
+ (recentInfo == nil || info.ModTime().After(recentInfo.ModTime())) {
465
+ recent = c
466
+ recentInfo = info
467
+ }
468
}
469
}
400
- setPlaceholders(info, recentSuffix, recentFilename, remainder)
470
+ if recentInfo == nil {
471
+ return false
472
+ }
473
+ setPlaceholders(recent, recentInfo)
474
return true
475
}
476
@@ -425,7 +498,7 @@ func parseErrorCode(input string) error {
498
// NOT end in a forward slash, the file must NOT
499
// be a directory.
500
func (m MatchFile) strictFileExists(file string) (os.FileInfo, bool) {
428
- stat, err := m.fileSystem.Stat(file)
501
+ info, err := m.fileSystem.Stat(file)
502
if err != nil {
503
// in reality, this can be any error
504
// such as permission or even obscure
@@ -440,11 +513,11 @@ func (m MatchFile) strictFileExists(file string) (os.FileInfo, bool) {
513
if strings.HasSuffix(file, separator) {
514
// by convention, file paths ending
515
// in a path separator must be a directory
443
- return stat, stat.IsDir()
516
+ return info, info.IsDir()
517
}
518
// by convention, file paths NOT ending
519
// in a path separator must NOT be a directory
447
- return stat, !stat.IsDir()
520
+ return info, !info.IsDir()
521
}
522
523
// firstSplit returns the first result where the path
@@ -581,6 +654,15 @@ func isCELStringListLiteral(e *exprpb.Expr) bool {
654
return false
655
}
656
657
+// globSafeRepl replaces special glob characters with escaped
658
+// equivalents. Note that the filepath godoc states that
659
+// escaping is not done on Windows because of the separator.
660
+var globSafeRepl = strings.NewReplacer(
661
+ "*", "\\*",
662
+ "[", "\\[",
663
+ "?", "\\?",
664
+)
665
+
666
const (
667
tryPolicyFirstExist = "first_exist"
668
tryPolicyLargestSize = "largest_size"
modules/caddyhttp/fileserver/matcher_test.go
+14
-15
@@ -28,7 +28,6 @@ import (
28
)
29
30
func TestFileMatcher(t *testing.T) {
31
-
31
// Windows doesn't like colons in files names
32
isWindows := runtime.GOOS == "windows"
33
if !isWindows {
@@ -87,25 +86,25 @@ func TestFileMatcher(t *testing.T) {
86
},
87
{
88
path: "ملف.txt", // the path file name is not escaped
90
- expectedPath: "ملف.txt",
89
+ expectedPath: "/ملف.txt",
90
expectedType: "file",
91
matched: true,
92
},
93
{
94
path: url.PathEscape("ملف.txt"), // singly-escaped path
96
- expectedPath: "ملف.txt",
95
+ expectedPath: "/ملف.txt",
96
expectedType: "file",
97
matched: true,
98
},
99
{
100
path: url.PathEscape(url.PathEscape("ملف.txt")), // doubly-escaped path
102
- expectedPath: "%D9%85%D9%84%D9%81.txt",
101
+ expectedPath: "/%D9%85%D9%84%D9%81.txt",
102
expectedType: "file",
103
matched: true,
104
},
105
{
106
path: "./with:in-name.txt", // browsers send the request with the path as such
108
- expectedPath: "with:in-name.txt",
107
+ expectedPath: "/with:in-name.txt",
108
expectedType: "file",
109
matched: !isWindows,
110
},
@@ -118,7 +117,7 @@ func TestFileMatcher(t *testing.T) {
117
118
u, err := url.Parse(tc.path)
119
if err != nil {
121
- t.Fatalf("Test %d: parsing path: %v", i, err)
120
+ t.Errorf("Test %d: parsing path: %v", i, err)
121
}
122
123
req := &http.Request{URL: u}
@@ -126,24 +125,24 @@ func TestFileMatcher(t *testing.T) {
125
126
result := m.Match(req)
127
if result != tc.matched {
129
- t.Fatalf("Test %d: expected match=%t, got %t", i, tc.matched, result)
128
+ t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result)
129
}
130
131
rel, ok := repl.Get("http.matchers.file.relative")
132
if !ok && result {
134
- t.Fatalf("Test %d: expected replacer value", i)
133
+ t.Errorf("Test %d: expected replacer value", i)
134
}
135
if !result {
136
continue
137
}
138
139
if rel != tc.expectedPath {
141
- t.Fatalf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
140
+ t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
141
}
142
143
fileType, _ := repl.Get("http.matchers.file.type")
144
if fileType != tc.expectedType {
146
- t.Fatalf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
145
+ t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
146
}
147
}
148
}
@@ -222,7 +221,7 @@ func TestPHPFileMatcher(t *testing.T) {
221
222
u, err := url.Parse(tc.path)
223
if err != nil {
225
- t.Fatalf("Test %d: parsing path: %v", i, err)
224
+ t.Errorf("Test %d: parsing path: %v", i, err)
225
}
226
227
req := &http.Request{URL: u}
@@ -230,24 +229,24 @@ func TestPHPFileMatcher(t *testing.T) {
229
230
result := m.Match(req)
231
if result != tc.matched {
233
- t.Fatalf("Test %d: expected match=%t, got %t", i, tc.matched, result)
232
+ t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result)
233
}
234
235
rel, ok := repl.Get("http.matchers.file.relative")
236
if !ok && result {
238
- t.Fatalf("Test %d: expected replacer value", i)
237
+ t.Errorf("Test %d: expected replacer value", i)
238
}
239
if !result {
240
continue
241
}
242
243
if rel != tc.expectedPath {
245
- t.Fatalf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
244
+ t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
245
}
246
247
fileType, _ := repl.Get("http.matchers.file.type")
248
if fileType != tc.expectedType {
250
- t.Fatalf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
249
+ t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
250
}
251
}
252
}
modules/caddyhttp/fileserver/staticfiles.go
+11
-2
@@ -618,10 +618,15 @@ func (wr statusOverrideResponseWriter) WriteHeader(int) {
618
// rooting or path prefixing without being constrained to a single
619
// root folder. The standard os.DirFS implementation is problematic
620
// since roots can be dynamic in our application.)
621
+//
622
+// osFS also implements fs.GlobFS, fs.ReadDirFS, and fs.ReadFileFS.
623
type osFS struct{}
624
623
-func (osFS) Open(name string) (fs.File, error) { return os.Open(name) }
624
-func (osFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }
625
+func (osFS) Open(name string) (fs.File, error) { return os.Open(name) }
626
+func (osFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }
627
+func (osFS) Glob(pattern string) ([]string, error) { return filepath.Glob(pattern) }
628
+func (osFS) ReadDir(name string) ([]fs.DirEntry, error) { return os.ReadDir(name) }
629
+func (osFS) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }
630
631
var defaultIndexNames = []string{"index.html", "index.txt"}
632
@@ -634,4 +639,8 @@ const (
639
var (
640
_ caddy.Provisioner = (*FileServer)(nil)
641
_ caddyhttp.MiddlewareHandler = (*FileServer)(nil)
642
+
643
+ _ fs.GlobFS = (*osFS)(nil)
644
+ _ fs.ReadDirFS = (*osFS)(nil)
645
+ _ fs.ReadFileFS = (*osFS)(nil)
646
)
modules/caddyhttp/fileserver/testdata/foodir/bar.txt
new
+1
@@ -0,0 +1 @@
1
+foodir/bar.txt
\ No newline at end of file
modules/caddyhttp/replacer.go
+4
@@ -143,6 +143,10 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
143
case "http.request.uri.path.dir":
144
dir, _ := path.Split(req.URL.Path)
145
return dir, true
146
+ case "http.request.uri.path.file.base":
147
+ return strings.TrimSuffix(path.Base(req.URL.Path), path.Ext(req.URL.Path)), true
148
+ case "http.request.uri.path.file.ext":
149
+ return path.Ext(req.URL.Path), true
150
case "http.request.uri.query":
151
return req.URL.RawQuery, true
152
case "http.request.duration":
modules/caddyhttp/replacer_test.go
+52
-32
@@ -27,7 +27,7 @@ import (
27
)
28
29
func TestHTTPVarReplacement(t *testing.T) {
30
- req, _ := http.NewRequest("GET", "/", nil)
30
+ req, _ := http.NewRequest(http.MethodGet, "/foo/bar.tar.gz", nil)
31
repl := caddy.NewReplacer()
32
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
33
req = req.WithContext(ctx)
@@ -72,114 +72,134 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
72
addHTTPVarsToReplacer(repl, req, res)
73
74
for i, tc := range []struct {
75
- input string
75
+ get string
76
expect string
77
}{
78
{
79
- input: "{http.request.scheme}",
79
+ get: "http.request.scheme",
80
expect: "https",
81
},
82
{
83
- input: "{http.request.host}",
83
+ get: "http.request.method",
84
+ expect: http.MethodGet,
85
+ },
86
+ {
87
+ get: "http.request.host",
88
expect: "example.com",
89
},
90
{
87
- input: "{http.request.port}",
91
+ get: "http.request.port",
92
expect: "80",
93
},
94
{
91
- input: "{http.request.hostport}",
95
+ get: "http.request.hostport",
96
expect: "example.com:80",
97
},
98
{
95
- input: "{http.request.remote.host}",
99
+ get: "http.request.remote.host",
100
expect: "localhost",
101
},
102
{
99
- input: "{http.request.remote.port}",
103
+ get: "http.request.remote.port",
104
expect: "1234",
105
},
106
{
103
- input: "{http.request.host.labels.0}",
107
+ get: "http.request.host.labels.0",
108
expect: "com",
109
},
110
{
107
- input: "{http.request.host.labels.1}",
111
+ get: "http.request.host.labels.1",
112
expect: "example",
113
},
114
{
111
- input: "{http.request.host.labels.2}",
112
- expect: "<empty>",
115
+ get: "http.request.host.labels.2",
116
+ expect: "",
117
},
118
{
115
- input: "{http.request.tls.cipher_suite}",
119
+ get: "http.request.uri.path.file",
120
+ expect: "bar.tar.gz",
121
+ },
122
+ {
123
+ get: "http.request.uri.path.file.base",
124
+ expect: "bar.tar",
125
+ },
126
+ {
127
+ // not ideal, but also most correct, given that files can have dots (example: index.<SHA>.html) TODO: maybe this isn't right..
128
+ get: "http.request.uri.path.file.ext",
129
+ expect: ".gz",
130
+ },
131
+ {
132
+ get: "http.request.tls.cipher_suite",
133
expect: "TLS_AES_256_GCM_SHA384",
134
},
135
{
119
- input: "{http.request.tls.proto}",
136
+ get: "http.request.tls.proto",
137
expect: "h2",
138
},
139
{
123
- input: "{http.request.tls.proto_mutual}",
140
+ get: "http.request.tls.proto_mutual",
141
expect: "true",
142
},
143
{
127
- input: "{http.request.tls.resumed}",
144
+ get: "http.request.tls.resumed",
145
expect: "false",
146
},
147
{
131
- input: "{http.request.tls.server_name}",
148
+ get: "http.request.tls.server_name",
149
expect: "foo.com",
150
},
151
{
135
- input: "{http.request.tls.version}",
152
+ get: "http.request.tls.version",
153
expect: "tls1.3",
154
},
155
{
139
- input: "{http.request.tls.client.fingerprint}",
156
+ get: "http.request.tls.client.fingerprint",
157
expect: "9f57b7b497cceacc5459b76ac1c3afedbc12b300e728071f55f84168ff0f7702",
158
},
159
{
143
- input: "{http.request.tls.client.issuer}",
160
+ get: "http.request.tls.client.issuer",
161
expect: "CN=Caddy Test CA",
162
},
163
{
147
- input: "{http.request.tls.client.serial}",
164
+ get: "http.request.tls.client.serial",
165
expect: "2",
166
},
167
{
151
- input: "{http.request.tls.client.subject}",
168
+ get: "http.request.tls.client.subject",
169
expect: "CN=client.localdomain",
170
},
171
{
155
- input: "{http.request.tls.client.san.dns_names}",
172
+ get: "http.request.tls.client.san.dns_names",
173
expect: "[localhost]",
174
},
175
{
159
- input: "{http.request.tls.client.san.dns_names.0}",
176
+ get: "http.request.tls.client.san.dns_names.0",
177
expect: "localhost",
178
},
179
{
163
- input: "{http.request.tls.client.san.dns_names.1}",
164
- expect: "<empty>",
180
+ get: "http.request.tls.client.san.dns_names.1",
181
+ expect: "",
182
},
183
{
167
- input: "{http.request.tls.client.san.ips}",
184
+ get: "http.request.tls.client.san.ips",
185
expect: "[127.0.0.1]",
186
},
187
{
171
- input: "{http.request.tls.client.san.ips.0}",
188
+ get: "http.request.tls.client.san.ips.0",
189
expect: "127.0.0.1",
190
},
191
{
175
- input: "{http.request.tls.client.certificate_pem}",
192
+ get: "http.request.tls.client.certificate_pem",
193
expect: string(clientCert) + "\n", // returned value comes with a newline appended to it
194
},
195
} {
179
- actual := repl.ReplaceAll(tc.input, "<empty>")
196
+ actual, got := repl.GetString(tc.get)
197
+ if !got {
198
+ t.Errorf("Test %d: Expected to recognize the placeholder name, but didn't", i)
199
+ }
200
if actual != tc.expect {
181
- t.Errorf("Test %d: Expected placeholder %s to be '%s' but got '%s'",
182
- i, tc.input, tc.expect, actual)
201
+ t.Errorf("Test %d: Expected %s to be '%s' but got '%s'",
202
+ i, tc.get, tc.expect, actual)
203
}
204
}
205
}