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 }