diff options
author | Valery Piashchynski <[email protected]> | 2021-05-13 17:15:00 +0300 |
---|---|---|
committer | Valery Piashchynski <[email protected]> | 2021-05-13 17:15:00 +0300 |
commit | 2be94ad0400e2f523d87f47e09a7bf505edef689 (patch) | |
tree | 1824c8ee28d0c6ce2884b99d0a4eaa99dcaa9cbb /plugins/static | |
parent | 705b69631dc91323c64a19594dcfeca06ea4fa5a (diff) |
- Remove unsafe casting (replace with a less unsafe)
- Make the static plugin great again (separate plugin)
- Revert new behavior
Signed-off-by: Valery Piashchynski <[email protected]>
Diffstat (limited to 'plugins/static')
-rw-r--r-- | plugins/static/config.go | 55 | ||||
-rw-r--r-- | plugins/static/etag.go | 72 | ||||
-rw-r--r-- | plugins/static/plugin.go | 189 |
3 files changed, 316 insertions, 0 deletions
diff --git a/plugins/static/config.go b/plugins/static/config.go new file mode 100644 index 00000000..c3f9c17d --- /dev/null +++ b/plugins/static/config.go @@ -0,0 +1,55 @@ +package static + +import ( + "os" + + "github.com/spiral/errors" +) + +// Config describes file location and controls access to them. +type Config struct { + Static *struct { + // Dir contains name of directory to control access to. + // Default - "." + Dir string + + // CalculateEtag can be true/false and used to calculate etag for the static + CalculateEtag bool `mapstructure:"calculate_etag"` + + // Weak etag `W/` + Weak bool + + // forbid specifies list of file extensions which are forbidden for access. + // example: .php, .exe, .bat, .htaccess and etc. + Forbid []string + + // Allow specifies list of file extensions which are allowed for access. + // example: .php, .exe, .bat, .htaccess and etc. + Allow []string + + // Request headers to add to every static. + Request map[string]string + + // Response headers to add to every static. + Response map[string]string + } +} + +// Valid returns nil if config is valid. +func (c *Config) Valid() error { + const op = errors.Op("static_plugin_valid") + st, err := os.Stat(c.Static.Dir) + if err != nil { + if os.IsNotExist(err) { + return errors.E(op, errors.Errorf("root directory '%s' does not exists", c.Static.Dir)) + } + + return err + } + + if !st.IsDir() { + return errors.E(op, errors.Errorf("invalid root directory '%s'", c.Static.Dir)) + } + + return nil +} diff --git a/plugins/static/etag.go b/plugins/static/etag.go new file mode 100644 index 00000000..5ee0d2f3 --- /dev/null +++ b/plugins/static/etag.go @@ -0,0 +1,72 @@ +package static + +import ( + "hash/crc32" + "io" + "net/http" + + "github.com/spiral/roadrunner/v2/utils" +) + +const etag string = "Etag" + +// weak Etag prefix +var weakPrefix = []byte(`W/`) + +// CRC32 table +var crc32q = crc32.MakeTable(0x48D90782) + +// SetEtag sets etag for the file +func SetEtag(weak bool, f http.File, name string, w http.ResponseWriter) { + // preallocate + calculatedEtag := make([]byte, 0, 64) + + // write weak + if weak { + calculatedEtag = append(calculatedEtag, weakPrefix...) + calculatedEtag = append(calculatedEtag, '"') + calculatedEtag = appendUint(calculatedEtag, crc32.Checksum(utils.AsBytes(name), crc32q)) + calculatedEtag = append(calculatedEtag, '"') + + w.Header().Set(etag, utils.AsString(calculatedEtag)) + return + } + + // read the file content + body, err := io.ReadAll(f) + if err != nil { + return + } + + // skip for 0 body + if len(body) == 0 { + return + } + + calculatedEtag = append(calculatedEtag, '"') + calculatedEtag = appendUint(calculatedEtag, uint32(len(body))) + calculatedEtag = append(calculatedEtag, '-') + calculatedEtag = appendUint(calculatedEtag, crc32.Checksum(body, crc32q)) + calculatedEtag = append(calculatedEtag, '"') + + w.Header().Set(etag, utils.AsString(calculatedEtag)) +} + +// appendUint appends n to dst and returns the extended dst. +func appendUint(dst []byte, n uint32) []byte { + var b [20]byte + buf := b[:] + i := len(buf) + var q uint32 + for n >= 10 { + i-- + q = n / 10 + buf[i] = '0' + byte(n-q*10) + n = q + } + i-- + buf[i] = '0' + byte(n) + + dst = append(dst, buf[i:]...) + return dst +} diff --git a/plugins/static/plugin.go b/plugins/static/plugin.go new file mode 100644 index 00000000..f2d8ee3f --- /dev/null +++ b/plugins/static/plugin.go @@ -0,0 +1,189 @@ +package static + +import ( + "net/http" + "path" + "strings" + + "github.com/spiral/errors" + "github.com/spiral/roadrunner/v2/plugins/config" + "github.com/spiral/roadrunner/v2/plugins/logger" +) + +// PluginName contains default service name. +const PluginName = "static" + +const RootPluginName = "http" + +// Plugin serves static files. Potentially convert into middleware? +type Plugin struct { + // server configuration (location, forbidden files and etc) + cfg *Config + + log logger.Logger + + // root is initiated http directory + root http.Dir + + // file extensions which are allowed to be served + allowedExtensions map[string]struct{} + + // file extensions which are forbidden to be served + forbiddenExtensions map[string]struct{} +} + +// Init must return configure service and return true if service hasStatus enabled. Must return error in case of +// misconfiguration. Services must not be used without proper configuration pushed first. +func (s *Plugin) Init(cfg config.Configurer, log logger.Logger) error { + const op = errors.Op("static_plugin_init") + if !cfg.Has(RootPluginName) { + return errors.E(op, errors.Disabled) + } + + err := cfg.UnmarshalKey(RootPluginName, &s.cfg) + if err != nil { + return errors.E(op, errors.Disabled, err) + } + + if s.cfg.Static == nil { + return errors.E(op, errors.Disabled) + } + + s.log = log + s.root = http.Dir(s.cfg.Static.Dir) + + err = s.cfg.Valid() + if err != nil { + return errors.E(op, err) + } + + // create 2 hashmaps with the allowed and forbidden file extensions + s.allowedExtensions = make(map[string]struct{}, len(s.cfg.Static.Allow)) + s.forbiddenExtensions = make(map[string]struct{}, len(s.cfg.Static.Forbid)) + + // init forbidden + for i := 0; i < len(s.cfg.Static.Forbid); i++ { + // skip empty lines + if s.cfg.Static.Forbid[i] == "" { + continue + } + s.forbiddenExtensions[s.cfg.Static.Forbid[i]] = struct{}{} + } + + // init allowed + for i := 0; i < len(s.cfg.Static.Allow); i++ { + // skip empty lines + if s.cfg.Static.Allow[i] == "" { + continue + } + s.allowedExtensions[s.cfg.Static.Allow[i]] = struct{}{} + } + + // check if any forbidden items presented in the allowed + // if presented, delete such items from allowed + for k := range s.forbiddenExtensions { + delete(s.allowedExtensions, k) + } + + // at this point we have distinct allowed and forbidden hashmaps, also with alwaysServed + return nil +} + +func (s *Plugin) Name() string { + return PluginName +} + +// Middleware must return true if request/response pair is handled within the middleware. +func (s *Plugin) Middleware(next http.Handler) http.Handler { + // Define the http.HandlerFunc + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // do not allow paths like ../../resource + // only specified folder and resources in it + // https://lgtm.com/rules/1510366186013/ + if strings.Contains(r.URL.Path, "..") { + w.WriteHeader(http.StatusForbidden) + return + } + + if s.cfg.Static.Request != nil { + for k, v := range s.cfg.Static.Request { + r.Header.Add(k, v) + } + } + + if s.cfg.Static.Response != nil { + for k, v := range s.cfg.Static.Response { + w.Header().Set(k, v) + } + } + + // first - create a proper file path + fPath := path.Clean(r.URL.Path) + ext := strings.ToLower(path.Ext(fPath)) + + // check that file extension in the forbidden list + if _, ok := s.forbiddenExtensions[ext]; ok { + s.log.Debug("file extension is forbidden", "ext", ext) + next.ServeHTTP(w, r) + return + } + + // ok, file is not in the forbidden list + // Stat it and get file info + f, err := s.root.Open(fPath) + if err != nil { + // else no such file, show error in logs only in debug mode + s.log.Debug("no such file or directory", "error", err) + // pass request to the worker + next.ServeHTTP(w, r) + return + } + + // at high confidence there is 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", "error", err) + // pass request to the worker + next.ServeHTTP(w, r) + return + } + + // 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.Static.CalculateEtag { + SetEtag(s.cfg.Static.Weak, f, finfo.Name(), w) + } + + defer func() { + err = f.Close() + if err != nil { + s.log.Error("file close error", "error", err) + } + }() + + // here we know, that file extension is not in the AlwaysServe and file exists + // (or by some reason, there is no error from the http.Open method) + + // if we have some allowed extensions, we should check them + if len(s.allowedExtensions) > 0 { + if _, ok := s.allowedExtensions[ext]; ok { + http.ServeContent(w, r, finfo.Name(), finfo.ModTime(), f) + } + + // file not in the allowed file extensions + return + } + + // otherwise we guess, that all file extensions are allowed + http.ServeContent(w, r, finfo.Name(), finfo.ModTime(), f) + }) +} |