// Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // HTTP file system request handler package http import ( "errors" "fmt" "internal/safefilepath" "io" "io/fs" "mime" "mime/multipart" "net/textproto" "net/url" "os" "path" "path/filepath" "sort" "strconv" "strings" "time" ) // A Dir implements [FileSystem] using the native file system restricted to a // specific directory tree. // // While the [FileSystem.Open] method takes '/'-separated paths, a Dir's string // value is a filename on the native file system, not a URL, so it is separated // by [filepath.Separator], which isn't necessarily '/'. // // Note that Dir could expose sensitive files and directories. Dir will follow // symlinks pointing out of the directory tree, which can be especially dangerous // if serving from a directory in which users are able to create arbitrary symlinks. // Dir will also allow access to files and directories starting with a period, // which could expose sensitive directories like .git or sensitive files like // .htpasswd. To exclude files with a leading period, remove the files/directories // from the server or create a custom FileSystem implementation. // // An empty Dir is treated as ".". type Dir string // mapOpenError maps the provided non-nil error from opening name // to a possibly better non-nil error. In particular, it turns OS-specific errors // about opening files in non-directories into fs.ErrNotExist. See Issues 18984 and 49552. func mapOpenError(originalErr error, name string, sep rune, stat func(string) (fs.FileInfo, error)) error { if errors.Is(originalErr, fs.ErrNotExist) || errors.Is(originalErr, fs.ErrPermission) { return originalErr } parts := strings.Split(name, string(sep)) for i := range parts { if parts[i] == "" { continue } fi, err := stat(strings.Join(parts[:i+1], string(sep))) if err != nil { return originalErr } if !fi.IsDir() { return fs.ErrNotExist } } return originalErr } // Open implements [FileSystem] using [os.Open], opening files for reading rooted // and relative to the directory d. func (d Dir) Open(name string) (File, error) { path, err := safefilepath.FromFS(path.Clean("/" + name)) if err != nil { return nil, errors.New("http: invalid or unsafe file path") } dir := string(d) if dir == "" { dir = "." } fullName := filepath.Join(dir, path) f, err := os.Open(fullName) if err != nil { return nil, mapOpenError(err, fullName, filepath.Separator, os.Stat) } return f, nil } // A FileSystem implements access to a collection of named files. // The elements in a file path are separated by slash ('/', U+002F) // characters, regardless of host operating system convention. // See the [FileServer] function to convert a FileSystem to a [Handler]. // // This interface predates the [fs.FS] interface, which can be used instead: // the [FS] adapter function converts an fs.FS to a FileSystem. type FileSystem interface { Open(name string) (File, error) } // A File is returned by a [FileSystem]'s Open method and can be // served by the [FileServer] implementation. // // The methods should behave the same as those on an [*os.File]. type File interface { io.Closer io.Reader io.Seeker Readdir(count int) ([]fs.FileInfo, error) Stat() (fs.FileInfo, error) } type anyDirs interface { len() int name(i int) string isDir(i int) bool } type fileInfoDirs []fs.FileInfo func (d fileInfoDirs) len() int { return len(d) } func (d fileInfoDirs) isDir(i int) bool { return d[i].IsDir() } func (d fileInfoDirs) name(i int) string { return d[i].Name() } type dirEntryDirs []fs.DirEntry func (d dirEntryDirs) len() int { return len(d) } func (d dirEntryDirs) isDir(i int) bool { return d[i].IsDir() } func (d dirEntryDirs) name(i int) string { return d[i].Name() } func dirList(w ResponseWriter, r *Request, f File) { // Prefer to use ReadDir instead of Readdir, // because the former doesn't require calling // Stat on every entry of a directory on Unix. var dirs anyDirs var err error if d, ok := f.(fs.ReadDirFile); ok { var list dirEntryDirs list, err = d.ReadDir(-1) dirs = list } else { var list fileInfoDirs list, err = f.Readdir(-1) dirs = list } if err != nil { logf(r, "http: error reading directory: %v", err) Error(w, "Error reading directory", StatusInternalServerError) return } sort.Slice(dirs, func(i, j int) bool { return dirs.name(i) < dirs.name(j) }) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, "
\n") for i, n := 0, dirs.len(); i < n; i++ { name := dirs.name(i) if dirs.isDir(i) { name += "/" } // 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. url := url.URL{Path: name} fmt.Fprintf(w, "%s\n", url.String(), htmlReplacer.Replace(name)) } fmt.Fprintf(w, "\n") } // ServeContent replies to the request using the content in the // provided ReadSeeker. The main benefit of ServeContent over [io.Copy] // is that it handles Range requests properly, sets the MIME type, and // handles If-Match, If-Unmodified-Since, If-None-Match, If-Modified-Since, // and If-Range requests. // // If the response's Content-Type header is not set, ServeContent // first tries to deduce the type from name's file extension and, // if that fails, falls back to reading the first block of the content // and passing it to [DetectContentType]. // The name is otherwise unused; in particular it can be empty and is // never sent in the response. // // If modtime is not the zero time or Unix epoch, ServeContent // includes it in a Last-Modified header in the response. If the // request includes an If-Modified-Since header, ServeContent uses // modtime to decide whether the content needs to be sent at all. // // The content's Seek method must work: ServeContent uses // a seek to the end of the content to determine its size. // // If the caller has set w's ETag header formatted per RFC 7232, section 2.3, // ServeContent uses it to handle requests using If-Match, If-None-Match, or If-Range. // // Note that [*os.File] implements the [io.ReadSeeker] interface. func ServeContent(w ResponseWriter, req *Request, name string, modtime time.Time, content io.ReadSeeker) { sizeFunc := func() (int64, error) { size, err := content.Seek(0, io.SeekEnd) if err != nil { return 0, errSeeker } _, err = content.Seek(0, io.SeekStart) if err != nil { return 0, errSeeker } return size, nil } serveContent(w, req, name, modtime, sizeFunc, content) } // errSeeker is returned by ServeContent's sizeFunc when the content // doesn't seek properly. The underlying Seeker's error text isn't // included in the sizeFunc reply so it's not sent over HTTP to end // users. var errSeeker = errors.New("seeker can't seek") // errNoOverlap is returned by serveContent's parseRange if first-byte-pos of // all of the byte-range-spec values is greater than the content size. var errNoOverlap = errors.New("invalid range: failed to overlap") // if name is empty, filename is unknown. (used for mime type, before sniffing) // if modtime.IsZero(), modtime is unknown. // content must be seeked to the beginning of the file. // The sizeFunc is called at most once. Its error, if any, is sent in the HTTP response. func serveContent(w ResponseWriter, r *Request, name string, modtime time.Time, sizeFunc func() (int64, error), content io.ReadSeeker) { setLastModified(w, modtime) done, rangeReq := checkPreconditions(w, r, modtime) if done { return } code := StatusOK // 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 := w.Header()["Content-Type"] var ctype string if !haveType { ctype = mime.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 = DetectContentType(buf[:n]) _, err := content.Seek(0, io.SeekStart) // rewind to output whole file if err != nil { Error(w, "seeker can't seek", StatusInternalServerError) return } } w.Header().Set("Content-Type", ctype) } else if len(ctypes) > 0 { ctype = ctypes[0] } size, err := sizeFunc() if err != nil { Error(w, err.Error(), StatusInternalServerError) return } if size < 0 { // Should never happen but just to be sure Error(w, "negative content size computed", StatusInternalServerError) return } // handle Content-Range header. sendSize := size var sendContent io.Reader = content ranges, err := parseRange(rangeReq, size) switch err { case nil: case errNoOverlap: if size == 0 { // Some clients add a Range header to all requests to // limit the size of the response. If the file is empty, // ignore the range header and respond with a 200 rather // than a 416. ranges = nil break } w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size)) fallthrough default: Error(w, err.Error(), StatusRequestedRangeNotSatisfiable) return } if sumRangesSize(ranges) > size { // The total number of bytes in all the ranges // is larger than the size of the file by // itself, so this is probably an attack, or a // dumb client. Ignore the range request. ranges = nil } switch { case len(ranges) == 1: // RFC 7233, Section 4.1: // "If a single part is being transferred, the server // generating the 206 response MUST generate a // Content-Range header field, describing what range // of the selected representation is enclosed, and a // payload consisting of the range. // ... // A server MUST NOT generate a multipart response to // a request for a single range, since a client that // does not request multiple parts might not support // multipart responses." ra := ranges[0] if _, err := content.Seek(ra.start, io.SeekStart); err != nil { Error(w, err.Error(), StatusRequestedRangeNotSatisfiable) return } sendSize = ra.length code = StatusPartialContent w.Header().Set("Content-Range", ra.contentRange(size)) case len(ranges) > 1: sendSize = rangesMIMESize(ranges, ctype, size) code = StatusPartialContent pr, pw := io.Pipe() mw := multipart.NewWriter(pw) w.Header().Set("Content-Type", "multipart/byteranges; boundary="+mw.Boundary()) sendContent = pr defer pr.Close() // cause writing goroutine to fail and exit if CopyN doesn't finish. go func() { for _, ra := range ranges { part, err := mw.CreatePart(ra.mimeHeader(ctype, size)) if err != nil { pw.CloseWithError(err) return } if _, err := content.Seek(ra.start, io.SeekStart); err != nil { pw.CloseWithError(err) return } if _, err := io.CopyN(part, content, ra.length); err != nil { pw.CloseWithError(err) return } } mw.Close() pw.Close() }() } w.Header().Set("Accept-Ranges", "bytes") // We should be able to unconditionally set the Content-Length here. // // However, there is a pattern observed in the wild that this breaks: // The user wraps the ResponseWriter in one which gzips data written to it, // and sets "Content-Encoding: gzip". // // The user shouldn't be doing this; the serveContent path here depends // on serving seekable data with a known length. If you want to compress // on the fly, then you shouldn't be using ServeFile/ServeContent, or // you should compress the entire file up-front and provide a seekable // view of the compressed data. // // However, since we've observed this pattern in the wild, and since // setting Content-Length here breaks code that mostly-works today, // skip setting Content-Length if the user set Content-Encoding. // // If this is a range request, always set Content-Length. // If the user isn't changing the bytes sent in the ResponseWrite, // the Content-Length will be correct. // If the user is changing the bytes sent, then the range request wasn't // going to work properly anyway and we aren't worse off. // // A possible future improvement on this might be to look at the type // of the ResponseWriter, and always set Content-Length if it's one // that we recognize. if len(ranges) > 0 || w.Header().Get("Content-Encoding") == "" { w.Header().Set("Content-Length", strconv.FormatInt(sendSize, 10)) } w.WriteHeader(code) if r.Method != "HEAD" { io.CopyN(w, sendContent, sendSize) } } // scanETag determines if a syntactically valid ETag is present at s. If so, // the ETag and remaining text after consuming ETag is returned. Otherwise, // it returns "", "". func scanETag(s string) (etag string, remain string) { s = textproto.TrimString(s) start := 0 if strings.HasPrefix(s, "W/") { start = 2 } if len(s[start:]) < 2 || s[start] != '"' { return "", "" } // ETag is either W/"text" or "text". // See RFC 7232 2.3. for i := start + 1; i < len(s); i++ { c := s[i] switch { // Character values allowed in ETags. case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80: case c == '"': return s[:i+1], s[i+1:] default: return "", "" } } return "", "" } // etagStrongMatch reports whether a and b match using strong ETag comparison. // Assumes a and b are valid ETags. func etagStrongMatch(a, b string) bool { return a == b && a != "" && a[0] == '"' } // etagWeakMatch reports whether a and b match using weak ETag comparison. // Assumes a and b are valid ETags. func etagWeakMatch(a, b string) bool { return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/") } // condResult is the result of an HTTP request precondition check. // See https://tools.ietf.org/html/rfc7232 section 3. type condResult int const ( condNone condResult = iota condTrue condFalse ) func checkIfMatch(w ResponseWriter, r *Request) condResult { im := r.Header.Get("If-Match") if im == "" { return condNone } for { im = textproto.TrimString(im) if len(im) == 0 { break } if im[0] == ',' { im = im[1:] continue } if im[0] == '*' { return condTrue } etag, remain := scanETag(im) if etag == "" { break } if etagStrongMatch(etag, w.Header().get("Etag")) { return condTrue } im = remain } return condFalse } func checkIfUnmodifiedSince(r *Request, modtime time.Time) condResult { ius := r.Header.Get("If-Unmodified-Since") if ius == "" || isZeroTime(modtime) { return condNone } t, err := ParseTime(ius) if err != nil { return condNone } // The Last-Modified header truncates sub-second precision so // the modtime needs to be truncated too. modtime = modtime.Truncate(time.Second) if ret := modtime.Compare(t); ret <= 0 { return condTrue } return condFalse } func checkIfNoneMatch(w ResponseWriter, r *Request) condResult { inm := r.Header.get("If-None-Match") if inm == "" { return condNone } buf := inm for { buf = textproto.TrimString(buf) if len(buf) == 0 { break } if buf[0] == ',' { buf = buf[1:] continue } if buf[0] == '*' { return condFalse } etag, remain := scanETag(buf) if etag == "" { break } if etagWeakMatch(etag, w.Header().get("Etag")) { return condFalse } buf = remain } return condTrue } func checkIfModifiedSince(r *Request, modtime time.Time) condResult { if r.Method != "GET" && r.Method != "HEAD" { return condNone } ims := r.Header.Get("If-Modified-Since") if ims == "" || isZeroTime(modtime) { return condNone } t, err := ParseTime(ims) if err != nil { return condNone } // The Last-Modified header truncates sub-second precision so // the modtime needs to be truncated too. modtime = modtime.Truncate(time.Second) if ret := modtime.Compare(t); ret <= 0 { return condFalse } return condTrue } func checkIfRange(w ResponseWriter, r *Request, modtime time.Time) condResult { if r.Method != "GET" && r.Method != "HEAD" { return condNone } ir := r.Header.get("If-Range") if ir == "" { return condNone } etag, _ := scanETag(ir) if etag != "" { if etagStrongMatch(etag, w.Header().Get("Etag")) { return condTrue } else { return condFalse } } // The If-Range value is typically the ETag value, but it may also be // the modtime date. See golang.org/issue/8367. if modtime.IsZero() { return condFalse } t, err := ParseTime(ir) if err != nil { return condFalse } if t.Unix() == modtime.Unix() { return condTrue } return condFalse } var unixEpochTime = time.Unix(0, 0) // isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0). func isZeroTime(t time.Time) bool { return t.IsZero() || t.Equal(unixEpochTime) } func setLastModified(w ResponseWriter, modtime time.Time) { if !isZeroTime(modtime) { w.Header().Set("Last-Modified", modtime.UTC().Format(TimeFormat)) } } func writeNotModified(w ResponseWriter) { // RFC 7232 section 4.1: // a sender SHOULD NOT generate representation metadata other than the // above listed fields unless said metadata exists for the purpose of // guiding cache updates (e.g., Last-Modified might be useful if the // response does not have an ETag field). h := w.Header() delete(h, "Content-Type") delete(h, "Content-Length") delete(h, "Content-Encoding") if h.Get("Etag") != "" { delete(h, "Last-Modified") } w.WriteHeader(StatusNotModified) } // checkPreconditions evaluates request preconditions and reports whether a precondition // resulted in sending StatusNotModified or StatusPreconditionFailed. func checkPreconditions(w ResponseWriter, r *Request, modtime time.Time) (done bool, rangeHeader string) { // This function carefully follows RFC 7232 section 6. ch := checkIfMatch(w, r) if ch == condNone { ch = checkIfUnmodifiedSince(r, modtime) } if ch == condFalse { w.WriteHeader(StatusPreconditionFailed) return true, "" } switch checkIfNoneMatch(w, r) { case condFalse: if r.Method == "GET" || r.Method == "HEAD" { writeNotModified(w) return true, "" } else { w.WriteHeader(StatusPreconditionFailed) return true, "" } case condNone: if checkIfModifiedSince(r, modtime) == condFalse { writeNotModified(w) return true, "" } } rangeHeader = r.Header.get("Range") if rangeHeader != "" && checkIfRange(w, r, modtime) == condFalse { rangeHeader = "" } return false, rangeHeader } // name is '/'-separated, not filepath.Separator. func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirect bool) { const indexPage = "/index.html" // redirect .../index.html to .../ // can't use Redirect() because that would make the path absolute, // which would be a problem running under StripPrefix if strings.HasSuffix(r.URL.Path, indexPage) { localRedirect(w, r, "./") return } f, err := fs.Open(name) if err != nil { msg, code := toHTTPError(err) Error(w, msg, code) return } defer f.Close() d, err := f.Stat() if err != nil { msg, code := toHTTPError(err) Error(w, msg, code) return } if redirect { // redirect to canonical path: / at end of directory url // r.URL.Path always begins with / url := r.URL.Path if d.IsDir() { if url[len(url)-1] != '/' { localRedirect(w, r, path.Base(url)+"/") return } } else { if url[len(url)-1] == '/' { localRedirect(w, r, "../"+path.Base(url)) return } } } if d.IsDir() { url := r.URL.Path // redirect if the directory name doesn't end in a slash if url == "" || url[len(url)-1] != '/' { localRedirect(w, r, path.Base(url)+"/") return } // use contents of index.html for directory, if present index := strings.TrimSuffix(name, "/") + indexPage ff, err := fs.Open(index) if err == nil { defer ff.Close() dd, err := ff.Stat() if err == nil { d = dd f = ff } } } // Still a directory? (we didn't find an index.html file) if d.IsDir() { if checkIfModifiedSince(r, d.ModTime()) == condFalse { writeNotModified(w) return } setLastModified(w, d.ModTime()) dirList(w, r, f) return } // serveContent will check modification time sizeFunc := func() (int64, error) { return d.Size(), nil } serveContent(w, r, d.Name(), d.ModTime(), sizeFunc, f) } // toHTTPError returns a non-specific HTTP error message and status code // for a given non-nil error value. It's important that toHTTPError does not // actually return err.Error(), since msg and httpStatus are returned to users, // and historically Go's ServeContent always returned just "404 Not Found" for // all errors. We don't want to start leaking information in error messages. func toHTTPError(err error) (msg string, httpStatus int) { if errors.Is(err, fs.ErrNotExist) { return "404 page not found", StatusNotFound } if errors.Is(err, fs.ErrPermission) { return "403 Forbidden", StatusForbidden } // Default: return "500 Internal Server Error", StatusInternalServerError } // localRedirect gives a Moved Permanently response. // It does not convert relative paths to absolute paths like Redirect does. func localRedirect(w ResponseWriter, r *Request, newPath string) { if q := r.URL.RawQuery; q != "" { newPath += "?" + q } w.Header().Set("Location", newPath) w.WriteHeader(StatusMovedPermanently) } // ServeFile replies to the request with the contents of the named // file or directory. // // If the provided file or directory name is a relative path, it is // interpreted relative to the current directory and may ascend to // parent directories. If the provided name is constructed from user // input, it should be sanitized before calling ServeFile. // // As a precaution, ServeFile will reject requests where r.URL.Path // contains a ".." path element; this protects against callers who // might unsafely use [filepath.Join] on r.URL.Path without sanitizing // it and then use that filepath.Join result as the name argument. // // As another special case, ServeFile redirects any request where r.URL.Path // ends in "/index.html" to the same path, without the final // "index.html". To avoid such redirects either modify the path or // use [ServeContent]. // // Outside of those two special cases, ServeFile does not use // r.URL.Path for selecting the file or directory to serve; only the // file or directory provided in the name argument is used. func ServeFile(w ResponseWriter, r *Request, name string) { if containsDotDot(r.URL.Path) { // Too many programs use r.URL.Path to construct the argument to // serveFile. Reject the request under the assumption that happened // here and ".." may not be wanted. // Note that name might not contain "..", for example if code (still // incorrectly) used filepath.Join(myDir, r.URL.Path). Error(w, "invalid URL path", StatusBadRequest) return } dir, file := filepath.Split(name) serveFile(w, r, Dir(dir), file, false) } // ServeFileFS replies to the request with the contents // of the named file or directory from the file system fsys. // // If the provided file or directory name is a relative path, it is // interpreted relative to the current directory and may ascend to // parent directories. If the provided name is constructed from user // input, it should be sanitized before calling [ServeFile]. // // As a precaution, ServeFile will reject requests where r.URL.Path // contains a ".." path element; this protects against callers who // might unsafely use [filepath.Join] on r.URL.Path without sanitizing // it and then use that filepath.Join result as the name argument. // // As another special case, ServeFile redirects any request where r.URL.Path // ends in "/index.html" to the same path, without the final // "index.html". To avoid such redirects either modify the path or // use ServeContent. // // Outside of those two special cases, ServeFile does not use // r.URL.Path for selecting the file or directory to serve; only the // file or directory provided in the name argument is used. func ServeFileFS(w ResponseWriter, r *Request, fsys fs.FS, name string) { if containsDotDot(r.URL.Path) { // Too many programs use r.URL.Path to construct the argument to // serveFile. Reject the request under the assumption that happened // here and ".." may not be wanted. // Note that name might not contain "..", for example if code (still // incorrectly) used filepath.Join(myDir, r.URL.Path). Error(w, "invalid URL path", StatusBadRequest) return } serveFile(w, r, FS(fsys), name, false) } func containsDotDot(v string) bool { if !strings.Contains(v, "..") { return false } for _, ent := range strings.FieldsFunc(v, isSlashRune) { if ent == ".." { return true } } return false } func isSlashRune(r rune) bool { return r == '/' || r == '\\' } type fileHandler struct { root FileSystem } type ioFS struct { fsys fs.FS } type ioFile struct { file fs.File } func (f ioFS) Open(name string) (File, error) { if name == "/" { name = "." } else { name = strings.TrimPrefix(name, "/") } file, err := f.fsys.Open(name) if err != nil { return nil, mapOpenError(err, name, '/', func(path string) (fs.FileInfo, error) { return fs.Stat(f.fsys, path) }) } return ioFile{file}, nil } func (f ioFile) Close() error { return f.file.Close() } func (f ioFile) Read(b []byte) (int, error) { return f.file.Read(b) } func (f ioFile) Stat() (fs.FileInfo, error) { return f.file.Stat() } var errMissingSeek = errors.New("io.File missing Seek method") var errMissingReadDir = errors.New("io.File directory missing ReadDir method") func (f ioFile) Seek(offset int64, whence int) (int64, error) { s, ok := f.file.(io.Seeker) if !ok { return 0, errMissingSeek } return s.Seek(offset, whence) } func (f ioFile) ReadDir(count int) ([]fs.DirEntry, error) { d, ok := f.file.(fs.ReadDirFile) if !ok { return nil, errMissingReadDir } return d.ReadDir(count) } func (f ioFile) Readdir(count int) ([]fs.FileInfo, error) { d, ok := f.file.(fs.ReadDirFile) if !ok { return nil, errMissingReadDir } var list []fs.FileInfo for { dirs, err := d.ReadDir(count - len(list)) for _, dir := range dirs { info, err := dir.Info() if err != nil { // Pretend it doesn't exist, like (*os.File).Readdir does. continue } list = append(list, info) } if err != nil { return list, err } if count < 0 || len(list) >= count { break } } return list, nil } // FS converts fsys to a [FileSystem] implementation, // for use with [FileServer] and [NewFileTransport]. // The files provided by fsys must implement [io.Seeker]. func FS(fsys fs.FS) FileSystem { return ioFS{fsys} } // FileServer returns a handler that serves HTTP requests // with the contents of the file system rooted at root. // // As a special case, the returned file server redirects any request // ending in "/index.html" to the same path, without the final // "index.html". // // To use the operating system's file system implementation, // use [http.Dir]: // // http.Handle("/", http.FileServer(http.Dir("/tmp"))) // // To use an [fs.FS] implementation, use [http.FileServerFS] instead. func FileServer(root FileSystem) Handler { return &fileHandler{root} } // FileServerFS returns a handler that serves HTTP requests // with the contents of the file system fsys. // // As a special case, the returned file server redirects any request // ending in "/index.html" to the same path, without the final // "index.html". // // http.Handle("/", http.FileServerFS(fsys)) func FileServerFS(root fs.FS) Handler { return FileServer(FS(root)) } func (f *fileHandler) ServeHTTP(w ResponseWriter, r *Request) { upath := r.URL.Path if !strings.HasPrefix(upath, "/") { upath = "/" + upath r.URL.Path = upath } serveFile(w, r, f.root, path.Clean(upath), true) } // httpRange specifies the byte range to be sent to the client. type httpRange struct { start, length int64 } func (r httpRange) contentRange(size int64) string { return fmt.Sprintf("bytes %d-%d/%d", r.start, r.start+r.length-1, size) } func (r httpRange) mimeHeader(contentType string, size int64) textproto.MIMEHeader { return textproto.MIMEHeader{ "Content-Range": {r.contentRange(size)}, "Content-Type": {contentType}, } } // parseRange parses a Range header string as per RFC 7233. // errNoOverlap is returned if none of the ranges overlap. func parseRange(s string, size int64) ([]httpRange, error) { if s == "" { return nil, nil // header not present } const b = "bytes=" if !strings.HasPrefix(s, b) { return nil, errors.New("invalid range") } var ranges []httpRange noOverlap := false for _, ra := range strings.Split(s[len(b):], ",") { ra = textproto.TrimString(ra) if ra == "" { continue } start, end, ok := strings.Cut(ra, "-") if !ok { return nil, errors.New("invalid range") } start, end = textproto.TrimString(start), textproto.TrimString(end) var r httpRange if start == "" { // If no start is specified, end specifies the // range start relative to the end of the file, // and we are dealing with