git.sr.ht/~pingoo/stdx@v0.0.0-20240218134121-094174641f6e/httpx/webapphandler.go (about)

     1  package httpx
     2  
     3  import (
     4  	"encoding/base64"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"io/fs"
     9  	"mime"
    10  	"net/http"
    11  	"path/filepath"
    12  	"regexp"
    13  	"strconv"
    14  	"strings"
    15  
    16  	"github.com/zeebo/blake3"
    17  )
    18  
    19  var ErrDir = errors.New("path is a folder")
    20  var ErrInvalidPath = errors.New("path is not valid")
    21  var ErrInternalError = errors.New("Internal Server Error")
    22  var errFileIsMissing = func(file string) error { return fmt.Errorf("webappHandler: %s is missing", file) }
    23  
    24  type fileMetadata struct {
    25  	contentType string
    26  	etag        string
    27  	// we store the contentLength as a string to avoid the conversion to string for each request
    28  	contentLength string
    29  	cacheControl  string
    30  }
    31  
    32  type WebappHandlerConfig struct {
    33  	// default: index.html
    34  	NotFoundFile string
    35  	// default: 200
    36  	NotFoundStatus int
    37  	// default: public, no-cache, must-revalidate
    38  	NotFoundCacheControl string
    39  	// default: ".js", ".css", ".woff", ".woff2"
    40  	Cache []CacheRule
    41  }
    42  
    43  type CacheRule struct {
    44  	Regexp         string
    45  	compiledRegexp *regexp.Regexp
    46  	CacheControl   string
    47  }
    48  
    49  // WebappHandler is an http.Handler that is designed to efficiently serve Single Page Applications.
    50  // if a file is not found, it will return notFoundFile (default: index.html) with the stauscode statusNotFound
    51  // WebappHandler sets the correct ETag header and cache the hash of files so that repeated requests
    52  // to files return only StatusNotModified responses
    53  // WebappHandler returns StatusMethodNotAllowed if the method is different than GET or HEAD
    54  func WebappHandler(folder fs.FS, config *WebappHandlerConfig) (handler func(w http.ResponseWriter, r *http.Request), err error) {
    55  	defaultConfig := defaultWebappHandlerConfig()
    56  	if config == nil {
    57  		config = defaultConfig
    58  	} else {
    59  		if config.NotFoundFile == "" {
    60  			config.NotFoundFile = defaultConfig.NotFoundFile
    61  		}
    62  		if config.NotFoundStatus == 0 {
    63  			config.NotFoundStatus = defaultConfig.NotFoundStatus
    64  		}
    65  		if config.NotFoundCacheControl == "" {
    66  			config.NotFoundCacheControl = defaultConfig.NotFoundCacheControl
    67  		}
    68  		if config.Cache == nil {
    69  			config.Cache = defaultConfig.Cache
    70  		}
    71  	}
    72  
    73  	for i := range config.Cache {
    74  		config.Cache[i].compiledRegexp, err = regexp.Compile(config.Cache[i].Regexp)
    75  		if err != nil {
    76  			err = fmt.Errorf("webappHandler: regexp is not valid: %s", config.Cache[i].Regexp)
    77  			return
    78  		}
    79  	}
    80  
    81  	filesMetadata, err := loadFilesMetdata(folder, config)
    82  	if err != nil {
    83  		return nil, err
    84  	}
    85  
    86  	handler = func(w http.ResponseWriter, req *http.Request) {
    87  		if req.Method != http.MethodGet && req.Method != http.MethodHead {
    88  			w.WriteHeader(http.StatusMethodNotAllowed)
    89  			w.Write([]byte("Method not allowed.\n"))
    90  			return
    91  		}
    92  
    93  		statusCode := http.StatusOK
    94  		path := strings.TrimPrefix(req.URL.Path, "/")
    95  		fileMetadata, fileExists := filesMetadata[path]
    96  		cacheControl := fileMetadata.cacheControl
    97  		if !fileExists {
    98  			path = config.NotFoundFile
    99  			fileMetadata = filesMetadata[path]
   100  			statusCode = config.NotFoundStatus
   101  			cacheControl = config.NotFoundCacheControl
   102  		} else {
   103  			w.Header().Set(HeaderETag, fileMetadata.etag)
   104  		}
   105  
   106  		w.Header().Set(HeaderContentLength, fileMetadata.contentLength)
   107  		w.Header().Set(HeaderContentType, fileMetadata.contentType)
   108  		w.Header().Set(HeaderCacheControl, cacheControl)
   109  
   110  		requestEtag := cleanRequestEtag(req.Header.Get(HeaderIfNoneMatch))
   111  		if (config.NotFoundStatus == http.StatusOK || fileExists) && requestEtag == fileMetadata.etag {
   112  			w.WriteHeader(http.StatusNotModified)
   113  			return
   114  		}
   115  
   116  		w.WriteHeader(statusCode)
   117  		err = sendFile(folder, path, w)
   118  		if err != nil {
   119  			w.Header().Set(HeaderCacheControl, CacheControlNoCache)
   120  			handleError(http.StatusInternalServerError, ErrInternalError.Error(), w)
   121  			return
   122  		}
   123  	}
   124  	return
   125  }
   126  
   127  func defaultWebappHandlerConfig() *WebappHandlerConfig {
   128  	return &WebappHandlerConfig{
   129  		NotFoundFile:         "index.html",
   130  		NotFoundStatus:       http.StatusOK,
   131  		NotFoundCacheControl: CacheControlDynamic,
   132  		Cache: []CacheRule{
   133  			{
   134  				// some webapp's assets files can be cached for very long time because they are versionned by
   135  				// the webapp's bundler
   136  				Regexp:       ".*\\.(js|css|woff|woff2)$",
   137  				CacheControl: CacheControlImmutable,
   138  			},
   139  			{
   140  				Regexp:       ".*\\.(jpg|jpeg|png|webp|gif|svg|ico)$",
   141  				CacheControl: "public, max-age=900, stale-while-revalidate=43200",
   142  			},
   143  		},
   144  	}
   145  }
   146  
   147  func sendFile(folder fs.FS, path string, w http.ResponseWriter) (err error) {
   148  	file, err := folder.Open(path)
   149  	if err != nil {
   150  		return
   151  	}
   152  
   153  	defer file.Close()
   154  
   155  	_, err = io.Copy(w, file)
   156  	return
   157  }
   158  
   159  func handleError(code int, message string, w http.ResponseWriter) {
   160  	http.Error(w, message, code)
   161  }
   162  
   163  // sometimes, a CDN may add the weak Etag prefix: W/
   164  func cleanRequestEtag(requestEtag string) string {
   165  	return strings.TrimPrefix(strings.TrimSpace(requestEtag), "W/")
   166  }
   167  
   168  func loadFilesMetdata(folder fs.FS, config *WebappHandlerConfig) (ret map[string]fileMetadata, err error) {
   169  	ret = make(map[string]fileMetadata, 10)
   170  
   171  	err = fs.WalkDir(folder, ".", func(path string, fileEntry fs.DirEntry, errWalk error) error {
   172  		if errWalk != nil {
   173  			return fmt.Errorf("webappHandler: error processing file %s: %w", path, errWalk)
   174  		}
   175  
   176  		if fileEntry.IsDir() || !fileEntry.Type().IsRegular() {
   177  			return nil
   178  		}
   179  
   180  		fileInfo, errWalk := fileEntry.Info()
   181  		if errWalk != nil {
   182  			return fmt.Errorf("webappHandler: error getting info for file %s: %w", path, errWalk)
   183  		}
   184  
   185  		file, errWalk := folder.Open(path)
   186  		if err != nil {
   187  			return fmt.Errorf("webappHandler: error opening file %s: %w", path, errWalk)
   188  		}
   189  		defer file.Close()
   190  
   191  		// we hash the file to generate its Etag
   192  		hasher := blake3.New()
   193  		_, errWalk = io.Copy(hasher, file)
   194  		if errWalk != nil {
   195  			return fmt.Errorf("webappHandler: error hashing file %s: %w", path, errWalk)
   196  		}
   197  		fileHash := hasher.Sum(nil)
   198  
   199  		etag := encodeEtag(fileHash)
   200  
   201  		extension := filepath.Ext(path)
   202  		contentType := mime.TypeByExtension(extension)
   203  
   204  		// the cacheControl value depends on the type of the file
   205  		cacheControl := CacheControlDynamic
   206  
   207  		for _, cacheRule := range config.Cache {
   208  			if cacheRule.compiledRegexp.Match([]byte(path)) {
   209  				cacheControl = cacheRule.CacheControl
   210  			}
   211  		}
   212  
   213  		ret[path] = fileMetadata{
   214  			contentType:   contentType,
   215  			etag:          etag,
   216  			contentLength: strconv.FormatInt(fileInfo.Size(), 10),
   217  			cacheControl:  cacheControl,
   218  		}
   219  
   220  		return nil
   221  	})
   222  
   223  	if _, indexHtmlExists := ret[config.NotFoundFile]; !indexHtmlExists {
   224  		err = errFileIsMissing(config.NotFoundFile)
   225  		return
   226  	}
   227  
   228  	return
   229  }
   230  
   231  func encodeEtag(hash []byte) string {
   232  	return `"` + base64.RawURLEncoding.EncodeToString(hash) + `"`
   233  }