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

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 14, 05:15 UTC

fileserver: Fix try_files for directories; windows fix (#3684)

* fileserver: Fix try_files for directories, windows fix * fileserver: Add new file type placeholder, refactoring, tests * fileserver: Review cleanup * fileserver: Flip the return args order

Francis Lavoie committed Sep 16, 2020 at 20:09 UTC b95b87381a282e1fe57295d145b71645d7801f07
6 files changed +165 -35
modules/caddyhttp/fileserver/matcher.go
+53 -32
@@ -19,6 +19,7 @@ import (
19 "net/http"
20 "os"
21 "path"
22 + "path/filepath"
23 "strings"
24 "time"
25
@@ -34,7 +35,7 @@ func init() {
35 // MatchFile is an HTTP request matcher that can match
36 // requests based upon file existence.
37 //
37 -// Upon matching, two new placeholders will be made
38 +// Upon matching, three new placeholders will be made
39 // available:
40 //
41 // - `{http.matchers.file.relative}` The root-relative
@@ -42,6 +43,8 @@ func init() {
43 // requests.
44 // - `{http.matchers.file.absolute}` The absolute path
45 // of the matched file.
46 +// - `{http.matchers.file.type}` Set to "directory" if
47 +// the matched file is a directory, "file" otherwise.
48 type MatchFile struct {
49 // The root directory, used for creating absolute
50 // file paths, and required when working with
@@ -153,25 +156,18 @@ func (m MatchFile) Validate() error {
156 }
157
158 // Match returns true if r matches m. Returns true
156 -// if a file was matched. If so, two placeholders
159 +// if a file was matched. If so, three placeholders
160 // will be available:
161 // - http.matchers.file.relative
162 // - http.matchers.file.absolute
163 +// - http.matchers.file.type
164 func (m MatchFile) Match(r *http.Request) bool {
161 - repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
162 - rel, abs, matched := m.selectFile(r)
163 - if matched {
164 - repl.Set("http.matchers.file.relative", rel)
165 - repl.Set("http.matchers.file.absolute", abs)
166 - }
167 - return matched
165 + return m.selectFile(r)
166 }
167
168 // selectFile chooses a file according to m.TryPolicy by appending
169 // the paths in m.TryFiles to m.Root, with placeholder replacements.
172 -// It returns the root-relative path to the matched file, the full
173 -// or absolute path, and whether a match was made.
174 -func (m MatchFile) selectFile(r *http.Request) (rel, abs string, matched bool) {
170 +func (m MatchFile) selectFile(r *http.Request) (matched bool) {
171 repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
172
173 root := repl.ReplaceAll(m.Root, ".")
@@ -183,13 +179,35 @@ func (m MatchFile) selectFile(r *http.Request) (rel, abs string, matched bool) {
179 m.TryFiles = []string{r.URL.Path}
180 }
181
182 + // common preparation of the file into parts
183 + prepareFilePath := func(file string) (string, string) {
184 + suffix := m.firstSplit(path.Clean(repl.ReplaceAll(file, "")))
185 + if strings.HasSuffix(file, "/") {
186 + suffix += "/"
187 + }
188 + fullpath := sanitizedPathJoin(root, suffix)
189 + return suffix, fullpath
190 + }
191 +
192 + // sets up the placeholders for the matched file
193 + setPlaceholders := func(info os.FileInfo, rel string, abs string) {
194 + repl.Set("http.matchers.file.relative", rel)
195 + repl.Set("http.matchers.file.absolute", abs)
196 +
197 + fileType := "file"
198 + if info.IsDir() {
199 + fileType = "directory"
200 + }
201 + repl.Set("http.matchers.file.type", fileType)
202 + }
203 +
204 switch m.TryPolicy {
205 case "", tryPolicyFirstExist:
206 for _, f := range m.TryFiles {
189 - suffix := m.firstSplit(path.Clean(repl.ReplaceAll(f, "")))
190 - fullpath := sanitizedPathJoin(root, suffix)
191 - if strictFileExists(fullpath) {
192 - return suffix, fullpath, true
207 + suffix, fullpath := prepareFilePath(f)
208 + if info, exists := strictFileExists(fullpath); exists {
209 + setPlaceholders(info, suffix, fullpath)
210 + return true
211 }
212 }
213
@@ -197,9 +215,9 @@ func (m MatchFile) selectFile(r *http.Request) (rel, abs string, matched bool) {
215 var largestSize int64
216 var largestFilename string
217 var largestSuffix string
218 + var info os.FileInfo
219 for _, f := range m.TryFiles {
201 - suffix := m.firstSplit(path.Clean(repl.ReplaceAll(f, "")))
202 - fullpath := sanitizedPathJoin(root, suffix)
220 + suffix, fullpath := prepareFilePath(f)
221 info, err := os.Stat(fullpath)
222 if err == nil && info.Size() > largestSize {
223 largestSize = info.Size()
@@ -207,15 +225,16 @@ func (m MatchFile) selectFile(r *http.Request) (rel, abs string, matched bool) {
225 largestSuffix = suffix
226 }
227 }
210 - return largestSuffix, largestFilename, true
228 + setPlaceholders(info, largestSuffix, largestFilename)
229 + return true
230
231 case tryPolicySmallestSize:
232 var smallestSize int64
233 var smallestFilename string
234 var smallestSuffix string
235 + var info os.FileInfo
236 for _, f := range m.TryFiles {
217 - suffix := m.firstSplit(path.Clean(repl.ReplaceAll(f, "")))
218 - fullpath := sanitizedPathJoin(root, suffix)
237 + suffix, fullpath := prepareFilePath(f)
238 info, err := os.Stat(fullpath)
239 if err == nil && (smallestSize == 0 || info.Size() < smallestSize) {
240 smallestSize = info.Size()
@@ -223,15 +242,16 @@ func (m MatchFile) selectFile(r *http.Request) (rel, abs string, matched bool) {
242 smallestSuffix = suffix
243 }
244 }
226 - return smallestSuffix, smallestFilename, true
245 + setPlaceholders(info, smallestSuffix, smallestFilename)
246 + return true
247
248 case tryPolicyMostRecentlyMod:
249 var recentDate time.Time
250 var recentFilename string
251 var recentSuffix string
252 + var info os.FileInfo
253 for _, f := range m.TryFiles {
233 - suffix := m.firstSplit(path.Clean(repl.ReplaceAll(f, "")))
234 - fullpath := sanitizedPathJoin(root, suffix)
254 + suffix, fullpath := prepareFilePath(f)
255 info, err := os.Stat(fullpath)
256 if err == nil &&
257 (recentDate.IsZero() || info.ModTime().After(recentDate)) {
@@ -240,7 +260,8 @@ func (m MatchFile) selectFile(r *http.Request) (rel, abs string, matched bool) {
260 recentSuffix = suffix
261 }
262 }
243 - return recentSuffix, recentFilename, true
263 + setPlaceholders(info, recentSuffix, recentFilename)
264 + return true
265 }
266
267 return
@@ -252,7 +273,7 @@ func (m MatchFile) selectFile(r *http.Request) (rel, abs string, matched bool) {
273 // the file must also be a directory; if it does
274 // NOT end in a forward slash, the file must NOT
275 // be a directory.
255 -func strictFileExists(file string) bool {
276 +func strictFileExists(file string) (os.FileInfo, bool) {
277 stat, err := os.Stat(file)
278 if err != nil {
279 // in reality, this can be any error
@@ -263,16 +284,16 @@ func strictFileExists(file string) bool {
284 // the file exists, so we just treat any
285 // error as if it does not exist; see
286 // https://stackoverflow.com/a/12518877/1048862
266 - return false
287 + return nil, false
288 }
268 - if strings.HasSuffix(file, "/") {
289 + if strings.HasSuffix(file, string(filepath.Separator)) {
290 // by convention, file paths ending
270 - // in a slash must be a directory
271 - return stat.IsDir()
291 + // in a path separator must be a directory
292 + return stat, stat.IsDir()
293 }
294 // by convention, file paths NOT ending
274 - // in a slash must NOT be a directory
275 - return !stat.IsDir()
295 + // in a path separator must NOT be a directory
296 + return stat, !stat.IsDir()
297 }
298
299 // firstSplit returns the first result where the path
modules/caddyhttp/fileserver/matcher_test.go
+93
@@ -22,30 +22,114 @@ import (
22 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
23 )
24
25 +func TestFileMatcher(t *testing.T) {
26 + for i, tc := range []struct {
27 + path string
28 + expectedPath string
29 + expectedType string
30 + matched bool
31 + }{
32 + {
33 + path: "/foo.txt",
34 + expectedPath: "/foo.txt",
35 + expectedType: "file",
36 + matched: true,
37 + },
38 + {
39 + path: "/foo.txt/",
40 + expectedPath: "/foo.txt",
41 + expectedType: "file",
42 + matched: true,
43 + },
44 + {
45 + path: "/foodir",
46 + expectedPath: "/foodir/",
47 + expectedType: "directory",
48 + matched: true,
49 + },
50 + {
51 + path: "/foodir/",
52 + expectedPath: "/foodir/",
53 + expectedType: "directory",
54 + matched: true,
55 + },
56 + {
57 + path: "/foodir/foo.txt",
58 + expectedPath: "/foodir/foo.txt",
59 + expectedType: "file",
60 + matched: true,
61 + },
62 + {
63 + path: "/missingfile.php",
64 + matched: false,
65 + },
66 + } {
67 + m := &MatchFile{
68 + Root: "./testdata",
69 + TryFiles: []string{"{http.request.uri.path}", "{http.request.uri.path}/"},
70 + }
71 +
72 + u, err := url.Parse(tc.path)
73 + if err != nil {
74 + t.Fatalf("Test %d: parsing path: %v", i, err)
75 + }
76 +
77 + req := &http.Request{URL: u}
78 + repl := caddyhttp.NewTestReplacer(req)
79 +
80 + result := m.Match(req)
81 + if result != tc.matched {
82 + t.Fatalf("Test %d: expected match=%t, got %t", i, tc.matched, result)
83 + }
84 +
85 + rel, ok := repl.Get("http.matchers.file.relative")
86 + if !ok && result {
87 + t.Fatalf("Test %d: expected replacer value", i)
88 + }
89 + if !result {
90 + continue
91 + }
92 +
93 + if rel != tc.expectedPath {
94 + t.Fatalf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
95 + }
96 +
97 + fileType, ok := repl.Get("http.matchers.file.type")
98 + if fileType != tc.expectedType {
99 + t.Fatalf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
100 + }
101 + }
102 +}
103 +
104 func TestPHPFileMatcher(t *testing.T) {
105 for i, tc := range []struct {
106 path string
107 expectedPath string
108 + expectedType string
109 matched bool
110 }{
111 {
112 path: "/index.php",
113 expectedPath: "/index.php",
114 + expectedType: "file",
115 matched: true,
116 },
117 {
118 path: "/index.php/somewhere",
119 expectedPath: "/index.php",
120 + expectedType: "file",
121 matched: true,
122 },
123 {
124 path: "/remote.php",
125 expectedPath: "/remote.php",
126 + expectedType: "file",
127 matched: true,
128 },
129 {
130 path: "/remote.php/somewhere",
131 expectedPath: "/remote.php",
132 + expectedType: "file",
133 matched: true,
134 },
135 {
@@ -55,11 +139,13 @@ func TestPHPFileMatcher(t *testing.T) {
139 {
140 path: "/notphp.php.txt",
141 expectedPath: "/notphp.php.txt",
142 + expectedType: "file",
143 matched: true,
144 },
145 {
146 path: "/notphp.php.txt/",
147 expectedPath: "/notphp.php.txt",
148 + expectedType: "file",
149 matched: true,
150 },
151 {
@@ -69,12 +155,14 @@ func TestPHPFileMatcher(t *testing.T) {
155 {
156 path: "/foo.php.php/index.php",
157 expectedPath: "/foo.php.php/index.php",
158 + expectedType: "file",
159 matched: true,
160 },
161 {
162 // See https://github.com/caddyserver/caddy/issues/3623
163 path: "/%E2%C3",
164 expectedPath: "/%E2%C3",
165 + expectedType: "file",
166 matched: false,
167 },
168 } {
@@ -108,6 +196,11 @@ func TestPHPFileMatcher(t *testing.T) {
196 if rel != tc.expectedPath {
197 t.Fatalf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
198 }
199 +
200 + fileType, ok := repl.Get("http.matchers.file.type")
201 + if fileType != tc.expectedType {
202 + t.Fatalf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
203 + }
204 }
205 }
206
modules/caddyhttp/fileserver/staticfiles.go
+12 -2
@@ -23,7 +23,6 @@ import (
23 "mime"
24 "net/http"
25 "os"
26 - "path"
26 "path/filepath"
27 "strconv"
28 "strings"
@@ -323,7 +322,18 @@ func sanitizedPathJoin(root, reqPath string) string {
322 if root == "" {
323 root = "."
324 }
326 - return filepath.Join(root, filepath.FromSlash(path.Clean("/"+reqPath)))
325 +
326 + path := filepath.Join(root, filepath.Clean("/"+reqPath))
327 +
328 + // filepath.Join also cleans the path, and cleaning strips
329 + // the trailing slash, so we need to re-add it afterwards.
330 + // if the length is 1, then it's a path to the root,
331 + // and that should return ".", so we don't append the separator.
332 + if strings.HasSuffix(reqPath, "/") && len(reqPath) > 1 {
333 + path += string(filepath.Separator)
334 + }
335 +
336 + return path
337 }
338
339 // fileHidden returns true if filename is hidden
modules/caddyhttp/fileserver/staticfiles_test.go
+5 -1
@@ -42,6 +42,10 @@ func TestSanitizedPathJoin(t *testing.T) {
42 inputPath: "/foo",
43 expect: "foo",
44 },
45 + {
46 + inputPath: "/foo/",
47 + expect: "foo" + string(filepath.Separator),
48 + },
49 {
50 inputPath: "/foo/bar",
51 expect: filepath.Join("foo", "bar"),
@@ -73,7 +77,7 @@ func TestSanitizedPathJoin(t *testing.T) {
77 {
78 inputRoot: "/a/b",
79 inputPath: "/%2e%2e%2f%2e%2e%2f",
76 - expect: filepath.Join("/", "a", "b"),
80 + expect: filepath.Join("/", "a", "b") + string(filepath.Separator),
81 },
82 {
83 inputRoot: "C:\\www",
modules/caddyhttp/fileserver/testdata/foo.txt new
+1
@@ -0,0 +1 @@
1 +foo.txt
\ No newline at end of file
modules/caddyhttp/fileserver/testdata/foodir/foo.txt new
+1
@@ -0,0 +1 @@
1 +foodir/foo.txt
\ No newline at end of file