Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 01:00 UTC
| 1 | // Copyright 2015 Matthew Holt and The Caddy Authors |
| 2 | // |
| 3 | // Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | // you may not use this file except in compliance with the License. |
| 5 | // You may obtain a copy of the License at |
| 6 | // |
| 7 | // http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | // |
| 9 | // Unless required by applicable law or agreed to in writing, software |
| 10 | // distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | // See the License for the specific language governing permissions and |
| 13 | // limitations under the License. |
| 14 | |
| 15 | package fileserver |
| 16 | |
| 17 | import ( |
| 18 | "fmt" |
| 19 | "io/fs" |
| 20 | "net/http" |
| 21 | "os" |
| 22 | "path/filepath" |
| 23 | "runtime" |
| 24 | "strconv" |
| 25 | "strings" |
| 26 | |
| 27 | "cel.dev/cel-go/cel" |
| 28 | "cel.dev/cel-go/common" |
| 29 | "cel.dev/cel-go/common/ast" |
| 30 | "cel.dev/cel-go/common/operators" |
| 31 | "cel.dev/cel-go/common/types" |
| 32 | "cel.dev/cel-go/common/types/ref" |
| 33 | "cel.dev/cel-go/parser" |
| 34 | "go.uber.org/zap" |
| 35 | "go.uber.org/zap/zapcore" |
| 36 | |
| 37 | "github.com/caddyserver/caddy/v2" |
| 38 | "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" |
| 39 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" |
| 40 | ) |
| 41 | |
| 42 | func init() { |
| 43 | caddy.RegisterModule(MatchFile{}) |
| 44 | } |
| 45 | |
| 46 | // MatchFile is an HTTP request matcher that can match |
| 47 | // requests based upon file existence. |
| 48 | // |
| 49 | // Upon matching, three new placeholders will be made |
| 50 | // available: |
| 51 | // |
| 52 | // - `{http.matchers.file.relative}` The root-relative |
| 53 | // path of the file. This is often useful when rewriting |
| 54 | // requests. |
| 55 | // - `{http.matchers.file.absolute}` The absolute path |
| 56 | // of the matched file. |
| 57 | // - `{http.matchers.file.type}` Set to "directory" if |
| 58 | // the matched file is a directory, "file" otherwise. |
| 59 | // - `{http.matchers.file.remainder}` Set to the remainder |
| 60 | // of the path if the path was split by `split_path`. |
| 61 | // |
| 62 | // Even though file matching may depend on the OS path |
| 63 | // separator, the placeholder values always use /. |
| 64 | type MatchFile struct { |
| 65 | // The file system implementation to use. By default, the |
| 66 | // local disk file system will be used. |
| 67 | FileSystem string `json:"fs,omitempty"` |
| 68 | |
| 69 | // The root directory, used for creating absolute |
| 70 | // file paths, and required when working with |
| 71 | // relative paths; if not specified, `{http.vars.root}` |
| 72 | // will be used, if set; otherwise, the current |
| 73 | // directory is assumed. Accepts placeholders. |
| 74 | Root string `json:"root,omitempty"` |
| 75 | |
| 76 | // The list of files to try. Each path here is |
| 77 | // considered related to Root. If nil, the request |
| 78 | // URL's path will be assumed. Files and |
| 79 | // directories are treated distinctly, so to match |
| 80 | // a directory, the filepath MUST end in a forward |
| 81 | // slash `/`. To match a regular file, there must |
| 82 | // be no trailing slash. Accepts placeholders. If |
| 83 | // the policy is "first_exist", then an error may |
| 84 | // be triggered as a fallback by configuring "=" |
| 85 | // followed by a status code number, |
| 86 | // for example "=404". |
| 87 | TryFiles []string `json:"try_files,omitempty"` |
| 88 | |
| 89 | // How to choose a file in TryFiles. Can be: |
| 90 | // |
| 91 | // - first_exist |
| 92 | // - first_exist_fallback |
| 93 | // - smallest_size |
| 94 | // - largest_size |
| 95 | // - most_recently_modified |
| 96 | // |
| 97 | // Default is first_exist. |
| 98 | TryPolicy string `json:"try_policy,omitempty"` |
| 99 | |
| 100 | // A list of delimiters to use to split the path in two |
| 101 | // when trying files. If empty, no splitting will |
| 102 | // occur, and the path will be tried as-is. For each |
| 103 | // split value, the left-hand side of the split, |
| 104 | // including the split value, will be the path tried. |
| 105 | // For example, the path `/remote.php/dav/` using the |
| 106 | // split value `.php` would try the file `/remote.php`. |
| 107 | // Each delimiter must appear at the end of a URI path |
| 108 | // component in order to be used as a split delimiter. |
| 109 | SplitPath []string `json:"split_path,omitempty"` |
| 110 | |
| 111 | fsmap caddy.FileSystems |
| 112 | |
| 113 | logger *zap.Logger |
| 114 | } |
| 115 | |
| 116 | // CaddyModule returns the Caddy module information. |
| 117 | func (MatchFile) CaddyModule() caddy.ModuleInfo { |
| 118 | return caddy.ModuleInfo{ |
| 119 | ID: "http.matchers.file", |
| 120 | New: func() caddy.Module { return new(MatchFile) }, |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // UnmarshalCaddyfile sets up the matcher from Caddyfile tokens. Syntax: |
| 125 | // |
| 126 | // file <files...> { |
| 127 | // root <path> |
| 128 | // try_files <files...> |
| 129 | // try_policy first_exist|smallest_size|largest_size|most_recently_modified |
| 130 | // } |
| 131 | func (m *MatchFile) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { |
| 132 | // iterate to merge multiple matchers into one |
| 133 | for d.Next() { |
| 134 | m.TryFiles = append(m.TryFiles, d.RemainingArgs()...) |
| 135 | for d.NextBlock(0) { |
| 136 | switch d.Val() { |
| 137 | case "root": |
| 138 | if !d.NextArg() { |
| 139 | return d.ArgErr() |
| 140 | } |
| 141 | m.Root = d.Val() |
| 142 | case "try_files": |
| 143 | m.TryFiles = append(m.TryFiles, d.RemainingArgs()...) |
| 144 | if len(m.TryFiles) == 0 { |
| 145 | return d.ArgErr() |
| 146 | } |
| 147 | case "try_policy": |
| 148 | if !d.NextArg() { |
| 149 | return d.ArgErr() |
| 150 | } |
| 151 | m.TryPolicy = d.Val() |
| 152 | case "split_path": |
| 153 | m.SplitPath = d.RemainingArgs() |
| 154 | if len(m.SplitPath) == 0 { |
| 155 | return d.ArgErr() |
| 156 | } |
| 157 | default: |
| 158 | return d.Errf("unrecognized subdirective: %s", d.Val()) |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | return nil |
| 163 | } |
| 164 | |
| 165 | // CELLibrary produces options that expose this matcher for use in CEL |
| 166 | // expression matchers. |
| 167 | // |
| 168 | // Example: |
| 169 | // |
| 170 | // expression file() |
| 171 | // expression file({http.request.uri.path}, '/index.php') |
| 172 | // expression file({'root': '/srv', 'try_files': [{http.request.uri.path}, '/index.php'], 'try_policy': 'first_exist', 'split_path': ['.php']}) |
| 173 | func (MatchFile) CELLibrary(ctx caddy.Context) (cel.Library, error) { |
| 174 | requestType := cel.ObjectType("http.Request") |
| 175 | |
| 176 | matcherFactory := func(data ref.Val) (caddyhttp.RequestMatcherWithError, error) { |
| 177 | values, err := caddyhttp.CELValueToMapStrList(data) |
| 178 | if err != nil { |
| 179 | return nil, err |
| 180 | } |
| 181 | |
| 182 | var root string |
| 183 | if len(values["root"]) > 0 { |
| 184 | root = values["root"][0] |
| 185 | } |
| 186 | |
| 187 | var fsName string |
| 188 | if len(values["fs"]) > 0 { |
| 189 | fsName = values["fs"][0] |
| 190 | } |
| 191 | |
| 192 | var try_policy string |
| 193 | if len(values["try_policy"]) > 0 { |
| 194 | try_policy = values["try_policy"][0] |
| 195 | } |
| 196 | |
| 197 | m := MatchFile{ |
| 198 | Root: root, |
| 199 | TryFiles: values["try_files"], |
| 200 | TryPolicy: try_policy, |
| 201 | SplitPath: values["split_path"], |
| 202 | FileSystem: fsName, |
| 203 | } |
| 204 | |
| 205 | err = m.Provision(ctx) |
| 206 | return m, err |
| 207 | } |
| 208 | |
| 209 | envOptions := []cel.EnvOption{ |
| 210 | cel.Macros(parser.NewGlobalVarArgMacro("file", celFileMatcherMacroExpander())), |
| 211 | cel.Function("file", cel.Overload("file_request_map", []*cel.Type{requestType, caddyhttp.CELTypeJSON}, cel.BoolType)), |
| 212 | cel.Function("file_request_map", |
| 213 | cel.Overload("file_request_map", []*cel.Type{requestType, caddyhttp.CELTypeJSON}, cel.BoolType), |
| 214 | cel.SingletonBinaryBinding(caddyhttp.CELMatcherRuntimeFunction("file_request_map", matcherFactory))), |
| 215 | } |
| 216 | |
| 217 | programOptions := []cel.ProgramOption{ |
| 218 | cel.CustomDecorator(caddyhttp.CELMatcherDecorator("file_request_map", matcherFactory)), |
| 219 | } |
| 220 | |
| 221 | return caddyhttp.NewMatcherCELLibrary(envOptions, programOptions), nil |
| 222 | } |
| 223 | |
| 224 | func celFileMatcherMacroExpander() parser.MacroExpander { |
| 225 | return func(eh parser.ExprHelper, target ast.Expr, args []ast.Expr) (ast.Expr, *common.Error) { |
| 226 | if len(args) == 0 { |
| 227 | return eh.NewCall("file", |
| 228 | eh.NewIdent(caddyhttp.CELRequestVarName), |
| 229 | eh.NewMap(), |
| 230 | ), nil |
| 231 | } |
| 232 | if len(args) == 1 { |
| 233 | arg := args[0] |
| 234 | if isCELStringLiteral(arg) || isCELCaddyPlaceholderCall(arg) { |
| 235 | return eh.NewCall("file", |
| 236 | eh.NewIdent(caddyhttp.CELRequestVarName), |
| 237 | eh.NewMap(eh.NewMapEntry( |
| 238 | eh.NewLiteral(types.String("try_files")), |
| 239 | eh.NewList(arg), |
| 240 | false, |
| 241 | )), |
| 242 | ), nil |
| 243 | } |
| 244 | if isCELTryFilesLiteral(arg) { |
| 245 | return eh.NewCall("file", eh.NewIdent(caddyhttp.CELRequestVarName), arg), nil |
| 246 | } |
| 247 | return nil, &common.Error{ |
| 248 | Location: eh.OffsetLocation(arg.ID()), |
| 249 | Message: "matcher requires either a map or string literal argument", |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | for _, arg := range args { |
| 254 | if !isCELStringLiteral(arg) && !isCELCaddyPlaceholderCall(arg) { |
| 255 | return nil, &common.Error{ |
| 256 | Location: eh.OffsetLocation(arg.ID()), |
| 257 | Message: "matcher only supports repeated string literal arguments", |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | return eh.NewCall("file", |
| 262 | eh.NewIdent(caddyhttp.CELRequestVarName), |
| 263 | eh.NewMap(eh.NewMapEntry( |
| 264 | eh.NewLiteral(types.String("try_files")), |
| 265 | eh.NewList(args...), |
| 266 | false, |
| 267 | )), |
| 268 | ), nil |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | // Provision sets up m's defaults. |
| 273 | func (m *MatchFile) Provision(ctx caddy.Context) error { |
| 274 | m.logger = ctx.Logger() |
| 275 | |
| 276 | m.fsmap = ctx.FileSystems() |
| 277 | |
| 278 | if m.Root == "" { |
| 279 | m.Root = "{http.vars.root}" |
| 280 | } |
| 281 | |
| 282 | if m.FileSystem == "" { |
| 283 | m.FileSystem = "{http.vars.fs}" |
| 284 | } |
| 285 | |
| 286 | // if list of files to try was omitted entirely, assume URL path |
| 287 | // (use placeholder instead of r.URL.Path; see issue #4146) |
| 288 | if m.TryFiles == nil { |
| 289 | m.TryFiles = []string{"{http.request.uri.path}"} |
| 290 | } |
| 291 | return nil |
| 292 | } |
| 293 | |
| 294 | // Validate ensures m has a valid configuration. |
| 295 | func (m MatchFile) Validate() error { |
| 296 | switch m.TryPolicy { |
| 297 | case "", |
| 298 | tryPolicyFirstExist, |
| 299 | tryPolicyFirstExistFallback, |
| 300 | tryPolicyLargestSize, |
| 301 | tryPolicySmallestSize, |
| 302 | tryPolicyMostRecentlyMod: |
| 303 | default: |
| 304 | return fmt.Errorf("unknown try policy %s", m.TryPolicy) |
| 305 | } |
| 306 | return nil |
| 307 | } |
| 308 | |
| 309 | // Match returns true if r matches m. Returns true |
| 310 | // if a file was matched. If so, four placeholders |
| 311 | // will be available: |
| 312 | // - http.matchers.file.relative: Path to file relative to site root |
| 313 | // - http.matchers.file.absolute: Path to file including site root |
| 314 | // - http.matchers.file.type: file or directory |
| 315 | // - http.matchers.file.remainder: Portion remaining after splitting file path (if configured) |
| 316 | func (m MatchFile) Match(r *http.Request) bool { |
| 317 | match, err := m.selectFile(r) |
| 318 | if err != nil { |
| 319 | // nolint:staticcheck |
| 320 | caddyhttp.SetVar(r.Context(), caddyhttp.MatcherErrorVarKey, err) |
| 321 | } |
| 322 | return match |
| 323 | } |
| 324 | |
| 325 | // MatchWithError returns true if r matches m. |
| 326 | func (m MatchFile) MatchWithError(r *http.Request) (bool, error) { |
| 327 | return m.selectFile(r) |
| 328 | } |
| 329 | |
| 330 | // selectFile chooses a file according to m.TryPolicy by appending |
| 331 | // the paths in m.TryFiles to m.Root, with placeholder replacements. |
| 332 | func (m MatchFile) selectFile(r *http.Request) (bool, error) { |
| 333 | repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) |
| 334 | |
| 335 | root := filepath.Clean(repl.ReplaceAll(m.Root, ".")) |
| 336 | |
| 337 | fsName := repl.ReplaceAll(m.FileSystem, "") |
| 338 | |
| 339 | fileSystem, ok := m.fsmap.Get(fsName) |
| 340 | if !ok { |
| 341 | if c := m.logger.Check(zapcore.ErrorLevel, "use of unregistered filesystem"); c != nil { |
| 342 | c.Write(zap.String("fs", fsName)) |
| 343 | } |
| 344 | return false, nil |
| 345 | } |
| 346 | type matchCandidate struct { |
| 347 | fullpath, relative, splitRemainder string |
| 348 | } |
| 349 | |
| 350 | // makeCandidates evaluates placeholders in file and expands any glob expressions |
| 351 | // to build a list of file candidates. Special glob characters are escaped in |
| 352 | // placeholder replacements so globs cannot be expanded from placeholders, and |
| 353 | // globs are not evaluated on Windows because of its path separator character: |
| 354 | // escaping is not supported so we can't safely glob on Windows, or we can't |
| 355 | // support placeholders on Windows (pick one). (Actually, evaluating untrusted |
| 356 | // globs is not the end of the world since the file server will still hide any |
| 357 | // hidden files, it just might lead to unexpected behavior.) |
| 358 | makeCandidates := func(file string) []matchCandidate { |
| 359 | // first, evaluate placeholders in the file pattern |
| 360 | expandedFile, err := repl.ReplaceFunc(file, func(variable string, val any) (any, error) { |
| 361 | if runtime.GOOS == "windows" { |
| 362 | return val, nil |
| 363 | } |
| 364 | switch v := val.(type) { |
| 365 | case string: |
| 366 | return globSafeRepl.Replace(v), nil |
| 367 | case fmt.Stringer: |
| 368 | return globSafeRepl.Replace(v.String()), nil |
| 369 | } |
| 370 | return val, nil |
| 371 | }) |
| 372 | if err != nil { |
| 373 | if c := m.logger.Check(zapcore.ErrorLevel, "evaluating placeholders"); c != nil { |
| 374 | c.Write(zap.Error(err)) |
| 375 | } |
| 376 | |
| 377 | expandedFile = file // "oh well," I guess? |
| 378 | } |
| 379 | |
| 380 | // we must split before globbing so that the file system |
| 381 | // doesn't include the remainder ("afterSplit") in the filename |
| 382 | beforeSplit, afterSplit, ok := m.cleanSplit(file, expandedFile) |
| 383 | if !ok { |
| 384 | return nil |
| 385 | } |
| 386 | |
| 387 | // create the full path to the file by prepending the site root |
| 388 | fullPattern := caddyhttp.SanitizedPathJoin(root, beforeSplit) |
| 389 | |
| 390 | // expand glob expressions, but not on Windows because Glob() doesn't |
| 391 | // support escaping on Windows due to path separator) |
| 392 | var globResults []string |
| 393 | if runtime.GOOS == "windows" { |
| 394 | globResults = []string{fullPattern} // precious Windows |
| 395 | } else { |
| 396 | globResults, err = fs.Glob(fileSystem, fullPattern) |
| 397 | if err != nil { |
| 398 | if c := m.logger.Check(zapcore.ErrorLevel, "expanding glob"); c != nil { |
| 399 | c.Write(zap.Error(err)) |
| 400 | } |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | // for each glob result, combine all the forms of the path |
| 405 | candidates := make([]matchCandidate, 0, len(globResults)) |
| 406 | for _, result := range globResults { |
| 407 | candidates = append(candidates, matchCandidate{ |
| 408 | fullpath: result, |
| 409 | relative: strings.TrimPrefix(result, root), |
| 410 | splitRemainder: afterSplit, |
| 411 | }) |
| 412 | } |
| 413 | |
| 414 | return candidates |
| 415 | } |
| 416 | |
| 417 | // makeLiteralCandidate builds a candidate from file as a literal path, |
| 418 | // without expanding globs or checking that the file exists. It is used only |
| 419 | // for the final entry of first_exist_fallback, which matches unconditionally |
| 420 | // even when globbing found nothing because the file is absent. |
| 421 | makeLiteralCandidate := func(file string) (matchCandidate, bool) { |
| 422 | // placeholders are not glob-escaped here, since nothing is globbed; |
| 423 | // errors were already logged by makeCandidates for the same pattern |
| 424 | expandedFile, err := repl.ReplaceFunc(file, func(_ string, val any) (any, error) { |
| 425 | return val, nil |
| 426 | }) |
| 427 | if err != nil { |
| 428 | expandedFile = file |
| 429 | } |
| 430 | beforeSplit, afterSplit, ok := m.cleanSplit(file, expandedFile) |
| 431 | if !ok { |
| 432 | return matchCandidate{}, false |
| 433 | } |
| 434 | fullPath := caddyhttp.SanitizedPathJoin(root, beforeSplit) |
| 435 | return matchCandidate{ |
| 436 | fullpath: fullPath, |
| 437 | relative: strings.TrimPrefix(fullPath, root), |
| 438 | splitRemainder: afterSplit, |
| 439 | }, true |
| 440 | } |
| 441 | |
| 442 | // setPlaceholders creates the placeholders for the matched file |
| 443 | setPlaceholders := func(candidate matchCandidate, isDir bool) { |
| 444 | repl.Set("http.matchers.file.relative", filepath.ToSlash(candidate.relative)) |
| 445 | repl.Set("http.matchers.file.absolute", filepath.ToSlash(candidate.fullpath)) |
| 446 | repl.Set("http.matchers.file.remainder", filepath.ToSlash(candidate.splitRemainder)) |
| 447 | |
| 448 | fileType := "file" |
| 449 | if isDir { |
| 450 | fileType = "directory" |
| 451 | } |
| 452 | repl.Set("http.matchers.file.type", fileType) |
| 453 | } |
| 454 | |
| 455 | // match file according to the configured policy |
| 456 | switch m.TryPolicy { |
| 457 | case "", tryPolicyFirstExist, tryPolicyFirstExistFallback: |
| 458 | maxI := -1 |
| 459 | if m.TryPolicy == tryPolicyFirstExistFallback { |
| 460 | maxI = len(m.TryFiles) - 1 |
| 461 | } |
| 462 | |
| 463 | for i, pattern := range m.TryFiles { |
| 464 | // If the pattern is a status code, emit an error, |
| 465 | // which short-circuits the middleware pipeline and |
| 466 | // writes an HTTP error response. |
| 467 | if err := parseErrorCode(pattern); err != nil { |
| 468 | return false, err |
| 469 | } |
| 470 | |
| 471 | candidates := makeCandidates(pattern) |
| 472 | // globbing finds only files that exist, but the fallback entry matches anyway |
| 473 | if i == maxI && len(candidates) == 0 { |
| 474 | if c, ok := makeLiteralCandidate(pattern); ok { |
| 475 | candidates = []matchCandidate{c} |
| 476 | } |
| 477 | } |
| 478 | for _, c := range candidates { |
| 479 | // Skip the IO if using fallback policy and it's the latest item |
| 480 | if i == maxI { |
| 481 | setPlaceholders(c, false) |
| 482 | |
| 483 | return true, nil |
| 484 | } |
| 485 | |
| 486 | if info, exists := m.strictFileExists(fileSystem, c.fullpath); exists { |
| 487 | setPlaceholders(c, info.IsDir()) |
| 488 | return true, nil |
| 489 | } |
| 490 | } |
| 491 | } |
| 492 | |
| 493 | case tryPolicyLargestSize: |
| 494 | var largestSize int64 |
| 495 | var largest matchCandidate |
| 496 | var largestInfo os.FileInfo |
| 497 | for _, pattern := range m.TryFiles { |
| 498 | candidates := makeCandidates(pattern) |
| 499 | for _, c := range candidates { |
| 500 | info, err := fs.Stat(fileSystem, c.fullpath) |
| 501 | if err == nil && (largestInfo == nil || info.Size() > largestSize) { |
| 502 | largestSize = info.Size() |
| 503 | largest = c |
| 504 | largestInfo = info |
| 505 | } |
| 506 | } |
| 507 | } |
| 508 | if largestInfo == nil { |
| 509 | return false, nil |
| 510 | } |
| 511 | setPlaceholders(largest, largestInfo.IsDir()) |
| 512 | return true, nil |
| 513 | |
| 514 | case tryPolicySmallestSize: |
| 515 | var smallestSize int64 |
| 516 | var smallest matchCandidate |
| 517 | var smallestInfo os.FileInfo |
| 518 | for _, pattern := range m.TryFiles { |
| 519 | candidates := makeCandidates(pattern) |
| 520 | for _, c := range candidates { |
| 521 | info, err := fs.Stat(fileSystem, c.fullpath) |
| 522 | if err == nil && (smallestInfo == nil || info.Size() < smallestSize) { |
| 523 | smallestSize = info.Size() |
| 524 | smallest = c |
| 525 | smallestInfo = info |
| 526 | } |
| 527 | } |
| 528 | } |
| 529 | if smallestInfo == nil { |
| 530 | return false, nil |
| 531 | } |
| 532 | setPlaceholders(smallest, smallestInfo.IsDir()) |
| 533 | return true, nil |
| 534 | |
| 535 | case tryPolicyMostRecentlyMod: |
| 536 | var recent matchCandidate |
| 537 | var recentInfo os.FileInfo |
| 538 | for _, pattern := range m.TryFiles { |
| 539 | candidates := makeCandidates(pattern) |
| 540 | for _, c := range candidates { |
| 541 | info, err := fs.Stat(fileSystem, c.fullpath) |
| 542 | if err == nil && |
| 543 | (recentInfo == nil || info.ModTime().After(recentInfo.ModTime())) { |
| 544 | recent = c |
| 545 | recentInfo = info |
| 546 | } |
| 547 | } |
| 548 | } |
| 549 | if recentInfo == nil { |
| 550 | return false, nil |
| 551 | } |
| 552 | setPlaceholders(recent, recentInfo.IsDir()) |
| 553 | return true, nil |
| 554 | } |
| 555 | |
| 556 | return false, nil |
| 557 | } |
| 558 | |
| 559 | // parseErrorCode checks if the input is a status |
| 560 | // code number, prefixed by "=", and returns an |
| 561 | // error if so. |
| 562 | func parseErrorCode(input string) error { |
| 563 | if len(input) > 1 && input[0] == '=' { |
| 564 | code, err := strconv.Atoi(input[1:]) |
| 565 | if err != nil || code < 100 || code > 999 { |
| 566 | return nil |
| 567 | } |
| 568 | return caddyhttp.Error(code, fmt.Errorf("%s", input[1:])) |
| 569 | } |
| 570 | return nil |
| 571 | } |
| 572 | |
| 573 | // strictFileExists returns true if file exists |
| 574 | // and matches the convention of the given file |
| 575 | // path. If the path ends in a forward slash, |
| 576 | // the file must also be a directory; if it does |
| 577 | // NOT end in a forward slash, the file must NOT |
| 578 | // be a directory. |
| 579 | func (m MatchFile) strictFileExists(fileSystem fs.FS, file string) (os.FileInfo, bool) { |
| 580 | info, err := fs.Stat(fileSystem, file) |
| 581 | if err != nil { |
| 582 | // in reality, this can be any error |
| 583 | // such as permission or even obscure |
| 584 | // ones like "is not a directory" (when |
| 585 | // trying to stat a file within a file); |
| 586 | // in those cases we can't be sure if |
| 587 | // the file exists, so we just treat any |
| 588 | // error as if it does not exist; see |
| 589 | // https://stackoverflow.com/a/12518877/1048862 |
| 590 | return nil, false |
| 591 | } |
| 592 | if strings.HasSuffix(file, separator) { |
| 593 | // by convention, file paths ending |
| 594 | // in a path separator must be a directory |
| 595 | return info, info.IsDir() |
| 596 | } |
| 597 | // by convention, file paths NOT ending |
| 598 | // in a path separator must NOT be a directory |
| 599 | return info, !info.IsDir() |
| 600 | } |
| 601 | |
| 602 | // cleanSplit cleans expanded, the expansion of pattern, and splits it if |
| 603 | // configured. It keeps the trailing slash so that "/file/" or "/file/./" |
| 604 | // cannot match a regular file, and reports false for a path ending in "/" |
| 605 | // unless pattern does too, since only such patterns match a directory. |
| 606 | func (m MatchFile) cleanSplit(pattern, expanded string) (beforeSplit, afterSplit string, ok bool) { |
| 607 | cleaned := caddyhttp.NormalizedPath(expanded, true) |
| 608 | if cleaned == "" { |
| 609 | // Preserve path.Clean's existing result for an empty path. |
| 610 | cleaned = "." |
| 611 | } |
| 612 | beforeSplit, afterSplit = m.firstSplit(cleaned) |
| 613 | patternHasTrailingSlash := strings.HasSuffix(pattern, "/") |
| 614 | // SanitizedPathJoin cleans the doubled slash but uses it to preserve |
| 615 | // the filesystem separator after the site root. |
| 616 | if beforeSplit == "/" && patternHasTrailingSlash { |
| 617 | beforeSplit += "/" |
| 618 | } |
| 619 | |
| 620 | // with "{path}", a request for "/dir/" falls through to the next entry (e.g. "{path}/index.php") |
| 621 | if beforeSplit != "/" && strings.HasSuffix(beforeSplit, "/") && !patternHasTrailingSlash { |
| 622 | return "", "", false |
| 623 | } |
| 624 | return beforeSplit, afterSplit, true |
| 625 | } |
| 626 | |
| 627 | // firstSplit returns the first result where the path |
| 628 | // can be split in two by a value in m.SplitPath. The |
| 629 | // return values are the first piece of the path that |
| 630 | // ends with the split substring and the remainder. |
| 631 | // If the path cannot be split, the path is returned |
| 632 | // as-is (with no remainder). |
| 633 | func (m MatchFile) firstSplit(path string) (splitPart, remainder string) { |
| 634 | for _, split := range m.SplitPath { |
| 635 | if idx := indexFold(path, split); idx > -1 { |
| 636 | pos := idx + len(split) |
| 637 | // skip the split if it's not the final part of the filename |
| 638 | if pos != len(path) && !strings.HasPrefix(path[pos:], "/") { |
| 639 | continue |
| 640 | } |
| 641 | return path[:pos], path[pos:] |
| 642 | } |
| 643 | } |
| 644 | return path, "" |
| 645 | } |
| 646 | |
| 647 | // There is no strings.IndexFold() function like there is strings.EqualFold(), |
| 648 | // but we can use strings.EqualFold() to build our own case-insensitive |
| 649 | // substring search (as of Go 1.14). |
| 650 | func indexFold(haystack, needle string) int { |
| 651 | nlen := len(needle) |
| 652 | for i := 0; i+nlen < len(haystack); i++ { |
| 653 | if strings.EqualFold(haystack[i:i+nlen], needle) { |
| 654 | return i |
| 655 | } |
| 656 | } |
| 657 | return -1 |
| 658 | } |
| 659 | |
| 660 | // isCELTryFilesLiteral returns whether the expression resolves to a map literal containing |
| 661 | // only string keys with or a placeholder call. |
| 662 | func isCELTryFilesLiteral(e ast.Expr) bool { |
| 663 | switch e.Kind() { |
| 664 | case ast.MapKind: |
| 665 | mapExpr := e.AsMap() |
| 666 | for _, entry := range mapExpr.Entries() { |
| 667 | mapKey := entry.AsMapEntry().Key() |
| 668 | mapVal := entry.AsMapEntry().Value() |
| 669 | if !isCELStringLiteral(mapKey) { |
| 670 | return false |
| 671 | } |
| 672 | mapKeyStr := mapKey.AsLiteral().ConvertToType(types.StringType).Value() |
| 673 | switch mapKeyStr { |
| 674 | case "try_files", "split_path": |
| 675 | if !isCELStringListLiteral(mapVal) { |
| 676 | return false |
| 677 | } |
| 678 | case "try_policy", "root": |
| 679 | if !isCELStringExpr(mapVal) { |
| 680 | return false |
| 681 | } |
| 682 | default: |
| 683 | return false |
| 684 | } |
| 685 | } |
| 686 | return true |
| 687 | |
| 688 | case ast.UnspecifiedExprKind, ast.CallKind, ast.ComprehensionKind, ast.IdentKind, ast.ListKind, ast.LiteralKind, ast.SelectKind, ast.StructKind: |
| 689 | // appeasing the linter :) |
| 690 | } |
| 691 | return false |
| 692 | } |
| 693 | |
| 694 | // isCELStringExpr indicates whether the expression is a supported string expression |
| 695 | func isCELStringExpr(e ast.Expr) bool { |
| 696 | return isCELStringLiteral(e) || isCELCaddyPlaceholderCall(e) || isCELConcatCall(e) |
| 697 | } |
| 698 | |
| 699 | // isCELStringLiteral returns whether the expression is a CEL string literal. |
| 700 | func isCELStringLiteral(e ast.Expr) bool { |
| 701 | switch e.Kind() { |
| 702 | case ast.LiteralKind: |
| 703 | constant := e.AsLiteral() |
| 704 | switch constant.Type() { |
| 705 | case types.StringType: |
| 706 | return true |
| 707 | } |
| 708 | case ast.UnspecifiedExprKind, ast.CallKind, ast.ComprehensionKind, ast.IdentKind, ast.ListKind, ast.MapKind, ast.SelectKind, ast.StructKind: |
| 709 | // appeasing the linter :) |
| 710 | } |
| 711 | return false |
| 712 | } |
| 713 | |
| 714 | // isCELCaddyPlaceholderCall returns whether the expression is a caddy placeholder call. |
| 715 | func isCELCaddyPlaceholderCall(e ast.Expr) bool { |
| 716 | switch e.Kind() { |
| 717 | case ast.CallKind: |
| 718 | call := e.AsCall() |
| 719 | if call.FunctionName() == caddyhttp.CELPlaceholderFuncName { |
| 720 | return true |
| 721 | } |
| 722 | case ast.UnspecifiedExprKind, ast.ComprehensionKind, ast.IdentKind, ast.ListKind, ast.LiteralKind, ast.MapKind, ast.SelectKind, ast.StructKind: |
| 723 | // appeasing the linter :) |
| 724 | } |
| 725 | return false |
| 726 | } |
| 727 | |
| 728 | // isCELConcatCall tests whether the expression is a concat function (+) with string, placeholder, or |
| 729 | // other concat call arguments. |
| 730 | func isCELConcatCall(e ast.Expr) bool { |
| 731 | switch e.Kind() { |
| 732 | case ast.CallKind: |
| 733 | call := e.AsCall() |
| 734 | if call.Target().Kind() != ast.UnspecifiedExprKind { |
| 735 | return false |
| 736 | } |
| 737 | if call.FunctionName() != operators.Add { |
| 738 | return false |
| 739 | } |
| 740 | for _, arg := range call.Args() { |
| 741 | if !isCELStringExpr(arg) { |
| 742 | return false |
| 743 | } |
| 744 | } |
| 745 | return true |
| 746 | case ast.UnspecifiedExprKind, ast.ComprehensionKind, ast.IdentKind, ast.ListKind, ast.LiteralKind, ast.MapKind, ast.SelectKind, ast.StructKind: |
| 747 | // appeasing the linter :) |
| 748 | } |
| 749 | return false |
| 750 | } |
| 751 | |
| 752 | // isCELStringListLiteral returns whether the expression resolves to a list literal |
| 753 | // containing only string constants or a placeholder call. |
| 754 | func isCELStringListLiteral(e ast.Expr) bool { |
| 755 | switch e.Kind() { |
| 756 | case ast.ListKind: |
| 757 | list := e.AsList() |
| 758 | for _, elem := range list.Elements() { |
| 759 | if !isCELStringExpr(elem) { |
| 760 | return false |
| 761 | } |
| 762 | } |
| 763 | return true |
| 764 | case ast.UnspecifiedExprKind, ast.CallKind, ast.ComprehensionKind, ast.IdentKind, ast.LiteralKind, ast.MapKind, ast.SelectKind, ast.StructKind: |
| 765 | // appeasing the linter :) |
| 766 | } |
| 767 | return false |
| 768 | } |
| 769 | |
| 770 | // globSafeRepl replaces special glob characters with escaped |
| 771 | // equivalents. Note that the filepath godoc states that |
| 772 | // escaping is not done on Windows because of the separator. |
| 773 | var globSafeRepl = strings.NewReplacer( |
| 774 | "*", "\\*", |
| 775 | "[", "\\[", |
| 776 | "?", "\\?", |
| 777 | "\\", "\\\\", |
| 778 | ) |
| 779 | |
| 780 | const ( |
| 781 | tryPolicyFirstExist = "first_exist" |
| 782 | tryPolicyFirstExistFallback = "first_exist_fallback" |
| 783 | tryPolicyLargestSize = "largest_size" |
| 784 | tryPolicySmallestSize = "smallest_size" |
| 785 | tryPolicyMostRecentlyMod = "most_recently_modified" |
| 786 | ) |
| 787 | |
| 788 | // Interface guards |
| 789 | var ( |
| 790 | _ caddy.Validator = (*MatchFile)(nil) |
| 791 | _ caddyhttp.RequestMatcherWithError = (*MatchFile)(nil) |
| 792 | _ caddyhttp.CELLibraryProducer = (*MatchFile)(nil) |
| 793 | ) |