Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
19 changes: 10 additions & 9 deletions etag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, '"')
Expand All @@ -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.
Expand Down
221 changes: 165 additions & 56 deletions plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package static
import (
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"unsafe"

rrcontext "github.com/roadrunner-server/context"
Expand Down Expand Up @@ -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(s *Plugin) 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 := 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(w, calculateEtag(s.cfg.Weak, f, finfo.Name()))
}

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)
}
}

type ScannedFile struct {
file http.File
name string
modTime time.Time
etag string
}

func createImmutableServer(s *Plugin) (FileServer, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing plugin pointer is a bad idea. Consider approach without that.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like that?

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 := s.root.Open(path)

if openError != nil {
return openError
}

var etag string

if s.cfg.CalculateEtag {
etag = calculateEtag(s.cfg.Weak, file, info.Name())
}

files[path] = ScannedFile{
file: file,
modTime: info.ModTime(),
name: info.Name(),
etag: etag,
}

return nil
}

err := filepath.Walk(s.cfg.Dir, 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
s.log.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 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, file.name, file.modTime, file.file)
}, nil
}

// Plugin serves static files. Potentially convert into middleware?
type Plugin struct {
// server configuration (location, forbidden files etc)
Expand All @@ -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
Expand Down Expand Up @@ -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)
if err != nil {
return errors.E(op, err)
}

server = immutableServer
} else {
server = createMutableServer(s)
}

s.fileServer = server

// at this point we have distinct allowed and forbidden hashmaps, also with alwaysServed
return nil
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
})
}

Expand Down