Repository: caddyserver/caddy (language: go)

Issue:
Enhancement: file-server browser, allow for listing symlinks as symlinks 
Caddy currently resolves symlink targets and displays the target information in its directory listing.

I would like an option to show links as links to the destination instead of appearing as a real file.

Example:
```
a/
b/
foo.txt
bar.txt
baz.txt -> a/baz.txt
...
```
Clicking the link should still download the file as before. 


Discussion / hints:
@mholt I would like to take a shot at this if you guys are still planning on implementing this feature. 

Just to get more context, we are here referring to the `caddy file-server` command right? Thank you. 
Sure. Feel free to send a PR.

> we are here referring to the `caddy file-server` command right?

No, this is about the `browse` sub-directive in `file_server` handler, regardless of whether it's run through configuration file or the `caddy file-server` command.

Fix patch:
diff --git a/modules/caddyhttp/fileserver/browse.go b/modules/caddyhttp/fileserver/browse.go
index 7602f3a9eca..86adc7e3930 100644
--- a/modules/caddyhttp/fileserver/browse.go
+++ b/modules/caddyhttp/fileserver/browse.go
@@ -50,6 +50,8 @@ var BrowseTemplate string
 type Browse struct {
 	// Filename of the template to use instead of the embedded browse template.
 	TemplateFile string `json:"template_file,omitempty"`
+	// Determines whether or not targets of symlinks should be revealed.
+	RevealSymlinks bool `json:"reveal_symlinks,omitempty"`
 }
 
 func (fsrv *FileServer) serveBrowse(fileSystem fs.FS, root, dirPath string, w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
diff --git a/modules/caddyhttp/fileserver/browse.html b/modules/caddyhttp/fileserver/browse.html
index af1772bfde1..484d900205f 100644
--- a/modules/caddyhttp/fileserver/browse.html
+++ b/modules/caddyhttp/fileserver/browse.html
@@ -962,7 +962,15 @@ <h1>
 						<td>
 							<a href="{{html .URL}}">
 								{{template "icon" .}}
+								{{- if not .SymlinkPath}}
 								<span class="name">{{html .Name}}</span>
+								{{- else}}
+								<span class="name">{{html .Name}} <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-arrow-narrow-right" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
+									<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" />
+									<path d="M15 16l4 -4" />
+									<path d="M15 8l4 4" />
+								</svg> {{html .SymlinkPath}}</span>
+								{{- end}}
 							</a>
 						</td>
 						{{- if .IsDir}}
diff --git a/modules/caddyhttp/fileserver/browsetplcontext.go b/modules/caddyhttp/fileserver/browsetplcontext.go
index 81db3d06be0..5456f0597f0 100644
--- a/modules/caddyhttp/fileserver/browsetplcontext.go
+++ b/modules/caddyhttp/fileserver/browsetplcontext.go
@@ -20,6 +20,7 @@ import (
 	"net/url"
 	"os"
 	"path"
+	"path/filepath"
 	"sort"
 	"strconv"
 	"strings"
@@ -74,12 +75,21 @@ func (fsrv *FileServer) directoryListing(ctx context.Context, fileSystem fs.FS,
 
 		size := info.Size()
 		fileIsSymlink := isSymlink(info)
+		symlinkPath := ""
 		if fileIsSymlink {
 			path := caddyhttp.SanitizedPathJoin(root, path.Join(urlPath, info.Name()))
 			fileInfo, err := fs.Stat(fileSystem, path)
 			if err == nil {
 				size = fileInfo.Size()
 			}
+
+			if fsrv.Browse.RevealSymlinks {
+				symLinkTarget, err := filepath.EvalSymlinks(path)
+				if err == nil {
+					symlinkPath = symLinkTarget
+				}
+			}
+
 			// An error most likely means the symlink target doesn't exist,
 			// which isn't entirely unusual and shouldn't fail the listing.
 			// In this case, just use the size of the symlink itself, which
@@ -93,14 +103,15 @@ func (fsrv *FileServer) directoryListing(ctx context.Context, fileSystem fs.FS,
 		u := url.URL{Path: "./" + name} // prepend with "./" to fix paths with ':' in the name
 
 		tplCtx.Items = append(tplCtx.Items, fileInfo{
-			IsDir:     isDir,
-			IsSymlink: fileIsSymlink,
-			Name:      name,
-			Size:      size,
-			URL:       u.String(),
-			ModTime:   info.ModTime().UTC(),
-			Mode:      info.Mode(),
-			Tpl:       tplCtx, // a reference up to the template context is useful
+			IsDir:       isDir,
+			IsSymlink:   fileIsSymlink,
+			Name:        name,
+			Size:        size,
+			URL:         u.String(),
+			ModTime:     info.ModTime().UTC(),
+			Mode:        info.Mode(),
+			Tpl:         tplCtx, // a reference up to the template context is useful
+			SymlinkPath: symlinkPath,
 		})
 	}
 
@@ -230,13 +241,14 @@ type crumb struct {
 // fileInfo contains serializable information
 // about a file or directory.
 type fileInfo struct {
-	Name      string      `json:"name"`
-	Size      int64       `json:"size"`
-	URL       string      `json:"url"`
-	ModTime   time.Time   `json:"mod_time"`
-	Mode      os.FileMode `json:"mode"`
-	IsDir     bool        `json:"is_dir"`
-	IsSymlink bool        `json:"is_symlink"`
+	Name        string      `json:"name"`
+	Size        int64       `json:"size"`
+	URL         string      `json:"url"`
+	ModTime     time.Time   `json:"mod_time"`
+	Mode        os.FileMode `json:"mode"`
+	IsDir       bool        `json:"is_dir"`
+	IsSymlink   bool        `json:"is_symlink"`
+	SymlinkPath string      `json:"symlink_path,omitempty"`
 
 	// a pointer to the template context is useful inside nested templates
 	Tpl *browseTemplateContext `json:"-"`
diff --git a/modules/caddyhttp/fileserver/caddyfile.go b/modules/caddyhttp/fileserver/caddyfile.go
index b18bcdcef51..6ad9190f3c2 100644
--- a/modules/caddyhttp/fileserver/caddyfile.go
+++ b/modules/caddyhttp/fileserver/caddyfile.go
@@ -112,6 +112,15 @@ func (fsrv *FileServer) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
 			}
 			fsrv.Browse = new(Browse)
 			d.Args(&fsrv.Browse.TemplateFile)
+			for nesting := d.Nesting(); d.NextBlock(nesting); {
+				if d.Val() != "reveal_symlinks" {
+					return d.Errf("unknown subdirective '%s'", d.Val())
+				}
+				if fsrv.Browse.RevealSymlinks {
+					return d.Err("Symlinks path reveal is already enabled")
+				}
+				fsrv.Browse.RevealSymlinks = true
+			}
 
 		case "precompressed":
 			var order []string
diff --git a/modules/caddyhttp/fileserver/command.go b/modules/caddyhttp/fileserver/command.go
index 2bc816743c6..a76998405d3 100644
--- a/modules/caddyhttp/fileserver/command.go
+++ b/modules/caddyhttp/fileserver/command.go
@@ -39,7 +39,7 @@ import (
 func init() {
 	caddycmd.RegisterCommand(caddycmd.Command{
 		Name:  "file-server",
-		Usage: "[--domain <example.com>] [--root <path>] [--listen <addr>] [--browse] [--access-log] [--precompressed]",
+		Usage: "[--domain <example.com>] [--root <path>] [--listen <addr>] [--browse] [--reveal-symlinks] [--access-log] [--precompressed]",
 		Short: "Spins up a production-ready file server",
 		Long: `
 A simple but production-ready file server. Useful for quick deployments,
@@ -62,6 +62,7 @@ respond with a file listing.`,
 			cmd.Flags().StringP("root", "r", "", "The path to the root of the site")
 			cmd.Flags().StringP("listen", "l", "", "The address to which to bind the listener")
 			cmd.Flags().BoolP("browse", "b", false, "Enable directory browsing")
+			cmd.Flags().BoolP("reveal-symlinks", "", false, "Show symlink paths when browse is enabled.")
 			cmd.Flags().BoolP("templates", "t", false, "Enable template rendering")
 			cmd.Flags().BoolP("access-log", "a", false, "Enable the access log")
 			cmd.Flags().BoolP("debug", "v", false, "Enable verbose debug logs")
@@ -91,12 +92,12 @@ func cmdFileServer(fs caddycmd.Flags) (int, error) {
 	templates := fs.Bool("templates")
 	accessLog := fs.Bool("access-log")
 	debug := fs.Bool("debug")
+	revealSymlinks := fs.Bool("reveal-symlinks")
 	compress := !fs.Bool("no-compress")
 	precompressed, err := fs.GetStringSlice("precompressed")
 	if err != nil {
 		return caddy.ExitCodeFailedStartup, fmt.Errorf("invalid precompressed flag: %v", err)
 	}
-
 	var handlers []json.RawMessage
 
 	if compress {
@@ -150,7 +151,7 @@ func cmdFileServer(fs caddycmd.Flags) (int, error) {
 	}
 
 	if browse {
-		handler.Browse = new(Browse)
+		handler.Browse = &Browse{RevealSymlinks: revealSymlinks}
 	}
 
 	handlers = append(handlers, caddyconfig.JSONModuleObject(handler, "handler", "file_server", nil))


Test patch: