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
This commit is contained in:
Matt Holt 2022-09-05 13:53:41 -06:00 committed by GitHub
parent ca4fae64d9
commit d5ea43fb4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 281 additions and 145 deletions

View file

@ -36,17 +36,16 @@ func init() {
// parseCaddyfile parses the file_server directive. It enables the static file // parseCaddyfile parses the file_server directive. It enables the static file
// server and configures it with this syntax: // server and configures it with this syntax:
// //
// file_server [<matcher>] [browse] { // file_server [<matcher>] [browse] {
// fs <backend...> // fs <backend...>
// root <path> // root <path>
// hide <files...> // hide <files...>
// index <files...> // index <files...>
// browse [<template_file>] // browse [<template_file>]
// precompressed <formats...> // precompressed <formats...>
// status <status> // status <status>
// disable_canonical_uris // disable_canonical_uris
// } // }
//
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
var fsrv FileServer var fsrv FileServer
@ -177,22 +176,23 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
// with a rewrite directive, so this is not a standard handler directive. // with a rewrite directive, so this is not a standard handler directive.
// A try_files directive has this syntax (notice no matcher tokens accepted): // A try_files directive has this syntax (notice no matcher tokens accepted):
// //
// try_files <files...> // try_files <files...> {
// policy first_exist|smallest_size|largest_size|most_recently_modified
// }
// //
// and is basically shorthand for: // and is basically shorthand for:
// //
// @try_files { // @try_files file {
// file { // try_files <files...>
// try_files <files...> // policy first_exist|smallest_size|largest_size|most_recently_modified
// } // }
// } // rewrite @try_files {http.matchers.file.relative}
// rewrite @try_files {http.matchers.file.relative}
// //
// This directive rewrites request paths only, preserving any other part // This directive rewrites request paths only, preserving any other part
// of the URI, unless the part is explicitly given in the file list. For // of the URI, unless the part is explicitly given in the file list. For
// example, if any of the files in the list have a query string: // example, if any of the files in the list have a query string:
// //
// try_files {path} index.php?{query}&p={path} // try_files {path} index.php?{query}&p={path}
// //
// then the query string will not be treated as part of the file name; and // then the query string will not be treated as part of the file name; and
// if that file matches, the given query string will replace any query string // 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)
return nil, h.ArgErr() return nil, h.ArgErr()
} }
// parse out the optional try policy
var tryPolicy string
for nesting := h.Nesting(); h.NextBlock(nesting); {
switch h.Val() {
case "policy":
if tryPolicy != "" {
return nil, h.Err("try policy already configured")
}
if !h.NextArg() {
return nil, h.ArgErr()
}
tryPolicy = h.Val()
switch tryPolicy {
case tryPolicyFirstExist, tryPolicyLargestSize, tryPolicySmallestSize, tryPolicyMostRecentlyMod:
default:
return nil, h.Errf("unrecognized try policy: %s", tryPolicy)
}
}
}
// makeRoute returns a route that tries the files listed in try // makeRoute returns a route that tries the files listed in try
// and then rewrites to the matched file; userQueryString is // and then rewrites to the matched file; userQueryString is
// appended to the rewrite rule. // appended to the rewrite rule.
@ -215,7 +236,7 @@ func parseTryFiles(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
URI: "{http.matchers.file.relative}" + userQueryString, URI: "{http.matchers.file.relative}" + userQueryString,
} }
matcherSet := caddy.ModuleMap{ matcherSet := caddy.ModuleMap{
"file": h.JSON(MatchFile{TryFiles: try}), "file": h.JSON(MatchFile{TryFiles: try, TryPolicy: tryPolicy}),
} }
return h.NewRoute(matcherSet, handler) return h.NewRoute(matcherSet, handler)
} }

View file

@ -21,9 +21,10 @@ import (
"net/http" "net/http"
"os" "os"
"path" "path"
"path/filepath"
"runtime"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
@ -33,6 +34,7 @@ import (
"github.com/google/cel-go/common/operators" "github.com/google/cel-go/common/operators"
"github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/parser" "github.com/google/cel-go/parser"
"go.uber.org/zap"
exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1" exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
) )
@ -55,6 +57,9 @@ func init() {
// the matched file is a directory, "file" otherwise. // the matched file is a directory, "file" otherwise.
// - `{http.matchers.file.remainder}` Set to the remainder // - `{http.matchers.file.remainder}` Set to the remainder
// of the path if the path was split by `split_path`. // of the path if the path was split by `split_path`.
//
// Even though file matching may depend on the OS path
// separator, the placeholder values always use /.
type MatchFile struct { type MatchFile struct {
// The file system implementation to use. By default, the // The file system implementation to use. By default, the
// local disk file system will be used. // local disk file system will be used.
@ -101,6 +106,8 @@ type MatchFile struct {
// Each delimiter must appear at the end of a URI path // Each delimiter must appear at the end of a URI path
// component in order to be used as a split delimiter. // component in order to be used as a split delimiter.
SplitPath []string `json:"split_path,omitempty"` SplitPath []string `json:"split_path,omitempty"`
logger *zap.Logger
} }
// CaddyModule returns the Caddy module information. // CaddyModule returns the Caddy module information.
@ -113,12 +120,11 @@ func (MatchFile) CaddyModule() caddy.ModuleInfo {
// UnmarshalCaddyfile sets up the matcher from Caddyfile tokens. Syntax: // UnmarshalCaddyfile sets up the matcher from Caddyfile tokens. Syntax:
// //
// file <files...> { // file <files...> {
// root <path> // root <path>
// try_files <files...> // try_files <files...>
// try_policy first_exist|smallest_size|largest_size|most_recently_modified // try_policy first_exist|smallest_size|largest_size|most_recently_modified
// } // }
//
func (m *MatchFile) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { func (m *MatchFile) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() { for d.Next() {
m.TryFiles = append(m.TryFiles, d.RemainingArgs()...) m.TryFiles = append(m.TryFiles, d.RemainingArgs()...)
@ -156,7 +162,8 @@ func (m *MatchFile) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
// expression matchers. // expression matchers.
// //
// Example: // Example:
// expression file({'root': '/srv', 'try_files': [{http.request.uri.path}, '/index.php'], 'try_policy': 'first_exist', 'split_path': ['.php']}) //
// expression file({'root': '/srv', 'try_files': [{http.request.uri.path}, '/index.php'], 'try_policy': 'first_exist', 'split_path': ['.php']})
func (MatchFile) CELLibrary(ctx caddy.Context) (cel.Library, error) { func (MatchFile) CELLibrary(ctx caddy.Context) (cel.Library, error) {
requestType := cel.ObjectType("http.Request") requestType := cel.ObjectType("http.Request")
@ -249,6 +256,8 @@ func celFileMatcherMacroExpander() parser.MacroExpander {
// Provision sets up m's defaults. // Provision sets up m's defaults.
func (m *MatchFile) Provision(ctx caddy.Context) error { func (m *MatchFile) Provision(ctx caddy.Context) error {
m.logger = ctx.Logger(m)
// establish the file system to use // establish the file system to use
if len(m.FileSystemRaw) > 0 { if len(m.FileSystemRaw) > 0 {
mod, err := ctx.LoadModule(m, "FileSystemRaw") mod, err := ctx.LoadModule(m, "FileSystemRaw")
@ -290,10 +299,10 @@ func (m MatchFile) Validate() error {
// Match returns true if r matches m. Returns true // Match returns true if r matches m. Returns true
// if a file was matched. If so, four placeholders // if a file was matched. If so, four placeholders
// will be available: // will be available:
// - http.matchers.file.relative // - http.matchers.file.relative: Path to file relative to site root
// - http.matchers.file.absolute // - http.matchers.file.absolute: Path to file including site root
// - http.matchers.file.type // - http.matchers.file.type: file or directory
// - http.matchers.file.remainder // - http.matchers.file.remainder: Portion remaining after splitting file path (if configured)
func (m MatchFile) Match(r *http.Request) bool { func (m MatchFile) Match(r *http.Request) bool {
return m.selectFile(r) return m.selectFile(r)
} }
@ -303,23 +312,80 @@ func (m MatchFile) Match(r *http.Request) bool {
func (m MatchFile) selectFile(r *http.Request) (matched bool) { func (m MatchFile) selectFile(r *http.Request) (matched bool) {
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
root := repl.ReplaceAll(m.Root, ".") root := filepath.Clean(repl.ReplaceAll(m.Root, "."))
// common preparation of the file into parts type matchCandidate struct {
prepareFilePath := func(file string) (suffix, fullpath, remainder string) { fullpath, relative, splitRemainder string
suffix, remainder = m.firstSplit(path.Clean(repl.ReplaceAll(file, "")))
if strings.HasSuffix(file, "/") {
suffix += "/"
}
fullpath = caddyhttp.SanitizedPathJoin(root, suffix)
return
} }
// sets up the placeholders for the matched file // makeCandidates evaluates placeholders in file and expands any glob expressions
setPlaceholders := func(info os.FileInfo, rel string, abs string, remainder string) { // to build a list of file candidates. Special glob characters are escaped in
repl.Set("http.matchers.file.relative", rel) // placeholder replacements so globs cannot be expanded from placeholders, and
repl.Set("http.matchers.file.absolute", abs) // globs are not evaluated on Windows because of its path separator character:
repl.Set("http.matchers.file.remainder", remainder) // escaping is not supported so we can't safely glob on Windows, or we can't
// support placeholders on Windows (pick one). (Actually, evaluating untrusted
// globs is not the end of the world since the file server will still hide any
// hidden files, it just might lead to unexpected behavior.)
makeCandidates := func(file string) []matchCandidate {
// first, evaluate placeholders in the file pattern
expandedFile, err := repl.ReplaceFunc(file, func(variable string, val any) (any, error) {
if runtime.GOOS == "windows" {
return val, nil
}
switch v := val.(type) {
case string:
return globSafeRepl.Replace(v), nil
case fmt.Stringer:
return globSafeRepl.Replace(v.String()), nil
}
return val, nil
})
if err != nil {
m.logger.Error("evaluating placeholders", zap.Error(err))
expandedFile = file // "oh well," I guess?
}
// clean the path and split, if configured -- we must split before
// globbing so that the file system doesn't include the remainder
// ("afterSplit") in the filename; be sure to restore trailing slash
beforeSplit, afterSplit := m.firstSplit(path.Clean(expandedFile))
if strings.HasSuffix(file, "/") {
beforeSplit += "/"
}
// create the full path to the file by prepending the site root
fullPattern := caddyhttp.SanitizedPathJoin(root, beforeSplit)
// expand glob expressions, but not on Windows because Glob() doesn't
// support escaping on Windows due to path separator)
var globResults []string
if runtime.GOOS == "windows" {
globResults = []string{fullPattern} // precious Windows
} else {
globResults, err = fs.Glob(m.fileSystem, fullPattern)
if err != nil {
m.logger.Error("expanding glob", zap.Error(err))
}
}
// for each glob result, combine all the forms of the path
var candidates []matchCandidate
for _, result := range globResults {
candidates = append(candidates, matchCandidate{
fullpath: result,
relative: strings.TrimPrefix(result, root),
splitRemainder: afterSplit,
})
}
return candidates
}
// setPlaceholders creates the placeholders for the matched file
setPlaceholders := func(candidate matchCandidate, info fs.FileInfo) {
repl.Set("http.matchers.file.relative", filepath.ToSlash(candidate.relative))
repl.Set("http.matchers.file.absolute", filepath.ToSlash(candidate.fullpath))
repl.Set("http.matchers.file.remainder", filepath.ToSlash(candidate.splitRemainder))
fileType := "file" fileType := "file"
if info.IsDir() { if info.IsDir() {
@ -328,76 +394,83 @@ func (m MatchFile) selectFile(r *http.Request) (matched bool) {
repl.Set("http.matchers.file.type", fileType) repl.Set("http.matchers.file.type", fileType)
} }
// match file according to the configured policy
switch m.TryPolicy { switch m.TryPolicy {
case "", tryPolicyFirstExist: case "", tryPolicyFirstExist:
for _, f := range m.TryFiles { for _, pattern := range m.TryFiles {
if err := parseErrorCode(f); err != nil { if err := parseErrorCode(pattern); err != nil {
caddyhttp.SetVar(r.Context(), caddyhttp.MatcherErrorVarKey, err) caddyhttp.SetVar(r.Context(), caddyhttp.MatcherErrorVarKey, err)
return return
} }
suffix, fullpath, remainder := prepareFilePath(f) candidates := makeCandidates(pattern)
if info, exists := m.strictFileExists(fullpath); exists { for _, c := range candidates {
setPlaceholders(info, suffix, fullpath, remainder) if info, exists := m.strictFileExists(c.fullpath); exists {
return true setPlaceholders(c, info)
return true
}
} }
} }
case tryPolicyLargestSize: case tryPolicyLargestSize:
var largestSize int64 var largestSize int64
var largestFilename string var largest matchCandidate
var largestSuffix string var largestInfo os.FileInfo
var remainder string for _, pattern := range m.TryFiles {
var info os.FileInfo candidates := makeCandidates(pattern)
for _, f := range m.TryFiles { for _, c := range candidates {
suffix, fullpath, splitRemainder := prepareFilePath(f) info, err := m.fileSystem.Stat(c.fullpath)
info, err := m.fileSystem.Stat(fullpath) if err == nil && info.Size() > largestSize {
if err == nil && info.Size() > largestSize { largestSize = info.Size()
largestSize = info.Size() largest = c
largestFilename = fullpath largestInfo = info
largestSuffix = suffix }
remainder = splitRemainder
} }
} }
setPlaceholders(info, largestSuffix, largestFilename, remainder) if largestInfo == nil {
return false
}
setPlaceholders(largest, largestInfo)
return true return true
case tryPolicySmallestSize: case tryPolicySmallestSize:
var smallestSize int64 var smallestSize int64
var smallestFilename string var smallest matchCandidate
var smallestSuffix string var smallestInfo os.FileInfo
var remainder string for _, pattern := range m.TryFiles {
var info os.FileInfo candidates := makeCandidates(pattern)
for _, f := range m.TryFiles { for _, c := range candidates {
suffix, fullpath, splitRemainder := prepareFilePath(f) info, err := m.fileSystem.Stat(c.fullpath)
info, err := m.fileSystem.Stat(fullpath) if err == nil && (smallestSize == 0 || info.Size() < smallestSize) {
if err == nil && (smallestSize == 0 || info.Size() < smallestSize) { smallestSize = info.Size()
smallestSize = info.Size() smallest = c
smallestFilename = fullpath smallestInfo = info
smallestSuffix = suffix }
remainder = splitRemainder
} }
} }
setPlaceholders(info, smallestSuffix, smallestFilename, remainder) if smallestInfo == nil {
return false
}
setPlaceholders(smallest, smallestInfo)
return true return true
case tryPolicyMostRecentlyMod: case tryPolicyMostRecentlyMod:
var recentDate time.Time var recent matchCandidate
var recentFilename string var recentInfo os.FileInfo
var recentSuffix string for _, pattern := range m.TryFiles {
var remainder string candidates := makeCandidates(pattern)
var info os.FileInfo for _, c := range candidates {
for _, f := range m.TryFiles { info, err := m.fileSystem.Stat(c.fullpath)
suffix, fullpath, splitRemainder := prepareFilePath(f) if err == nil &&
info, err := m.fileSystem.Stat(fullpath) (recentInfo == nil || info.ModTime().After(recentInfo.ModTime())) {
if err == nil && recent = c
(recentDate.IsZero() || info.ModTime().After(recentDate)) { recentInfo = info
recentDate = info.ModTime() }
recentFilename = fullpath
recentSuffix = suffix
remainder = splitRemainder
} }
} }
setPlaceholders(info, recentSuffix, recentFilename, remainder) if recentInfo == nil {
return false
}
setPlaceholders(recent, recentInfo)
return true return true
} }
@ -425,7 +498,7 @@ func parseErrorCode(input string) error {
// NOT end in a forward slash, the file must NOT // NOT end in a forward slash, the file must NOT
// be a directory. // be a directory.
func (m MatchFile) strictFileExists(file string) (os.FileInfo, bool) { func (m MatchFile) strictFileExists(file string) (os.FileInfo, bool) {
stat, err := m.fileSystem.Stat(file) info, err := m.fileSystem.Stat(file)
if err != nil { if err != nil {
// in reality, this can be any error // in reality, this can be any error
// such as permission or even obscure // such as permission or even obscure
@ -440,11 +513,11 @@ func (m MatchFile) strictFileExists(file string) (os.FileInfo, bool) {
if strings.HasSuffix(file, separator) { if strings.HasSuffix(file, separator) {
// by convention, file paths ending // by convention, file paths ending
// in a path separator must be a directory // in a path separator must be a directory
return stat, stat.IsDir() return info, info.IsDir()
} }
// by convention, file paths NOT ending // by convention, file paths NOT ending
// in a path separator must NOT be a directory // in a path separator must NOT be a directory
return stat, !stat.IsDir() return info, !info.IsDir()
} }
// firstSplit returns the first result where the path // firstSplit returns the first result where the path
@ -581,6 +654,15 @@ func isCELStringListLiteral(e *exprpb.Expr) bool {
return false return false
} }
// globSafeRepl replaces special glob characters with escaped
// equivalents. Note that the filepath godoc states that
// escaping is not done on Windows because of the separator.
var globSafeRepl = strings.NewReplacer(
"*", "\\*",
"[", "\\[",
"?", "\\?",
)
const ( const (
tryPolicyFirstExist = "first_exist" tryPolicyFirstExist = "first_exist"
tryPolicyLargestSize = "largest_size" tryPolicyLargestSize = "largest_size"

View file

@ -28,7 +28,6 @@ import (
) )
func TestFileMatcher(t *testing.T) { func TestFileMatcher(t *testing.T) {
// Windows doesn't like colons in files names // Windows doesn't like colons in files names
isWindows := runtime.GOOS == "windows" isWindows := runtime.GOOS == "windows"
if !isWindows { if !isWindows {
@ -87,25 +86,25 @@ func TestFileMatcher(t *testing.T) {
}, },
{ {
path: "ملف.txt", // the path file name is not escaped path: "ملف.txt", // the path file name is not escaped
expectedPath: "ملف.txt", expectedPath: "/ملف.txt",
expectedType: "file", expectedType: "file",
matched: true, matched: true,
}, },
{ {
path: url.PathEscape("ملف.txt"), // singly-escaped path path: url.PathEscape("ملف.txt"), // singly-escaped path
expectedPath: "ملف.txt", expectedPath: "/ملف.txt",
expectedType: "file", expectedType: "file",
matched: true, matched: true,
}, },
{ {
path: url.PathEscape(url.PathEscape("ملف.txt")), // doubly-escaped path path: url.PathEscape(url.PathEscape("ملف.txt")), // doubly-escaped path
expectedPath: "%D9%85%D9%84%D9%81.txt", expectedPath: "/%D9%85%D9%84%D9%81.txt",
expectedType: "file", expectedType: "file",
matched: true, matched: true,
}, },
{ {
path: "./with:in-name.txt", // browsers send the request with the path as such path: "./with:in-name.txt", // browsers send the request with the path as such
expectedPath: "with:in-name.txt", expectedPath: "/with:in-name.txt",
expectedType: "file", expectedType: "file",
matched: !isWindows, matched: !isWindows,
}, },
@ -118,7 +117,7 @@ func TestFileMatcher(t *testing.T) {
u, err := url.Parse(tc.path) u, err := url.Parse(tc.path)
if err != nil { if err != nil {
t.Fatalf("Test %d: parsing path: %v", i, err) t.Errorf("Test %d: parsing path: %v", i, err)
} }
req := &http.Request{URL: u} req := &http.Request{URL: u}
@ -126,24 +125,24 @@ func TestFileMatcher(t *testing.T) {
result := m.Match(req) result := m.Match(req)
if result != tc.matched { if result != tc.matched {
t.Fatalf("Test %d: expected match=%t, got %t", i, tc.matched, result) t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result)
} }
rel, ok := repl.Get("http.matchers.file.relative") rel, ok := repl.Get("http.matchers.file.relative")
if !ok && result { if !ok && result {
t.Fatalf("Test %d: expected replacer value", i) t.Errorf("Test %d: expected replacer value", i)
} }
if !result { if !result {
continue continue
} }
if rel != tc.expectedPath { if rel != tc.expectedPath {
t.Fatalf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath) t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
} }
fileType, _ := repl.Get("http.matchers.file.type") fileType, _ := repl.Get("http.matchers.file.type")
if fileType != tc.expectedType { if fileType != tc.expectedType {
t.Fatalf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType) t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
} }
} }
} }
@ -222,7 +221,7 @@ func TestPHPFileMatcher(t *testing.T) {
u, err := url.Parse(tc.path) u, err := url.Parse(tc.path)
if err != nil { if err != nil {
t.Fatalf("Test %d: parsing path: %v", i, err) t.Errorf("Test %d: parsing path: %v", i, err)
} }
req := &http.Request{URL: u} req := &http.Request{URL: u}
@ -230,24 +229,24 @@ func TestPHPFileMatcher(t *testing.T) {
result := m.Match(req) result := m.Match(req)
if result != tc.matched { if result != tc.matched {
t.Fatalf("Test %d: expected match=%t, got %t", i, tc.matched, result) t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result)
} }
rel, ok := repl.Get("http.matchers.file.relative") rel, ok := repl.Get("http.matchers.file.relative")
if !ok && result { if !ok && result {
t.Fatalf("Test %d: expected replacer value", i) t.Errorf("Test %d: expected replacer value", i)
} }
if !result { if !result {
continue continue
} }
if rel != tc.expectedPath { if rel != tc.expectedPath {
t.Fatalf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath) t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
} }
fileType, _ := repl.Get("http.matchers.file.type") fileType, _ := repl.Get("http.matchers.file.type")
if fileType != tc.expectedType { if fileType != tc.expectedType {
t.Fatalf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType) t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
} }
} }
} }

View file

@ -618,10 +618,15 @@ func (wr statusOverrideResponseWriter) WriteHeader(int) {
// rooting or path prefixing without being constrained to a single // rooting or path prefixing without being constrained to a single
// root folder. The standard os.DirFS implementation is problematic // root folder. The standard os.DirFS implementation is problematic
// since roots can be dynamic in our application.) // since roots can be dynamic in our application.)
//
// osFS also implements fs.GlobFS, fs.ReadDirFS, and fs.ReadFileFS.
type osFS struct{} type osFS struct{}
func (osFS) Open(name string) (fs.File, error) { return os.Open(name) } func (osFS) Open(name string) (fs.File, error) { return os.Open(name) }
func (osFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) } func (osFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }
func (osFS) Glob(pattern string) ([]string, error) { return filepath.Glob(pattern) }
func (osFS) ReadDir(name string) ([]fs.DirEntry, error) { return os.ReadDir(name) }
func (osFS) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }
var defaultIndexNames = []string{"index.html", "index.txt"} var defaultIndexNames = []string{"index.html", "index.txt"}
@ -634,4 +639,8 @@ const (
var ( var (
_ caddy.Provisioner = (*FileServer)(nil) _ caddy.Provisioner = (*FileServer)(nil)
_ caddyhttp.MiddlewareHandler = (*FileServer)(nil) _ caddyhttp.MiddlewareHandler = (*FileServer)(nil)
_ fs.GlobFS = (*osFS)(nil)
_ fs.ReadDirFS = (*osFS)(nil)
_ fs.ReadFileFS = (*osFS)(nil)
) )

View file

@ -0,0 +1 @@
foodir/bar.txt

View file

@ -143,6 +143,10 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
case "http.request.uri.path.dir": case "http.request.uri.path.dir":
dir, _ := path.Split(req.URL.Path) dir, _ := path.Split(req.URL.Path)
return dir, true return dir, true
case "http.request.uri.path.file.base":
return strings.TrimSuffix(path.Base(req.URL.Path), path.Ext(req.URL.Path)), true
case "http.request.uri.path.file.ext":
return path.Ext(req.URL.Path), true
case "http.request.uri.query": case "http.request.uri.query":
return req.URL.RawQuery, true return req.URL.RawQuery, true
case "http.request.duration": case "http.request.duration":

View file

@ -27,7 +27,7 @@ import (
) )
func TestHTTPVarReplacement(t *testing.T) { func TestHTTPVarReplacement(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil) req, _ := http.NewRequest(http.MethodGet, "/foo/bar.tar.gz", nil)
repl := caddy.NewReplacer() repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl) ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx) req = req.WithContext(ctx)
@ -72,114 +72,134 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
addHTTPVarsToReplacer(repl, req, res) addHTTPVarsToReplacer(repl, req, res)
for i, tc := range []struct { for i, tc := range []struct {
input string get string
expect string expect string
}{ }{
{ {
input: "{http.request.scheme}", get: "http.request.scheme",
expect: "https", expect: "https",
}, },
{ {
input: "{http.request.host}", get: "http.request.method",
expect: http.MethodGet,
},
{
get: "http.request.host",
expect: "example.com", expect: "example.com",
}, },
{ {
input: "{http.request.port}", get: "http.request.port",
expect: "80", expect: "80",
}, },
{ {
input: "{http.request.hostport}", get: "http.request.hostport",
expect: "example.com:80", expect: "example.com:80",
}, },
{ {
input: "{http.request.remote.host}", get: "http.request.remote.host",
expect: "localhost", expect: "localhost",
}, },
{ {
input: "{http.request.remote.port}", get: "http.request.remote.port",
expect: "1234", expect: "1234",
}, },
{ {
input: "{http.request.host.labels.0}", get: "http.request.host.labels.0",
expect: "com", expect: "com",
}, },
{ {
input: "{http.request.host.labels.1}", get: "http.request.host.labels.1",
expect: "example", expect: "example",
}, },
{ {
input: "{http.request.host.labels.2}", get: "http.request.host.labels.2",
expect: "<empty>", expect: "",
}, },
{ {
input: "{http.request.tls.cipher_suite}", get: "http.request.uri.path.file",
expect: "bar.tar.gz",
},
{
get: "http.request.uri.path.file.base",
expect: "bar.tar",
},
{
// not ideal, but also most correct, given that files can have dots (example: index.<SHA>.html) TODO: maybe this isn't right..
get: "http.request.uri.path.file.ext",
expect: ".gz",
},
{
get: "http.request.tls.cipher_suite",
expect: "TLS_AES_256_GCM_SHA384", expect: "TLS_AES_256_GCM_SHA384",
}, },
{ {
input: "{http.request.tls.proto}", get: "http.request.tls.proto",
expect: "h2", expect: "h2",
}, },
{ {
input: "{http.request.tls.proto_mutual}", get: "http.request.tls.proto_mutual",
expect: "true", expect: "true",
}, },
{ {
input: "{http.request.tls.resumed}", get: "http.request.tls.resumed",
expect: "false", expect: "false",
}, },
{ {
input: "{http.request.tls.server_name}", get: "http.request.tls.server_name",
expect: "foo.com", expect: "foo.com",
}, },
{ {
input: "{http.request.tls.version}", get: "http.request.tls.version",
expect: "tls1.3", expect: "tls1.3",
}, },
{ {
input: "{http.request.tls.client.fingerprint}", get: "http.request.tls.client.fingerprint",
expect: "9f57b7b497cceacc5459b76ac1c3afedbc12b300e728071f55f84168ff0f7702", expect: "9f57b7b497cceacc5459b76ac1c3afedbc12b300e728071f55f84168ff0f7702",
}, },
{ {
input: "{http.request.tls.client.issuer}", get: "http.request.tls.client.issuer",
expect: "CN=Caddy Test CA", expect: "CN=Caddy Test CA",
}, },
{ {
input: "{http.request.tls.client.serial}", get: "http.request.tls.client.serial",
expect: "2", expect: "2",
}, },
{ {
input: "{http.request.tls.client.subject}", get: "http.request.tls.client.subject",
expect: "CN=client.localdomain", expect: "CN=client.localdomain",
}, },
{ {
input: "{http.request.tls.client.san.dns_names}", get: "http.request.tls.client.san.dns_names",
expect: "[localhost]", expect: "[localhost]",
}, },
{ {
input: "{http.request.tls.client.san.dns_names.0}", get: "http.request.tls.client.san.dns_names.0",
expect: "localhost", expect: "localhost",
}, },
{ {
input: "{http.request.tls.client.san.dns_names.1}", get: "http.request.tls.client.san.dns_names.1",
expect: "<empty>", expect: "",
}, },
{ {
input: "{http.request.tls.client.san.ips}", get: "http.request.tls.client.san.ips",
expect: "[127.0.0.1]", expect: "[127.0.0.1]",
}, },
{ {
input: "{http.request.tls.client.san.ips.0}", get: "http.request.tls.client.san.ips.0",
expect: "127.0.0.1", expect: "127.0.0.1",
}, },
{ {
input: "{http.request.tls.client.certificate_pem}", get: "http.request.tls.client.certificate_pem",
expect: string(clientCert) + "\n", // returned value comes with a newline appended to it expect: string(clientCert) + "\n", // returned value comes with a newline appended to it
}, },
} { } {
actual := repl.ReplaceAll(tc.input, "<empty>") actual, got := repl.GetString(tc.get)
if !got {
t.Errorf("Test %d: Expected to recognize the placeholder name, but didn't", i)
}
if actual != tc.expect { if actual != tc.expect {
t.Errorf("Test %d: Expected placeholder %s to be '%s' but got '%s'", t.Errorf("Test %d: Expected %s to be '%s' but got '%s'",
i, tc.input, tc.expect, actual) i, tc.get, tc.expect, actual)
} }
} }
} }