package router import ( "bytes" stdContext "context" "fmt" "html" "html/template" "io" "io/ioutil" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "sort" "strings" "sync" "time" "github.com/kataras/iris/v12/context" ) const indexName = "/index.html" // DirListFunc is the function signature for customizing directory and file listing. // See `DirList` and `DirListRich` functions for its implementations. type DirListFunc func(ctx *context.Context, dirOptions DirOptions, dirName string, dir http.File) error // Attachments options for files to be downloaded and saved locally by the client. // See `DirOptions`. type Attachments struct { // Set to true to enable the files to be downloaded and // saved locally by the client, instead of serving the file. Enable bool // Options to send files with a limit of bytes sent per second. Limit float64 Burst int // Use this function to change the sent filename. NameFunc func(systemName string) (attachmentName string) } // DirCacheOptions holds the options for the cached file system. // See `DirOptions`structure for more. type DirCacheOptions struct { // Enable or disable cache. Enable bool // Minimum content size for compression in bytes. CompressMinSize int64 // Ignore compress files that match this pattern. CompressIgnore *regexp.Regexp // The available sever's encodings to be negotiated with the client's needs, // common values: gzip, br. Encodings []string // If greater than zero then prints information about cached files to the stdout. // If it's 1 then it prints only the total cached and after-compression reduced file sizes // If it's 2 then it prints it per file too. Verbose uint8 } // DirOptions contains the settings that `FileServer` can use to serve files. // See `DefaultDirOptions`. type DirOptions struct { // Defaults to "/index.html", if request path is ending with **/*/$IndexName // then it redirects to **/*(/). // That index handler is registered automatically // by the framework unless but it can be overridden. IndexName string // PushTargets filenames (map's value) to // be served without additional client's requests (HTTP/2 Push) // when a specific request path (map's key WITHOUT prefix) // is requested and it's not a directory (it's an `IndexFile`). // // Example: // "/": { // "favicon.ico", // "js/main.js", // "css/main.css", // } PushTargets map[string][]string // PushTargetsRegexp like `PushTargets` but accepts regexp which // is compared against all files under a directory (recursively). // The `IndexName` should be set. // // Example: // "/": regexp.MustCompile("((.*).js|(.*).css|(.*).ico)$") // See `iris.MatchCommonAssets` too. PushTargetsRegexp map[string]*regexp.Regexp // Cache to enable in-memory cache and pre-compress files. Cache DirCacheOptions // When files should served under compression. Compress bool // List the files inside the current requested // directory if `IndexName` not found. ShowList bool // If `ShowList` is true then this function will be used instead // of the default one to show the list of files // of a current requested directory(dir). // See `DirListRich` package-level function too. DirList DirListFunc // Files downloaded and saved locally. Attachments Attachments // Optional validator that loops through each requested resource. AssetValidator func(ctx *context.Context, name string) bool } // DefaultDirOptions holds the default settings for `FileServer`. var DefaultDirOptions = DirOptions{ IndexName: indexName, PushTargets: make(map[string][]string), PushTargetsRegexp: make(map[string]*regexp.Regexp), Cache: DirCacheOptions{ // Disable by-default. Enable: false, // Don't compress files smaller than 300 bytes. CompressMinSize: 300, // Gzip, deflate, br(brotli), snappy. Encodings: context.AllEncodings, // Log to the stdout (no iris logger) the total reduced file size. Verbose: 1, }, Compress: true, ShowList: false, DirList: DirListRich(DirListRichOptions{ Tmpl: DirListRichTemplate, TmplName: "dirlist", }), Attachments: Attachments{ Enable: false, Limit: 0, Burst: 0, }, AssetValidator: nil, } // FileServer returns a Handler which serves files from a specific file system. // The first parameter is the file system, // if it's a `http.Dir` the files should be located near the executable program. // The second parameter is the settings that the caller can use to customize the behavior. // // See `Party#HandleDir` too. // Examples can be found at: https://github.com/kataras/iris/tree/master/_examples/file-server func FileServer(fs http.FileSystem, options DirOptions) context.Handler { if fs == nil { panic("FileServer: fs is nil. The fs parameter should point to a file system of physical system directory or to an embedded one") } // Make sure index name starts with a slash. if options.IndexName != "" { options.IndexName = prefix(options.IndexName, "/") } // Make sure PushTarget's paths are in the proper form. for path, filenames := range options.PushTargets { for idx, filename := range filenames { filenames[idx] = filepath.ToSlash(filename) } options.PushTargets[path] = filenames } if !options.Attachments.Enable { // make sure rate limiting is not used when attachments are not. options.Attachments.Limit = 0 options.Attachments.Burst = 0 } plainStatusCode := func(ctx *context.Context, statusCode int) { if writer, ok := ctx.ResponseWriter().(*context.CompressResponseWriter); ok { writer.Disabled = true } ctx.StatusCode(statusCode) } dirList := options.DirList if dirList == nil { dirList = DirList } open := fsOpener(fs, options.Cache) // We only need its opener, the "fs" is NOT used below. h := func(ctx *context.Context) { r := ctx.Request() name := prefix(r.URL.Path, "/") r.URL.Path = name f, err := open(name, r) if err != nil { plainStatusCode(ctx, http.StatusNotFound) return } defer f.Close() info, err := f.Stat() if err != nil { plainStatusCode(ctx, http.StatusNotFound) return } var indexFound bool // use contents of index.html for directory, if present if info.IsDir() && options.IndexName != "" { // Note that, in contrast of the default net/http mechanism; // here different handlers may serve the indexes // if manually then this will block will never fire, // if index handler are automatically registered by the framework // then this block will be fired on indexes because the static site routes are registered using the static route's handler. // // End-developers must have the chance to register different logic and middlewares // to an index file, useful on Single Page Applications. index := strings.TrimSuffix(name, "/") + options.IndexName fIndex, err := open(index, r) if err == nil { defer fIndex.Close() infoIndex, err := fIndex.Stat() if err == nil { indexFound = true f = fIndex info = infoIndex } } } // Still a directory? (we didn't find an index.html file) if info.IsDir() { if !options.ShowList { plainStatusCode(ctx, http.StatusNotFound) return } if modified, err := ctx.CheckIfModifiedSince(info.ModTime()); !modified && err == nil { ctx.WriteNotModified() ctx.StatusCode(http.StatusNotModified) ctx.Next() return } ctx.SetLastModified(info.ModTime()) err = dirList(ctx, options, info.Name(), f) if err != nil { ctx.Application().Logger().Errorf("FileServer: dirList: %v", err) plainStatusCode(ctx, http.StatusInternalServerError) return } ctx.Next() return } // index requested, send a moved permanently status // and navigate back to the route without the index suffix. if strings.HasSuffix(name, options.IndexName) { localRedirect(ctx, "./") return } if options.AssetValidator != nil { if !options.AssetValidator(ctx, name) { errCode := ctx.GetStatusCode() if ctx.ResponseWriter().Written() <= context.StatusCodeWritten { // if nothing written as body from the AssetValidator but 200 status code(which is the default), // then we assume that the end-developer just returned false expecting this to be not found. if errCode == http.StatusOK { errCode = http.StatusNotFound } plainStatusCode(ctx, errCode) } return } } // try to find and send the correct content type based on the filename // and the binary data inside "f". detectOrWriteContentType(ctx, info.Name(), f) // if not index file and attachments should be force-sent: if !indexFound && options.Attachments.Enable { destName := info.Name() // diposition := "attachment" if nameFunc := options.Attachments.NameFunc; nameFunc != nil { destName = nameFunc(destName) } ctx.ResponseWriter().Header().Set(context.ContentDispositionHeaderKey, "attachment;filename="+destName) } // the encoding saved from the negotiation. encoding, isCached := getFileEncoding(f) if isCached { // if it's cached and its settings didnt allow this file to be compressed // then don't try to compress it on the fly, even if the options.Compress was set to true. if encoding != "" { if ctx.ResponseWriter().Header().Get(context.ContentEncodingHeaderKey) != "" { // disable any compression writer if that header exist, // note that, we don't directly check for CompressResponseWriter type // because it may be a ResponseRecorder. ctx.CompressWriter(false) } // Set the response header we need, the data are already compressed. context.AddCompressHeaders(ctx.ResponseWriter().Header(), encoding) } } else if options.Compress { ctx.CompressWriter(true) } if indexFound && !options.Attachments.Enable { if indexAssets, ok := options.PushTargets[name]; ok { if pusher, ok := ctx.ResponseWriter().Naive().(http.Pusher); ok { var pushOpts *http.PushOptions if encoding != "" { pushOpts = &http.PushOptions{Header: r.Header} } for _, indexAsset := range indexAssets { if indexAsset[0] != '/' { // it's relative path. indexAsset = path.Join(r.RequestURI, indexAsset) } if err = pusher.Push(indexAsset, pushOpts); err != nil { break } } } } if regex, ok := options.PushTargetsRegexp[r.URL.Path]; ok { if pusher, ok := ctx.ResponseWriter().Naive().(http.Pusher); ok { var pushOpts *http.PushOptions if encoding != "" { pushOpts = &http.PushOptions{Header: r.Header} } prefixURL := strings.TrimSuffix(r.RequestURI, name) names, err := findNames(fs, name) if err == nil { for _, indexAsset := range names { // it's an index file, do not pushed that. if strings.HasSuffix(prefix(indexAsset, "/"), options.IndexName) { continue } // match using relative path (without the first '/' slash) // to keep consistency between the `PushTargets` behavior if regex.MatchString(indexAsset) { // println("Regex Matched: " + indexAsset) if err = pusher.Push(path.Join(prefixURL, indexAsset), pushOpts); err != nil { break } } } } } } } // If limit is 0 then same as ServeContent. ctx.ServeContentWithRate(f, info.Name(), info.ModTime(), options.Attachments.Limit, options.Attachments.Burst) if serveCode := ctx.GetStatusCode(); context.StatusCodeNotSuccessful(serveCode) { plainStatusCode(ctx, serveCode) return } ctx.Next() // fire any middleware, if any. } return h } // StripPrefix returns a handler that serves HTTP requests // by removing the given prefix from the request URL's Path // and invoking the handler h. StripPrefix handles a // request for a path that doesn't begin with prefix by // replying with an HTTP 404 not found error. // // Usage: // fileserver := FileServer("./static_files", DirOptions {...}) // h := StripPrefix("/static", fileserver) // app.Get("/static/{file:path}", h) // app.Head("/static/{file:path}", h) func StripPrefix(prefix string, h context.Handler) context.Handler { if prefix == "" { return h } // here we separate the path from the subdomain (if any), we care only for the path // fixes a bug when serving static files via a subdomain canonicalPrefix := prefix if dotWSlashIdx := strings.Index(canonicalPrefix, SubdomainPrefix); dotWSlashIdx > 0 { canonicalPrefix = canonicalPrefix[dotWSlashIdx+1:] } canonicalPrefix = toWebPath(canonicalPrefix) return func(ctx *context.Context) { if p := strings.TrimPrefix(ctx.Request().URL.Path, canonicalPrefix); len(p) < len(ctx.Request().URL.Path) { ctx.Request().URL.Path = p h(ctx) } else { ctx.NotFound() } } } func toWebPath(systemPath string) string { // winos slash to slash webpath := strings.Replace(systemPath, "\\", "/", -1) // double slashes to single webpath = strings.Replace(webpath, "//", "/", -1) return webpath } // Abs calls filepath.Abs but ignores the error and // returns the original value if any error occurred. func Abs(path string) string { absPath, err := filepath.Abs(path) if err != nil { return path } return absPath } // The algorithm uses at most sniffLen bytes to make its decision. const sniffLen = 512 func detectOrWriteContentType(ctx *context.Context, name string, content io.ReadSeeker) (string, error) { // If Content-Type isn't set, use the file's extension to find it, but // if the Content-Type is unset explicitly, do not sniff the type. ctypes, haveType := ctx.ResponseWriter().Header()["Content-Type"] var ctype string if !haveType { ctype = TypeByExtension(filepath.Ext(name)) if ctype == "" { // read a chunk to decide between utf-8 text and binary var buf [sniffLen]byte n, _ := io.ReadFull(content, buf[:]) ctype = http.DetectContentType(buf[:n]) _, err := content.Seek(0, io.SeekStart) // rewind to output whole file if err != nil { return "", err } } ctx.ContentType(ctype) } else if len(ctypes) > 0 { ctype = ctypes[0] } return ctype, nil } // localRedirect gives a Moved Permanently response. // It does not convert relative paths to absolute paths like Redirect does. func localRedirect(ctx *context.Context, newPath string) { if q := ctx.Request().URL.RawQuery; q != "" { newPath += "?" + q } ctx.Header("Location", newPath) ctx.StatusCode(http.StatusMovedPermanently) } // DirectoryExists returns true if a directory(or file) exists, otherwise false func DirectoryExists(dir string) bool { if _, err := os.Stat(dir); os.IsNotExist(err) { return false } return true } // Instead of path.Base(filepath.ToSlash(s)) // let's do something like that, it is faster // (used to list directories on serve-time too): func toBaseName(s string) string { n := len(s) - 1 for i := n; i >= 0; i-- { if c := s[i]; c == '/' || c == '\\' { if i == n { // "s" ends with a slash, remove it and retry. return toBaseName(s[:n]) } return s[i+1:] // return the rest, trimming the slash. } } return s } // DirList is a `DirListFunc` which renders directories and files in html, but plain, mode. // See `DirListRich` for more. func DirList(ctx *context.Context, dirOptions DirOptions, dirName string, dir http.File) error { dirs, err := dir.Readdir(-1) if err != nil { return err } sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() }) ctx.ContentType(context.ContentHTMLHeaderValue) _, err = ctx.WriteString("
\n") if err != nil { return err } for _, d := range dirs { name := toBaseName(d.Name()) upath := path.Join(ctx.Request().RequestURI, name) url := url.URL{Path: upath} downloadAttr := "" if dirOptions.Attachments.Enable && !d.IsDir() { downloadAttr = " download" // fixes chrome Resource interpreted, other browsers will just ignore this attribute. } viewName := name if d.IsDir() { viewName += "/" } // name may contain '?' or '#', which must be escaped to remain // part of the URL path, and not indicate the start of a query // string or fragment. _, err = ctx.Writef("%s\n", url.String(), downloadAttr, html.EscapeString(viewName)) if err != nil { return err } } _, err = ctx.WriteString("\n") return err } // DirListRichOptions the options for the `DirListRich` helper function. type DirListRichOptions struct { // If not nil then this template's "dirlist" is used to render the listing page. Tmpl *template.Template // If not empty then this view file is used to render the listing page. // The view should be registered with `Application.RegisterView`. // E.g. "dirlist.html" TmplName string } // DirListRich is a `DirListFunc` which can be passed to `DirOptions.DirList` field // to override the default file listing appearance. // See `DirListRichTemplate` to modify the template, if necessary. func DirListRich(opts ...DirListRichOptions) DirListFunc { var options DirListRichOptions if len(opts) > 0 { options = opts[0] } if options.TmplName == "" && options.Tmpl == nil { options.Tmpl = DirListRichTemplate } return func(ctx *context.Context, dirOptions DirOptions, dirName string, dir http.File) error { dirs, err := dir.Readdir(-1) if err != nil { return err } sortBy := ctx.URLParam("sort") switch sortBy { case "name": sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() }) case "size": sort.Slice(dirs, func(i, j int) bool { return dirs[i].Size() < dirs[j].Size() }) default: sort.Slice(dirs, func(i, j int) bool { return dirs[i].ModTime().After(dirs[j].ModTime()) }) } pageData := listPageData{ Title: fmt.Sprintf("List of %d files", len(dirs)), Files: make([]fileInfoData, 0, len(dirs)), } for _, d := range dirs { name := toBaseName(d.Name()) upath := path.Join(ctx.Request().RequestURI, name) url := url.URL{Path: upath} viewName := name if d.IsDir() { viewName += "/" } shouldDownload := dirOptions.Attachments.Enable && !d.IsDir() pageData.Files = append(pageData.Files, fileInfoData{ Info: d, ModTime: d.ModTime().UTC().Format(http.TimeFormat), Path: url.String(), RelPath: path.Join(ctx.Path(), name), Name: html.EscapeString(viewName), Download: shouldDownload, }) } if options.TmplName != "" { return ctx.View(options.TmplName, pageData) } return options.Tmpl.ExecuteTemplate(ctx, "dirlist", pageData) } } type ( listPageData struct { Title string // the document's title. Files []fileInfoData } fileInfoData struct { Info os.FileInfo ModTime string // format-ed time. Path string // the request path. RelPath string // file path without the system directory itself (we are not exposing it to the user). Name string // the html-escaped name. Download bool // the file should be downloaded (attachment instead of inline view). } ) // FormatBytes returns a string representation of the "b" bytes. func FormatBytes(b int64) string { const unit = 1000 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp]) } // DirListRichTemplate is the html template the `DirListRich` function is using to render // the directories and files. var DirListRichTemplate = template.Must(template.New("dirlist"). Funcs(template.FuncMap{ "formatBytes": FormatBytes, }).Parse(`
# | Name | Size | ||
---|---|---|---|---|
{{ $idx }} | {{ if $file.Download }}{{ $file.Name }} | {{ else }}{{ $file.Name }} | {{ end }} {{ if $file.Info.IsDir }}Dir | {{ else }}{{ formatBytes $file.Info.Size }} | {{ end }}