Files
Dmitry VerkhoturovandGitHub 1f34984dab Bump go-pkgz/rest to v1.24.0 and opt in to wildcard origins with credentials (#2157)
rest.CORS refuses "*" together with credentials since go-pkgz/rest#52, so the
bump and the option have to land together: the option does not exist in v1.22.0
and the panic fires at construction, inside routes(), which makes it a startup
failure rather than a request-time one.

The wildcard stays. The comment widget is embedded on arbitrary third-party
sites, so the set of origins is not knowable, which is why the escape hatch was
asked for upstream instead of accepting the panic. What it costs is unchanged
and now written next to the call: any site a signed-in user visits can read
authenticated responses, so state-changing requests have to keep being protected
by something other than the origin, X-XSRF-Token today.

The example module is tidied in the same commit, as it reaches go-pkgz/rest
through the replace directive and its indirect graph would otherwise keep the
old pin and fail the readonly module check in CI.

The bump also carries testify to v1.12.0, which drops go-spew and go-difflib
from the module graph.
2026-08-19 00:33:13 -05:00

192 lines
5.1 KiB
Go

package rest
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
// FS provides http.FileServer handler to serve static files from a http.FileSystem,
// prevents directory listing by default and supports spa-friendly mode (off by default) returning /index.html on 404.
// - public defines base path of the url, i.e. for http://example.com/static/* it should be /static
// - local for the local path to the root of the served directory
// - notFound is the reader for the custom 404 html, can be nil for default
type FS struct {
public, root string
notFound io.Reader
isSpa bool
enableListing bool
handler http.HandlerFunc
}
// NewFileServer creates file server with optional spa mode and optional direcroty listing (disabled by default)
func NewFileServer(public, local string, options ...FsOpt) (*FS, error) {
res := FS{
public: public,
notFound: nil,
isSpa: false,
enableListing: false,
}
root, err := filepath.Abs(local)
if err != nil {
return nil, fmt.Errorf("can't get absolute path for %s: %w", local, err)
}
res.root = root
if _, err = os.Stat(root); os.IsNotExist(err) {
return nil, fmt.Errorf("local path %s doesn't exist: %w", root, err)
}
for _, opt := range options {
err = opt(&res)
if err != nil {
return nil, err
}
}
cfs := customFS{
fs: http.Dir(root),
spa: res.isSpa,
listing: res.enableListing,
}
f := http.StripPrefix(public, http.FileServer(cfs))
res.handler = func(w http.ResponseWriter, r *http.Request) { f.ServeHTTP(w, r) }
if !res.enableListing {
h, err := custom404Handler(f, res.notFound)
if err != nil {
return nil, err
}
res.handler = func(w http.ResponseWriter, r *http.Request) { h.ServeHTTP(w, r) }
}
return &res, nil
}
// FileServer is a shortcut for making FS with listing disabled and the custom noFound reader (can be nil).
//
// Deprecated: the method is for back-compatibility only and user should use the universal NewFileServer instead
func FileServer(public, local string, notFound io.Reader) (http.Handler, error) {
return NewFileServer(public, local, FsOptCustom404(notFound))
}
// FileServerSPA is a shortcut for making FS with SPA-friendly handling of 404, listing disabled and the custom noFound reader (can be nil).
//
// Deprecated: the method is for back-compatibility only and user should use the universal NewFileServer instead
func FileServerSPA(public, local string, notFound io.Reader) (http.Handler, error) {
return NewFileServer(public, local, FsOptCustom404(notFound), FsOptSPA)
}
// ServeHTTP makes FileServer compatible with http.Handler interface
func (fs *FS) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fs.handler(w, r)
}
// FsOpt defines functional option type
type FsOpt func(fs *FS) error
// FsOptSPA turns on SPA mode returning "/index.html" on not-found
func FsOptSPA(fs *FS) error {
fs.isSpa = true
return nil
}
// FsOptListing turns on directory listing
func FsOptListing(fs *FS) error {
fs.enableListing = true
return nil
}
// FsOptCustom404 sets custom 404 reader
func FsOptCustom404(fr io.Reader) FsOpt {
return func(fs *FS) error {
fs.notFound = fr
return nil
}
}
// customFS wraps http.FileSystem with spa and no-listing optional support
type customFS struct {
fs http.FileSystem
spa bool
listing bool
}
// Open file on FS, for directory enforce index.html and fail on a missing index.
// Every handle opened here is either returned to the caller or closed, as http.FileServer
// closes only the file it gets back.
func (cfs customFS) Open(name string) (http.File, error) {
f, err := cfs.fs.Open(name)
if err != nil {
if cfs.spa {
return cfs.fs.Open("/index.html")
}
return nil, err
}
finfo, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, err
}
if finfo.IsDir() {
index := strings.TrimSuffix(name, "/") + "/index.html"
indexFile, ierr := cfs.fs.Open(index)
if ierr == nil { // index.html will be served if found
_ = indexFile.Close() // opened to probe for existence only, http.FileServer opens it again on its own
return f, nil
}
// no index.html in directory
if !cfs.listing { // listing disabled
_ = f.Close()
return nil, ierr
}
}
return f, nil
}
// respWriter404 intercept Write to provide custom 404 response
type respWriter404 struct {
http.ResponseWriter
status int
msg []byte
}
func (w *respWriter404) WriteHeader(status int) {
w.status = status
if status == http.StatusNotFound {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
}
w.ResponseWriter.WriteHeader(status)
}
func (w *respWriter404) Write(p []byte) (n int, err error) {
if w.status != http.StatusNotFound || w.msg == nil {
return w.ResponseWriter.Write(p)
}
_, err = w.ResponseWriter.Write(w.msg)
return len(p), err
}
func custom404Handler(next http.Handler, notFound io.Reader) (http.Handler, error) {
if notFound == nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) }), nil
}
body, err := io.ReadAll(notFound)
if err != nil {
return nil, err
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(&respWriter404{ResponseWriter: w, msg: body}, r)
}), nil
}