github.com/vugu/vugu@v0.3.5/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 name = index 178 d = dd 179 f = ff 180 } 181 } else { 182 // no index.html found for directory 183 if !fs.listings { 184 fs.serveNotFound(w, r) 185 return 186 } 187 } 188 } 189 190 // Still a directory? (we didn't find an index.html file) 191 if fs.listings && d.IsDir() { 192 if checkIfModifiedSince(r, d.ModTime()) == condFalse { 193 writeNotModified(w) 194 return 195 } 196 setLastModified(w, d.ModTime()) 197 dirList(w, r, f) 198 return 199 } 200 201 // serveContent will check modification time 202 // sizeFunc := func() (int64, error) { return d.Size(), nil } 203 // serveContent(w, r, d.Name(), d.ModTime(), sizeFunc, f) 204 205 // log.Printf("about to serve: f=%#v, d=%#v", f, d) 206 207 http.ServeContent(w, r, d.Name(), d.ModTime(), f) 208 } 209 210 // localRedirect gives a Moved Permanently response. 211 // It does not convert relative paths to absolute paths like Redirect does. 212 func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) { 213 if q := r.URL.RawQuery; q != "" { 214 newPath += "?" + q 215 } 216 w.Header().Set("Location", newPath) 217 w.WriteHeader(http.StatusMovedPermanently) 218 } 219 220 // toHTTPError returns a non-specific HTTP error message and status code 221 // for a given non-nil error value. It's important that toHTTPError does not 222 // actually return err.Error(), since msg and httpStatus are returned to users, 223 // and historically Go's ServeContent always returned just "404 Not Found" for 224 // all errors. We don't want to start leaking information in error messages. 225 func toHTTPError(err error) (msg string, httpStatus int) { 226 if os.IsNotExist(err) { 227 return "404 page not found", http.StatusNotFound 228 } 229 if os.IsPermission(err) { 230 return "403 Forbidden", http.StatusForbidden 231 } 232 // Default: 233 return "500 Internal Server Error", http.StatusInternalServerError 234 } 235 236 // condResult is the result of an HTTP request precondition check. 237 // See https://tools.ietf.org/html/rfc7232 section 3. 238 type condResult int 239 240 const ( 241 condNone condResult = iota 242 condTrue 243 condFalse 244 ) 245 246 func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult { 247 if r.Method != "GET" && r.Method != "HEAD" { 248 return condNone 249 } 250 ims := r.Header.Get("If-Modified-Since") 251 if ims == "" || isZeroTime(modtime) { 252 return condNone 253 } 254 t, err := http.ParseTime(ims) 255 if err != nil { 256 return condNone 257 } 258 // The Last-Modified header truncates sub-second precision so 259 // the modtime needs to be truncated too. 260 modtime = modtime.Truncate(time.Second) 261 if modtime.Before(t) || modtime.Equal(t) { 262 return condFalse 263 } 264 return condTrue 265 } 266 267 func writeNotModified(w http.ResponseWriter) { 268 // RFC 7232 section 4.1: 269 // a sender SHOULD NOT generate representation metadata other than the 270 // above listed fields unless said metadata exists for the purpose of 271 // guiding cache updates (e.g., Last-Modified might be useful if the 272 // response does not have an ETag field). 273 h := w.Header() 274 delete(h, "Content-Type") 275 delete(h, "Content-Length") 276 if h.Get("Etag") != "" { 277 delete(h, "Last-Modified") 278 } 279 w.WriteHeader(http.StatusNotModified) 280 } 281 282 func setLastModified(w http.ResponseWriter, modtime time.Time) { 283 if !isZeroTime(modtime) { 284 w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat)) 285 } 286 } 287 288 var unixEpochTime = time.Unix(0, 0) 289 290 // isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0). 291 func isZeroTime(t time.Time) bool { 292 return t.IsZero() || t.Equal(unixEpochTime) 293 } 294 295 func dirList(w http.ResponseWriter, r *http.Request, f http.File) { 296 dirs, err := f.Readdir(-1) 297 if err != nil { 298 log.Print(r, "http: error reading directory: %v", err) 299 http.Error(w, "Error reading directory", http.StatusInternalServerError) 300 return 301 } 302 sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() }) 303 304 w.Header().Set("Content-Type", "text/html; charset=utf-8") 305 fmt.Fprintf(w, "<pre>\n") 306 for _, d := range dirs { 307 name := d.Name() 308 if d.IsDir() { 309 name += "/" 310 } 311 // name may contain '?' or '#', which must be escaped to remain 312 // part of the URL path, and not indicate the start of a query 313 // string or fragment. 314 url := url.URL{Path: name} 315 fmt.Fprintf(w, "<a href=\"%s\">%s</a>\n", url.String(), htmlReplacer.Replace(name)) 316 } 317 fmt.Fprintf(w, "</pre>\n") 318 } 319 320 var htmlReplacer = strings.NewReplacer( 321 "&", "&", 322 "<", "<", 323 ">", ">", 324 325 `"`, """, 326 327 "'", "'", 328 ) 329 330 // ---------------------------------- 331 // old notes: 332 333 // contentFunc func(fs http.FileSystem, name string) (modtime time.Time, content io.ReadSeeker, err error) // can handle various request path transformations 334 335 // SetContentFunc assigns the function that will 336 // func (fs *FileServer) SetContentFunc(f func(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error)) { 337 338 // } 339 340 // // DefaultContentFunc serves files directly from a the filesystem with the following additional logic: 341 // // If the path is a directory but does not end with a slash it is redirected to be with a slash. 342 // // If the path is a directory and ends with a slash then if it contains an index.html file that is served. 343 // // If the path does not exist but exists when .html is appended to it then that file is served. 344 // // For anything else the error returned from fs.Open is returned. 345 // func DefaultContentFunc(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error) { 346 347 // } 348 349 // // DefaultListingContentFunc is like DefaultContentFunc but with directory listings enabled. 350 // func DefaultListingContentFunc(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error) { 351 // } 352 353 // // ReadSeekCloser has Read, Seek and Close methods. 354 // type ReadSeekCloser interface { 355 // io.Reader 356 // io.Seeker 357 // io.Closer 358 // } 359 360 // what about /anything mapping to index page 361 // (seems like an option to me - maybe need some func to map this stuff 362 // plus convenience methods for common cases) 363 364 // pick a sensible default