github.com/vugu/vugu@v0.3.6-0.20240430171613-3f6f402e014b/devutil/file-server.go (about) 1 package devutil 2 3 import ( 4 "fmt" 5 "log" 6 "net/http" 7 "net/url" 8 "os" 9 "path" 10 "sort" 11 "strings" 12 "time" 13 ) 14 15 // FileServer is similar to http.FileServer but has some options and behavior differences more useful for Vugu programs. 16 // The following rules apply when serving http responses: 17 // 18 // If the path is a directory but does not end with a slash it is redirected to be with a slash. 19 // 20 // If the path is a directory and ends with a slash then if it contains an index.html file that is served. 21 // 22 // If the path is a directory and ends with a slash and has no index.html, if listings are enabled a listing will be returned. 23 // 24 // If the path does not exist but exists when .html is appended to it then that file is served. 25 // 26 // For anything else the handler for the not-found case is called, or if not set then a 404.html will be searched for and if 27 // that's not present http.NotFound is called. 28 // 29 // Directory listings are disabled by default due to security concerns but can be enabled with SetListings. 30 type FileServer struct { 31 fsys http.FileSystem 32 listings bool // do we show directory listings 33 notFoundHandler http.Handler // call when not found 34 } 35 36 // NewFileServer returns a FileServer instance. 37 // Before using you must set FileSystem to serve from by calling SetFileSystem or SetDir. 38 func NewFileServer() *FileServer { 39 return &FileServer{} 40 } 41 42 // SetFileSystem sets the FileSystem to use when serving files. 43 func (fs *FileServer) SetFileSystem(fsys http.FileSystem) *FileServer { 44 fs.fsys = fsys 45 return fs 46 } 47 48 // SetDir is short for SetFileSystem(http.Dir(dir)) 49 func (fs *FileServer) SetDir(dir string) *FileServer { 50 return fs.SetFileSystem(http.Dir(dir)) 51 } 52 53 // SetListings enables or disables automatic directory listings when a directory is indicated in the URL path. 54 func (fs *FileServer) SetListings(v bool) *FileServer { 55 fs.listings = v 56 return fs 57 } 58 59 // SetNotFoundHandler sets the handle used when no applicable file can be found. 60 func (fs *FileServer) SetNotFoundHandler(h http.Handler) *FileServer { 61 fs.notFoundHandler = h 62 return fs 63 } 64 65 func (fs *FileServer) serveNotFound(w http.ResponseWriter, r *http.Request) { 66 67 // notFoundHandler takes precedence 68 if fs.notFoundHandler != nil { 69 fs.notFoundHandler.ServeHTTP(w, r) 70 return 71 } 72 73 // check for 404.html 74 { 75 f, err := fs.fsys.Open("/404.html") 76 if err != nil { 77 goto defNotFound 78 } 79 defer f.Close() 80 st, err := f.Stat() 81 if err != nil { 82 goto defNotFound 83 } 84 w.Header().Set("Content-Type", "text/html; charset=utf-8") 85 w.WriteHeader(404) 86 http.ServeContent(w, r, r.URL.Path, st.ModTime(), f) 87 return 88 } 89 90 defNotFound: 91 // otherwise fall back to http.NotFound 92 http.NotFound(w, r) 93 } 94 95 // ServeHTTP implements http.Handler with the appropriate behavior. 96 func (fs *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 97 98 // NOTE: much of this borrowed and adapted from https://golang.org/src/net/http/fs.go 99 100 upath := r.URL.Path 101 if !strings.HasPrefix(upath, "/") { 102 upath = "/" + upath 103 r.URL.Path = upath 104 } 105 106 const indexPage = "/index.html" 107 108 // redirect .../index.html to .../ 109 // can't use Redirect() because that would make the path absolute, 110 // which would be a problem running under StripPrefix 111 if strings.HasSuffix(r.URL.Path, indexPage) { 112 localRedirect(w, r, "./") 113 return 114 } 115 116 name := path.Clean("/" + r.URL.Path) 117 118 f, err := fs.fsys.Open(name) 119 if err != nil { 120 121 // try again with .html 122 f2, err2 := fs.fsys.Open(name + ".html") 123 if err2 == nil { 124 f = f2 125 } else { 126 127 msg, code := toHTTPError(err) 128 if code == 404 { 129 fs.serveNotFound(w, r) 130 return 131 } 132 http.Error(w, msg, code) 133 return 134 } 135 136 } 137 defer f.Close() 138 139 d, err := f.Stat() 140 if err != nil { 141 msg, code := toHTTPError(err) 142 http.Error(w, msg, code) 143 return 144 } 145 146 // redirect to canonical path: / at end of directory url 147 // r.URL.Path always begins with / 148 url := r.URL.Path 149 if d.IsDir() { 150 if url[len(url)-1] != '/' { 151 localRedirect(w, r, path.Base(url)+"/") 152 return 153 } 154 } else { 155 if url[len(url)-1] == '/' { 156 localRedirect(w, r, "../"+path.Base(url)) 157 return 158 } 159 } 160 161 if d.IsDir() { 162 163 url := r.URL.Path 164 // redirect if the directory name doesn't end in a slash 165 if url == "" || url[len(url)-1] != '/' { 166 localRedirect(w, r, path.Base(url)+"/") 167 return 168 } 169 170 // use contents of index.html for directory, if present 171 index := strings.TrimSuffix(name, "/") + indexPage 172 ff, err := fs.fsys.Open(index) 173 if err == nil { 174 defer ff.Close() 175 dd, err := ff.Stat() 176 if err == nil { 177 d = dd 178 f = ff 179 } 180 } else { 181 // no index.html found for directory 182 if !fs.listings { 183 fs.serveNotFound(w, r) 184 return 185 } 186 } 187 } 188 189 // Still a directory? (we didn't find an index.html file) 190 if fs.listings && d.IsDir() { 191 if checkIfModifiedSince(r, d.ModTime()) == condFalse { 192 writeNotModified(w) 193 return 194 } 195 setLastModified(w, d.ModTime()) 196 dirList(w, r, f) 197 return 198 } 199 200 // serveContent will check modification time 201 // sizeFunc := func() (int64, error) { return d.Size(), nil } 202 // serveContent(w, r, d.Name(), d.ModTime(), sizeFunc, f) 203 204 // log.Printf("about to serve: f=%#v, d=%#v", f, d) 205 206 http.ServeContent(w, r, d.Name(), d.ModTime(), f) 207 } 208 209 // localRedirect gives a Moved Permanently response. 210 // It does not convert relative paths to absolute paths like Redirect does. 211 func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) { 212 if q := r.URL.RawQuery; q != "" { 213 newPath += "?" + q 214 } 215 w.Header().Set("Location", newPath) 216 w.WriteHeader(http.StatusMovedPermanently) 217 } 218 219 // toHTTPError returns a non-specific HTTP error message and status code 220 // for a given non-nil error value. It's important that toHTTPError does not 221 // actually return err.Error(), since msg and httpStatus are returned to users, 222 // and historically Go's ServeContent always returned just "404 Not Found" for 223 // all errors. We don't want to start leaking information in error messages. 224 func toHTTPError(err error) (msg string, httpStatus int) { 225 if os.IsNotExist(err) { 226 return "404 page not found", http.StatusNotFound 227 } 228 if os.IsPermission(err) { 229 return "403 Forbidden", http.StatusForbidden 230 } 231 // Default: 232 return "500 Internal Server Error", http.StatusInternalServerError 233 } 234 235 // condResult is the result of an HTTP request precondition check. 236 // See https://tools.ietf.org/html/rfc7232 section 3. 237 type condResult int 238 239 const ( 240 condNone condResult = iota 241 condTrue 242 condFalse 243 ) 244 245 func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult { 246 if r.Method != "GET" && r.Method != "HEAD" { 247 return condNone 248 } 249 ims := r.Header.Get("If-Modified-Since") 250 if ims == "" || isZeroTime(modtime) { 251 return condNone 252 } 253 t, err := http.ParseTime(ims) 254 if err != nil { 255 return condNone 256 } 257 // The Last-Modified header truncates sub-second precision so 258 // the modtime needs to be truncated too. 259 modtime = modtime.Truncate(time.Second) 260 if modtime.Before(t) || modtime.Equal(t) { 261 return condFalse 262 } 263 return condTrue 264 } 265 266 func writeNotModified(w http.ResponseWriter) { 267 // RFC 7232 section 4.1: 268 // a sender SHOULD NOT generate representation metadata other than the 269 // above listed fields unless said metadata exists for the purpose of 270 // guiding cache updates (e.g., Last-Modified might be useful if the 271 // response does not have an ETag field). 272 h := w.Header() 273 delete(h, "Content-Type") 274 delete(h, "Content-Length") 275 if h.Get("Etag") != "" { 276 delete(h, "Last-Modified") 277 } 278 w.WriteHeader(http.StatusNotModified) 279 } 280 281 func setLastModified(w http.ResponseWriter, modtime time.Time) { 282 if !isZeroTime(modtime) { 283 w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat)) 284 } 285 } 286 287 var unixEpochTime = time.Unix(0, 0) 288 289 // isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0). 290 func isZeroTime(t time.Time) bool { 291 return t.IsZero() || t.Equal(unixEpochTime) 292 } 293 294 func dirList(w http.ResponseWriter, r *http.Request, f http.File) { 295 dirs, err := f.Readdir(-1) 296 if err != nil { 297 log.Print(r, "http: error reading directory: %v", err) 298 http.Error(w, "Error reading directory", http.StatusInternalServerError) 299 return 300 } 301 sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() }) 302 303 w.Header().Set("Content-Type", "text/html; charset=utf-8") 304 fmt.Fprintf(w, "<pre>\n") 305 for _, d := range dirs { 306 name := d.Name() 307 if d.IsDir() { 308 name += "/" 309 } 310 // name may contain '?' or '#', which must be escaped to remain 311 // part of the URL path, and not indicate the start of a query 312 // string or fragment. 313 url := url.URL{Path: name} 314 fmt.Fprintf(w, "<a href=\"%s\">%s</a>\n", url.String(), htmlReplacer.Replace(name)) 315 } 316 fmt.Fprintf(w, "</pre>\n") 317 } 318 319 var htmlReplacer = strings.NewReplacer( 320 "&", "&", 321 "<", "<", 322 ">", ">", 323 324 `"`, """, 325 326 "'", "'", 327 ) 328 329 // ---------------------------------- 330 // old notes: 331 332 // contentFunc func(fs http.FileSystem, name string) (modtime time.Time, content io.ReadSeeker, err error) // can handle various request path transformations 333 334 // SetContentFunc assigns the function that will 335 // func (fs *FileServer) SetContentFunc(f func(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error)) { 336 337 // } 338 339 // // DefaultContentFunc serves files directly from a the filesystem with the following additional logic: 340 // // If the path is a directory but does not end with a slash it is redirected to be with a slash. 341 // // If the path is a directory and ends with a slash then if it contains an index.html file that is served. 342 // // If the path does not exist but exists when .html is appended to it then that file is served. 343 // // For anything else the error returned from fs.Open is returned. 344 // func DefaultContentFunc(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error) { 345 346 // } 347 348 // // DefaultListingContentFunc is like DefaultContentFunc but with directory listings enabled. 349 // func DefaultListingContentFunc(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error) { 350 // } 351 352 // // ReadSeekCloser has Read, Seek and Close methods. 353 // type ReadSeekCloser interface { 354 // io.Reader 355 // io.Seeker 356 // io.Closer 357 // } 358 359 // what about /anything mapping to index page 360 // (seems like an option to me - maybe need some func to map this stuff 361 // plus convenience methods for common cases) 362 363 // pick a sensible default