@marcel91200 / caddy Mirror · read-only / commits / d5ea43fb
Fork 0

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 }