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