github.com/blend/go-sdk@v1.20240719.1/web/static_file_server.go (about) 1 /* 2 3 Copyright (c) 2024 - Present. Blend Labs, Inc. All rights reserved 4 Use of this source code is governed by a MIT license that can be found in the LICENSE file. 5 6 */ 7 8 package web 9 10 import ( 11 "bytes" 12 "io" 13 "net/http" 14 "os" 15 "regexp" 16 "sync" 17 18 "github.com/blend/go-sdk/logger" 19 "github.com/blend/go-sdk/webutil" 20 ) 21 22 // NewStaticFileServer returns a new static file cache. 23 func NewStaticFileServer(options ...StaticFileserverOption) *StaticFileServer { 24 var sfs StaticFileServer 25 for _, opt := range options { 26 opt(&sfs) 27 } 28 return &sfs 29 } 30 31 // StaticFileserverOption are options for static fileservers. 32 type StaticFileserverOption func(*StaticFileServer) 33 34 // OptStaticFileServerSearchPaths sets the static fileserver search paths. 35 func OptStaticFileServerSearchPaths(searchPaths ...http.FileSystem) StaticFileserverOption { 36 return func(sfs *StaticFileServer) { 37 sfs.SearchPaths = searchPaths 38 } 39 } 40 41 // OptStaticFileServerHeaders sets the static fileserver default headers.. 42 func OptStaticFileServerHeaders(headers http.Header) StaticFileserverOption { 43 return func(sfs *StaticFileServer) { 44 sfs.Headers = headers 45 } 46 } 47 48 // OptStaticFileServerCacheDisabled sets the static fileserver should read from disk for each request. 49 func OptStaticFileServerCacheDisabled(cacheDisabled bool) StaticFileserverOption { 50 return func(sfs *StaticFileServer) { 51 sfs.CacheDisabled = cacheDisabled 52 } 53 } 54 55 // StaticFileServer is a cache of static files. 56 // It can operate in cached mode, or with `CacheDisabled` set to `true` 57 // it will read from disk for each request. 58 // In cached mode, it automatically adds etags for files it caches. 59 type StaticFileServer struct { 60 sync.RWMutex 61 62 SearchPaths []http.FileSystem 63 RewriteRules []RewriteRule 64 Headers http.Header 65 CacheDisabled bool 66 Cache map[string]*CachedStaticFile 67 } 68 69 // AddHeader adds a header to the static cache results. 70 func (sc *StaticFileServer) AddHeader(key, value string) { 71 if sc.Headers == nil { 72 sc.Headers = http.Header{} 73 } 74 sc.Headers[key] = append(sc.Headers[key], value) 75 } 76 77 // AddRewriteRule adds a static re-write rule. 78 // This is meant to modify the path of a file from what is requested by the browser 79 // to how a file may actually be accessed on disk. 80 // Typically re-write rules are used to enforce caching semantics. 81 func (sc *StaticFileServer) AddRewriteRule(match string, action RewriteAction) error { 82 expr, err := regexp.Compile(match) 83 if err != nil { 84 return err 85 } 86 sc.RewriteRules = append(sc.RewriteRules, RewriteRule{ 87 MatchExpression: match, 88 expr: expr, 89 Action: action, 90 }) 91 return nil 92 } 93 94 // Action is the entrypoint for the static server. 95 // It adds default headers if specified, and then serves the file from disk 96 // or from a pull-through cache if enabled. 97 func (sc *StaticFileServer) Action(r *Ctx) Result { 98 filePath, err := r.RouteParam("filepath") 99 if err != nil { 100 if r.DefaultProvider != nil { 101 return r.DefaultProvider.BadRequest(err) 102 } 103 http.Error(r.Response, err.Error(), http.StatusBadRequest) 104 return nil 105 } 106 107 for key, values := range sc.Headers { 108 for _, value := range values { 109 r.Response.Header().Set(key, value) 110 } 111 } 112 113 if sc.CacheDisabled { 114 return sc.ServeFile(r, filePath) 115 } 116 return sc.ServeCachedFile(r, filePath) 117 } 118 119 // ServeFile writes the file to the response by reading from disk 120 // for each request (i.e. skipping the cache) 121 func (sc *StaticFileServer) ServeFile(r *Ctx, filePath string) Result { 122 f, finalPath, err := sc.ResolveFile(filePath) 123 if err != nil { 124 return sc.fileError(r, err) 125 } 126 defer f.Close() 127 128 finfo, err := f.Stat() 129 if err != nil { 130 return sc.fileError(r, err) 131 } 132 if finfo.IsDir() { 133 return r.DefaultProvider.NotFound() 134 } 135 136 r.WithContext(logger.WithLabel(r.Context(), "web.static_file", finalPath)) 137 http.ServeContent(r.Response, r.Request, filePath, finfo.ModTime(), f) 138 return nil 139 } 140 141 // ServeCachedFile writes the file to the response, potentially 142 // serving a cached instance of the file. 143 func (sc *StaticFileServer) ServeCachedFile(r *Ctx, filepath string) Result { 144 file, err := sc.ResolveCachedFile(filepath) 145 if err != nil { 146 return sc.fileError(r, err) 147 } 148 if file == nil { 149 return r.DefaultProvider.NotFound() 150 } 151 _ = file.Render(r) 152 return nil 153 } 154 155 // ResolveFile resolves a file from rewrite rules and search paths. 156 // First the file path is modified according to the rewrite rules. 157 // Then each search path is checked for the resolved file path. 158 func (sc *StaticFileServer) ResolveFile(filePath string) (f http.File, finalPath string, err error) { 159 for _, rule := range sc.RewriteRules { 160 if matched, newFilePath := rule.Apply(filePath); matched { 161 filePath = newFilePath 162 } 163 } 164 for _, searchPath := range sc.SearchPaths { 165 f, err = searchPath.Open(filePath) 166 if typed, ok := f.(*os.File); ok && typed != nil { 167 finalPath = typed.Name() 168 } 169 if err != nil { 170 if os.IsNotExist(err) { 171 continue 172 } 173 return 174 } 175 if f != nil { 176 return 177 } 178 } 179 return 180 } 181 182 // ResolveCachedFile returns a cached file at a given path. 183 // It returns the cached instance of a file if it exists, and adds it to the cache if there is a miss. 184 func (sc *StaticFileServer) ResolveCachedFile(filepath string) (*CachedStaticFile, error) { 185 // start in read shared mode 186 sc.RLock() 187 if sc.Cache != nil { 188 if file, ok := sc.Cache[filepath]; ok { 189 sc.RUnlock() 190 return file, nil 191 } 192 } 193 sc.RUnlock() 194 195 // transition to exclusive write mode 196 sc.Lock() 197 defer sc.Unlock() 198 199 if sc.Cache == nil { 200 sc.Cache = make(map[string]*CachedStaticFile) 201 } 202 // double check ftw 203 if file, ok := sc.Cache[filepath]; ok { 204 return file, nil 205 } 206 207 diskFile, _, err := sc.ResolveFile(filepath) 208 if err != nil { 209 return nil, err 210 } 211 212 if diskFile == nil { 213 sc.Cache[filepath] = nil 214 return nil, nil 215 } 216 217 finfo, err := diskFile.Stat() 218 if err != nil { 219 if os.IsNotExist(err) { 220 return nil, nil 221 } 222 return nil, err 223 } 224 if finfo.IsDir() { 225 return nil, nil 226 } 227 228 contents, err := io.ReadAll(diskFile) 229 if err != nil { 230 return nil, err 231 } 232 233 file := &CachedStaticFile{ 234 Path: filepath, 235 Contents: bytes.NewReader(contents), 236 ModTime: finfo.ModTime(), 237 ETag: webutil.ETag(contents), 238 Size: len(contents), 239 } 240 241 sc.Cache[filepath] = file 242 return file, nil 243 } 244 245 func (sc *StaticFileServer) fileError(r *Ctx, err error) Result { 246 if os.IsNotExist(err) { 247 if r.DefaultProvider != nil { 248 return r.DefaultProvider.NotFound() 249 } 250 http.NotFound(r.Response, r.Request) 251 return nil 252 } 253 if r.DefaultProvider != nil { 254 return r.DefaultProvider.InternalError(err) 255 } 256 http.Error(r.Response, err.Error(), http.StatusInternalServerError) 257 return nil 258 }