diff --git a/config.go b/config.go index a3de203..d47a73b 100644 --- a/config.go +++ b/config.go @@ -18,6 +18,8 @@ type Config struct { // Weak etag `W/` Weak bool `mapstructure:"weak"` + Immutable bool `mapstructure:"immutable"` + // forbid specifies a list of file extensions which are forbidden for access. // example: .php, .exe, .bat, .htaccess etc. Forbid []string `mapstructure:"forbid"` diff --git a/etag.go b/etag.go index cc3239a..6e9e43e 100644 --- a/etag.go +++ b/etag.go @@ -14,31 +14,27 @@ var weakPrefix = []byte(`W/`) //nolint:gochecknoglobals // CRC32 table, constant var crc32q = crc32.MakeTable(0x48D90782) //nolint:gochecknoglobals -// SetEtag sets etag for the file -func SetEtag(weak bool, f http.File, name string, w http.ResponseWriter) { +func calculateEtag(weak bool, f http.File, name string) string { // preallocate calculatedEtag := make([]byte, 0, 64) - // write weak if weak { calculatedEtag = append(calculatedEtag, weakPrefix...) calculatedEtag = append(calculatedEtag, '"') calculatedEtag = appendUint(calculatedEtag, crc32.Checksum(strToBytes(name), crc32q)) calculatedEtag = append(calculatedEtag, '"') - - w.Header().Set(etag, bytesToStr(calculatedEtag)) - return + return bytesToStr(calculatedEtag) } // read the file content body, err := io.ReadAll(f) if err != nil { - return + return bytesToStr(calculatedEtag) } // skip for 0 body if len(body) == 0 { - return + return bytesToStr(calculatedEtag) } calculatedEtag = append(calculatedEtag, '"') @@ -47,7 +43,12 @@ func SetEtag(weak bool, f http.File, name string, w http.ResponseWriter) { calculatedEtag = appendUint(calculatedEtag, crc32.Checksum(body, crc32q)) calculatedEtag = append(calculatedEtag, '"') - w.Header().Set(etag, bytesToStr(calculatedEtag)) + return bytesToStr(calculatedEtag) +} + +// SetEtag sets etag for the file +func SetEtag(w http.ResponseWriter, calculatedEtag string) { + w.Header().Set(etag, calculatedEtag) } // appendUint appends n to dst and returns the extended dst. diff --git a/plugin.go b/plugin.go index 7b5b885..fd416c9 100644 --- a/plugin.go +++ b/plugin.go @@ -3,8 +3,11 @@ package static import ( "fmt" "net/http" + "os" "path" + "path/filepath" "strings" + "time" "unsafe" rrcontext "github.com/roadrunner-server/context" @@ -34,6 +37,149 @@ type Logger interface { NamedLogger(name string) *zap.Logger } +type FileServer func(next http.Handler, w http.ResponseWriter, r *http.Request, fp string) + +func createMutableServer(root http.Dir, cfg *Config, logger *zap.Logger) FileServer { + return func(next http.Handler, w http.ResponseWriter, r *http.Request, fp string) { + // ok, file is not in the forbidden list + // Stat it and get file info + f, err := root.Open(fp) + if err != nil { + // else no such file, show error in logs only in debug mode + logger.Debug("no such file or directory", zap.Error(err)) + // pass request to the worker + next.ServeHTTP(w, r) + return + } + + // at high confidence here should not be an error + // because we stat-ed the path previously and know, that that is file (not a dir), and it exists + finfo, err := f.Stat() + if err != nil { + // else no such file, show error in logs only in debug mode + logger.Debug("no such file or directory", zap.Error(err)) + // pass request to the worker + next.ServeHTTP(w, r) + return + } + + defer func() { + err = f.Close() + if err != nil { + logger.Error("file close error", zap.Error(err)) + } + }() + + // if provided path to the dir, do not serve the dir, but pass the request to the worker + if finfo.IsDir() { + logger.Debug("possible path to dir provided") + // pass request to the worker + next.ServeHTTP(w, r) + return + } + + // set etag + if cfg.CalculateEtag { + SetEtag(w, calculateEtag(cfg.Weak, f, finfo.Name())) + } + + if cfg.Request != nil { + for k, v := range cfg.Request { + r.Header.Add(k, v) + } + } + + if cfg.Response != nil { + for k, v := range cfg.Response { + w.Header().Set(k, v) + } + } + + // we passed all checks - serve the file + http.ServeContent(w, r, finfo.Name(), finfo.ModTime(), f) + } +} + +type ScannedFile struct { + file http.File + name string + modTime time.Time + etag string +} + +func createImmutableServer(root http.Dir, cfg *Config, logger *zap.Logger) (FileServer, error) { + var files map[string]ScannedFile + + var scanner func(path string, info os.FileInfo, err error) error + scanner = func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return filepath.Walk(info.Name(), scanner) + } + + file, openError := root.Open(path) + + if openError != nil { + return openError + } + + var etag string + + if cfg.CalculateEtag { + etag = calculateEtag(cfg.Weak, file, info.Name()) + } + + files[path] = ScannedFile{ + file: file, + modTime: info.ModTime(), + name: info.Name(), + etag: etag, + } + + return nil + } + + err := filepath.Walk(string(root), scanner) + + if err != nil { + return nil, err + } + + return func(next http.Handler, w http.ResponseWriter, r *http.Request, fp string) { + file, ok := files[fp] + if ok { + // else no such file, show error in logs only in debug mode + logger.Debug("no such file or directory") + // pass request to the worker + next.ServeHTTP(w, r) + return + } + + // set etag + if file.etag != "" { + SetEtag(w, file.etag) + } + + if cfg.Request != nil { + for k, v := range cfg.Request { + r.Header.Add(k, v) + } + } + + if cfg.Response != nil { + for k, v := range cfg.Response { + w.Header().Set(k, v) + } + } + + // we passed all checks - serve the file + http.ServeContent(w, r, file.name, file.modTime, file.file) + }, nil +} + // Plugin serves static files. Potentially convert into middleware? type Plugin struct { // server configuration (location, forbidden files etc) @@ -48,6 +194,8 @@ type Plugin struct { forbiddenExtensions map[string]struct{} // opentelemetry prop propagation.TextMapPropagator + + fileServer FileServer } // Init must return configure service and return true if the service hasStatus enabled. Must return error in case of @@ -106,6 +254,21 @@ func (s *Plugin) Init(cfg Configurer, log Logger) error { s.prop = propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}, jprop.Jaeger{}) + var server FileServer + + if s.cfg.Immutable { + immutableServer, err := createImmutableServer(s.root, s.cfg, s.log) + if err != nil { + return errors.E(op, err) + } + + server = immutableServer + } else { + server = createMutableServer(s.root, s.cfg, s.log) + } + + s.fileServer = server + // at this point we have distinct allowed and forbidden hashmaps, also with alwaysServed return nil } @@ -115,7 +278,7 @@ func (s *Plugin) Name() string { } // Middleware must return true if a request/response pair is handled within the middleware. -func (s *Plugin) Middleware(next http.Handler) http.Handler { //nolint:gocognit,gocyclo +func (s *Plugin) Middleware(next http.Handler) http.Handler { // Define the http.HandlerFunc return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if val, ok := r.Context().Value(rrcontext.OtelTracerNameKey).(string); ok { @@ -170,63 +333,9 @@ func (s *Plugin) Middleware(next http.Handler) http.Handler { //nolint:gocognit, // file extension allowed } - // ok, file is not in the forbidden list - // Stat it and get file info - f, err := s.root.Open(fp) - if err != nil { - // else no such file, show error in logs only in debug mode - s.log.Debug("no such file or directory", zap.Error(err)) - // pass request to the worker - next.ServeHTTP(w, r) - return - } - // at high confidence here should not be an error - // because we stat-ed the path previously and know, that that is file (not a dir), and it exists - finfo, err := f.Stat() - if err != nil { - // else no such file, show error in logs only in debug mode - s.log.Debug("no such file or directory", zap.Error(err)) - // pass request to the worker - next.ServeHTTP(w, r) - return - } - - defer func() { - err = f.Close() - if err != nil { - s.log.Error("file close error", zap.Error(err)) - } - }() - - // if provided path to the dir, do not serve the dir, but pass the request to the worker - if finfo.IsDir() { - s.log.Debug("possible path to dir provided") - // pass request to the worker - next.ServeHTTP(w, r) - return - } - - // set etag - if s.cfg.CalculateEtag { - SetEtag(s.cfg.Weak, f, finfo.Name(), w) - } - - if s.cfg.Request != nil { - for k, v := range s.cfg.Request { - r.Header.Add(k, v) - } - } - - if s.cfg.Response != nil { - for k, v := range s.cfg.Response { - w.Header().Set(k, v) - } - } - - // we passed all checks - serve the file - http.ServeContent(w, r, finfo.Name(), finfo.ModTime(), f) + s.fileServer(next, w, r, fp) }) }