mirror of
https://github.com/caddyserver/caddy.git
synced 2024-12-27 06:03:48 +03:00
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:
parent
ca4fae64d9
commit
d5ea43fb4b
7 changed files with 281 additions and 145 deletions
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
1
modules/caddyhttp/fileserver/testdata/foodir/bar.txt
vendored
Normal file
1
modules/caddyhttp/fileserver/testdata/foodir/bar.txt
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
foodir/bar.txt
|
|
@ -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":
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue