diff --git a/caddytest/integration/caddyfile_adapt/file_server_plain_time_format.caddyfiletest b/caddytest/integration/caddyfile_adapt/file_server_plain_time_format.caddyfiletest new file mode 100644 index 000000000..9cc2918cb --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/file_server_plain_time_format.caddyfiletest @@ -0,0 +1,36 @@ +:80 + +file_server { + browse { + plain_time_format "2006-01-02 15:04:05 MST" + } +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":80" + ], + "routes": [ + { + "handle": [ + { + "browse": { + "plain_time_format": "2006-01-02 15:04:05 MST" + }, + "handler": "file_server", + "hide": [ + "./Caddyfile" + ] + } + ] + } + ] + } + } + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/file_server_plain_time_format_empty_argument.caddyfiletest b/caddytest/integration/caddyfile_adapt/file_server_plain_time_format_empty_argument.caddyfiletest new file mode 100644 index 000000000..a296ef255 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/file_server_plain_time_format_empty_argument.caddyfiletest @@ -0,0 +1,9 @@ +:80 + +file_server { + browse { + plain_time_format "" + } +} +---------- +plain_time_format requires a non-empty time layout argument diff --git a/caddytest/integration/caddyfile_adapt/file_server_plain_time_format_extra_arguments.caddyfiletest b/caddytest/integration/caddyfile_adapt/file_server_plain_time_format_extra_arguments.caddyfiletest new file mode 100644 index 000000000..b943d70f4 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/file_server_plain_time_format_extra_arguments.caddyfiletest @@ -0,0 +1,9 @@ +:80 + +file_server { + browse { + plain_time_format 2006-01-02 15:04:05 MST + } +} +---------- +plain_time_format accepts exactly one time layout argument; enclose layouts containing spaces in quotes diff --git a/caddytest/integration/caddyfile_adapt/file_server_plain_time_format_missing_argument.caddyfiletest b/caddytest/integration/caddyfile_adapt/file_server_plain_time_format_missing_argument.caddyfiletest new file mode 100644 index 000000000..d09351bd5 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/file_server_plain_time_format_missing_argument.caddyfiletest @@ -0,0 +1,9 @@ +:80 + +file_server { + browse { + plain_time_format + } +} +---------- +plain_time_format is missing a time layout argument diff --git a/modules/caddyhttp/fileserver/browse.go b/modules/caddyhttp/fileserver/browse.go index 4ccd03831..d90751f16 100644 --- a/modules/caddyhttp/fileserver/browse.go +++ b/modules/caddyhttp/fileserver/browse.go @@ -69,10 +69,17 @@ type Browse struct { // FileLimit limits the number of up to n DirEntry values in directory order. FileLimit int `json:"file_limit,omitempty"` + + // PlainTimeFormat specifies the Go time layout used for modification + // times in plain-text directory listings. The default layout is + // `January 2, 2006 at 15:04:05`. See + // [Go's time layout documentation](https://pkg.go.dev/time#pkg-constants). + PlainTimeFormat string `json:"plain_time_format,omitempty"` } const ( - defaultDirEntryLimit = 10000 + defaultDirEntryLimit = 10000 + defaultPlainTimeFormat = "January 2, 2006 at 15:04:05" ) func (fsrv *FileServer) serveBrowse(fileSystem fs.FS, root, dirPath string, w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { @@ -167,11 +174,19 @@ func (fsrv *FileServer) serveBrowse(fileSystem fs.FS, root, dirPath string, w ht return caddyhttp.Error(http.StatusInternalServerError, err) } + // Use the configured time format if specified, otherwise use the default + plainTimeFormat := defaultPlainTimeFormat + if fsrv.Browse.PlainTimeFormat != "" { + plainTimeFormat = fsrv.Browse.PlainTimeFormat + } + // Actual files for _, item := range listing.Items { //nolint:gosec // not sure how this could be XSS unless you lose control of the file system (like aren't sanitizing) and client ignores Content-Type of text/plain if _, err := fmt.Fprintf(writer, "%s\t%s\t%s\n", - item.Name, item.HumanSize(), item.HumanModTime("January 2, 2006 at 15:04:05"), + item.Name, + item.HumanSize(), + item.HumanModTime(plainTimeFormat), ); err != nil { return caddyhttp.Error(http.StatusInternalServerError, err) } diff --git a/modules/caddyhttp/fileserver/browse_test.go b/modules/caddyhttp/fileserver/browse_test.go new file mode 100644 index 000000000..0977de5f2 --- /dev/null +++ b/modules/caddyhttp/fileserver/browse_test.go @@ -0,0 +1,88 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fileserver + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +func TestBrowsePlainTimeFormat(t *testing.T) { + root := t.TempDir() + filename := filepath.Join(root, "example.txt") + if err := os.WriteFile(filename, nil, 0o600); err != nil { + t.Fatal(err) + } + modTime := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) + if err := os.Chtimes(filename, modTime, modTime); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + layout string + wantModTime string + }{ + { + name: "default", + wantModTime: "January 2, 2024 at 03:04:05", + }, + { + name: "custom", + layout: "2006-01-02T15:04:05Z07:00", + wantModTime: "2024-01-02T03:04:05Z", + }, + } { + t.Run(tc.name, func(t *testing.T) { + fsrv := FileServer{ + Root: root, + Browse: &Browse{ + PlainTimeFormat: tc.layout, + }, + } + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) + if err := fsrv.Provision(ctx); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("Accept", "text/plain") + originalReq := *req + reqCtx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer()) + reqCtx = context.WithValue(reqCtx, caddyhttp.OriginalRequestCtxKey, originalReq) + req = req.WithContext(reqCtx) + + w := httptest.NewRecorder() + if err := fsrv.ServeHTTP(w, req, nil); err != nil { + t.Fatal(err) + } + if got := w.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { + t.Fatalf("Content-Type = %q, want %q", got, "text/plain; charset=utf-8") + } + if !strings.Contains(w.Body.String(), tc.wantModTime) { + t.Fatalf("response body does not contain modification time %q:\n%s", tc.wantModTime, w.Body.String()) + } + }) + } +} diff --git a/modules/caddyhttp/fileserver/caddyfile.go b/modules/caddyhttp/fileserver/caddyfile.go index 7d9e6f4d8..12c234518 100644 --- a/modules/caddyhttp/fileserver/caddyfile.go +++ b/modules/caddyhttp/fileserver/caddyfile.go @@ -56,7 +56,12 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) // root // hide // index -// browse [] +// browse [] { +// reveal_symlinks +// sort +// file_limit +// plain_time_format +// } // precompressed // status // disable_canonical_uris @@ -147,6 +152,21 @@ func (fsrv *FileServer) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.Err("file_limit is already enabled") } fsrv.Browse.FileLimit = val + case "plain_time_format": + plainTimeFormat := d.RemainingArgs() + if len(plainTimeFormat) == 0 { + return d.Err("plain_time_format is missing a time layout argument") + } + if len(plainTimeFormat) > 1 { + return d.Err("plain_time_format accepts exactly one time layout argument; enclose layouts containing spaces in quotes") + } + if plainTimeFormat[0] == "" { + return d.Err("plain_time_format requires a non-empty time layout argument") + } + if fsrv.Browse.PlainTimeFormat != "" { + return d.Err("plain_time_format is already set") + } + fsrv.Browse.PlainTimeFormat = plainTimeFormat[0] default: return d.Errf("unknown subdirective '%s'", d.Val()) }