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

     1  package httpx
     2  
     3  import "encoding/base64"
     4  
     5  // import (
     6  // 	"bytes"
     7  // 	"crypto/sha256"
     8  // 	"encoding/base64"
     9  // 	"errors"
    10  // 	"fmt"
    11  // 	"io"
    12  // 	"io/fs"
    13  // 	"mime"
    14  // 	"net/http"
    15  // 	"path/filepath"
    16  // 	"strconv"
    17  // 	"strings"
    18  // 	"sync"
    19  
    20  // 	"git.sr.ht/~pingoo/stdx/log/slogx"
    21  // )
    22  
    23  // type webappFileInfo struct {
    24  // 	hash [32]byte
    25  // 	size int64
    26  // }
    27  
    28  // // webappFileInfoCache is used to cache the metadata about a file
    29  // // these metdata are used to send StatusNotModified response if the request has an If-None-Match HTTP
    30  // // header
    31  // type webappFileInfoCache struct {
    32  // 	files map[string]webappFileInfo
    33  // 	mutex sync.RWMutex
    34  // }
    35  
    36  // func (cache *webappFileInfoCache) Get(path string) (record webappFileInfo, exists bool) {
    37  // 	cache.mutex.RLock()
    38  // 	record, exists = cache.files[path]
    39  // 	cache.mutex.RUnlock()
    40  // 	return
    41  // }
    42  
    43  // func (cache *webappFileInfoCache) Set(path string, info webappFileInfo) {
    44  // 	cache.mutex.Lock()
    45  // 	cache.files[path] = info
    46  // 	cache.mutex.Unlock()
    47  // }
    48  
    49  // func WebappHandlerOld(folder fs.FS) func(w http.ResponseWriter, r *http.Request) {
    50  // 	cache := webappFileInfoCache{
    51  // 		files: make(map[string]webappFileInfo, 100),
    52  // 		mutex: sync.RWMutex{},
    53  // 	}
    54  // 	return func(w http.ResponseWriter, req *http.Request) {
    55  // 		ctx := req.Context()
    56  // 		logger := slogx.FromCtx(ctx)
    57  
    58  // 		if req.Method != http.MethodGet && req.Method != http.MethodHead {
    59  // 			w.WriteHeader(http.StatusMethodNotAllowed)
    60  // 			w.Write([]byte("Method not allowed.\n"))
    61  // 			return
    62  // 		}
    63  
    64  // 		ok, err := tryRead(folder, req.URL.Path, &cache, w, req)
    65  // 		if err != nil && !errors.Is(err, ErrDir) && !errors.Is(err, ErrInvalidPath) {
    66  // 			logger.Error("httpx.WebappHandler: reading file", slogx.Err(err))
    67  // 			w.Header().Set(HeaderCacheControl, CacheControlNoCache)
    68  // 			handleError(http.StatusInternalServerError, ErrInternalError.Error(), w)
    69  // 			return
    70  // 		}
    71  // 		if ok {
    72  // 			return
    73  // 		}
    74  
    75  // 		_, err = tryRead(folder, "index.html", &cache, w, req)
    76  // 		if err != nil {
    77  // 			logger.Error("httpx.WebappHandler: reading index.html", slogx.Err(err))
    78  // 			w.Header().Set(HeaderCacheControl, CacheControlNoCache)
    79  // 			handleError(http.StatusInternalServerError, ErrInternalError.Error(), w)
    80  // 			return
    81  // 		}
    82  // 	}
    83  // }
    84  
    85  // // alternatively, we could pre-load all the files with their metadata like here: https://github.com/go-chi/chi/issues/611
    86  // func tryRead(fs fs.FS, path string, cache *webappFileInfoCache, w http.ResponseWriter, req *http.Request) (ok bool, err error) {
    87  // 	// path = filepath.Clean(path)
    88  // 	if path == "" || strings.Contains(path, "..") {
    89  // 		err = ErrInvalidPath
    90  // 		return
    91  // 	}
    92  // 	// logger := slogx.FromCtx(r.Context())
    93  
    94  // 	// TrimLeft is efficient here as we only trim 1 character so only bytes comparison, no UTF-8
    95  // 	path = strings.TrimLeft(path, "/")
    96  
    97  // 	extension := filepath.Ext(path)
    98  // 	contentType := mime.TypeByExtension(extension)
    99  
   100  // 	cacheControl := CacheControlDynamic
   101  // 	switch extension {
   102  // 	case ".js", ".css", ".woff", ".woff2":
   103  // 		// some webapp's assets files can be cached for very long time because they are versionned by
   104  // 		// the webapp's bundler
   105  // 		cacheControl = CacheControlImmutable
   106  // 	}
   107  
   108  // 	w.Header().Set(HeaderContentType, contentType)
   109  // 	w.Header().Set(HeaderCacheControl, cacheControl)
   110  
   111  // 	// first, we handle caching
   112  // 	requestEtag := decodeEtag(strings.TrimSpace(req.Header.Get(HeaderIfNoneMatch)))
   113  
   114  // 	cachedFileInfo, isCached := cache.Get(path)
   115  // 	if isCached && bytes.Equal(requestEtag, cachedFileInfo.hash[:]) {
   116  // 		// logger.Debug("httpx.WebappHandler: cache HIT")
   117  // 		w.Header().Set(HeaderETag, encodeEtagOptimized(cachedFileInfo.hash))
   118  // 		w.Header().Set(HeaderContentLength, strconv.FormatInt(cachedFileInfo.size, 10))
   119  // 		w.WriteHeader(http.StatusNotModified)
   120  // 		ok = true
   121  // 		return
   122  // 	}
   123  
   124  // 	file, err := fs.Open(path)
   125  // 	if err != nil {
   126  // 		err = ErrInvalidPath
   127  // 		return
   128  // 	}
   129  // 	defer file.Close()
   130  
   131  // 	// use fs.Stat instead?
   132  // 	// embed.FS does not implement FS.Stat, so the file need to be Open / closed anyway
   133  // 	fileInfo, err := file.Stat()
   134  // 	if err != nil {
   135  // 		err = ErrInternalError
   136  // 		return
   137  // 	}
   138  // 	if fileInfo.IsDir() {
   139  // 		err = ErrDir
   140  // 		return
   141  // 	}
   142  
   143  // 	seeker, isSeeker := file.(io.Seeker)
   144  // 	if !isSeeker {
   145  // 		err = ErrInternalError
   146  // 		return
   147  // 	}
   148  
   149  // 	// we hash the file to get its Etag
   150  // 	hasher := blake3.New()
   151  // 	_, err = io.Copy(hasher, file)
   152  // 	if err != nil {
   153  // 		err = ErrInternalError
   154  // 		return
   155  // 	}
   156  // 	fileHash := hasher.Sum(nil)
   157  
   158  // 	cachedFileInfo.size = fileInfo.Size()
   159  // 	cachedFileInfo.hash = [32]byte(fileHash)
   160  // 	cache.Set(path, cachedFileInfo)
   161  
   162  // 	w.Header().Set(HeaderETag, encodeEtagOptimized(cachedFileInfo.hash))
   163  // 	w.Header().Set(HeaderContentLength, strconv.FormatInt(cachedFileInfo.size, 10))
   164  
   165  // 	if bytes.Equal(requestEtag, cachedFileInfo.hash[:]) {
   166  // 		// logger.Debug("httpx.WebappHandler: etag HIT")
   167  // 		w.WriteHeader(http.StatusNotModified)
   168  // 		ok = true
   169  // 		return
   170  // 	}
   171  
   172  // 	// logger.Debug("httpx.WebappHandler: MISS")
   173  
   174  // 	w.WriteHeader(http.StatusOK)
   175  // 	// finally, we can send the file
   176  // 	seeker.Seek(0, io.SeekStart)
   177  // 	_, err = io.Copy(w, file)
   178  // 	if err != nil {
   179  // 		err = fmt.Errorf("httpx.tryRead: copying content to HTTP response: %w", err)
   180  // 		return
   181  // 	}
   182  
   183  // 	ok = true
   184  
   185  // 	return
   186  // }
   187  
   188  // func decodeEtag(requestEtag string) (etagBytes []byte) {
   189  // 	// sometimes, a CDN may add the weak Etag prefix: W/
   190  // 	requestEtag = strings.TrimPrefix(requestEtag, "W/")
   191  // 	requestEtag = strings.TrimPrefix(requestEtag, `"`)
   192  // 	requestEtag = strings.TrimSuffix(requestEtag, `"`)
   193  // 	etagBytes, err := base64.RawURLEncoding.DecodeString(requestEtag)
   194  // 	if err != nil {
   195  // 		etagBytes = nil
   196  // 		return
   197  // 	}
   198  // 	return
   199  // }
   200  
   201  // // func toEtagPlus(hash [32]byte) string {
   202  // // 	etag := base64.RawURLEncoding.EncodeToString(hash[:])
   203  // // 	return `"` + etag + `"`
   204  // // }
   205  
   206  func encodeEtagOptimized(hash [32]byte) string {
   207  	base64Len := base64.RawURLEncoding.EncodedLen(32)
   208  	buf := make([]byte, base64Len+2)
   209  	buf[0] = '"'
   210  	base64.RawURLEncoding.Encode(buf[1:], hash[:])
   211  	buf[base64Len+1] = '"'
   212  	return string(buf)
   213  }