// Copyright 2021-2025 Martin Riedl // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package httprouter import ( "embed" "fmt" "io" "io/fs" "mime" "net/http" "os" "path" "strings" ) // FS file system for serving static content. type FS struct { StaticFiles *embed.FS FolderPrefix string UseLocalFolder bool LocalFolderPrefix string HeaderHandler HeaderHandler IndexFileNames []string } // NewFS creates a new instance of the http file system used for serving static files. func NewFS(staticFiles *embed.FS) *FS { return &FS{ StaticFiles: staticFiles, HeaderHandler: DefaultHeaderHandler, } } func (fs *FS) ServeHTTP(w http.ResponseWriter, r *http.Request, info RoutingInfo) { // use wildcard placeholder value for the path (if available) url, found := info.Parameters["*"] if !found { url = r.URL.Path } // search for file in static file system file, err := fs.findFile(url) if err != nil { http.NotFound(w, r) return } // check, if the file is a folder file, ok := fs.checkFolder(file, url) if !ok { http.NotFound(w, r) return } defer file.Close() // serve file content fs.serve(w, r, info, file) } func (fs *FS) findFile(url string) (fs.File, error) { // build file path filePath := path.Join(fs.FolderPrefix, url) filePath = strings.TrimPrefix(filePath, "/") // check, if file exists in local folder if fs.UseLocalFolder { localFilePath := path.Join(fs.LocalFolderPrefix, filePath) localFilePath = strings.TrimPrefix(localFilePath, "/") if localFilePath == "" { localFilePath = "." } file, err := os.Open(localFilePath) if err == nil { return file, nil } } // use static file system if filePath == "" { filePath = "." } file, err := fs.StaticFiles.Open(filePath) return file, err } func (fs *FS) checkFolder(file fs.File, url string) (fs.File, bool) { fileInfo, err := file.Stat() if err != nil { _ = file.Close() return file, false } // check for folder / root location if fileInfo.IsDir() { // close folder handler (we don't need it anymore) _ = file.Close() // check for index files for _, indexFileName := range fs.IndexFileNames { newFullPath := path.Join(url, indexFileName) if newFile, err := fs.findFile(newFullPath); err == nil { return newFile, true } } return file, false } return file, true } func (fs *FS) serve(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) { // execute header handler if fs.HeaderHandler != nil { fs.HeaderHandler(w, r, info, file) } w.WriteHeader(http.StatusOK) // check for head request if r.Method == http.MethodHead { // return no content return } // send file content io.Copy(w, file) } // HeaderHandler for custom http header handling type HeaderHandler func(http.ResponseWriter, *http.Request, RoutingInfo, fs.File) // DefaultHeaderHandler is the default implementation for file headers func DefaultHeaderHandler(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) { ContentLengthHeaderHandler(w, r, info, file) ContentTypeHeaderHandler(w, r, info, file) CacheControlHeaderHandler(w, r, info, file) ContentTypeOptionHeaderHandler(w, r, info, file) } // ContentLengthHeaderHandler sends the file size func ContentLengthHeaderHandler(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) { // send file size as header stat, err := file.Stat() if err != nil { return } w.Header().Set("Content-Length", fmt.Sprint(stat.Size())) } // ContentTypeHeaderHandler sends content type based on the file name extension func ContentTypeHeaderHandler(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) { stat, err := file.Stat() if err != nil { return } mimeHeader := mime.TypeByExtension(path.Ext(stat.Name())) if mimeHeader != "" { w.Header().Set("Content-Type", mimeHeader) } } // CacheControlHeaderHandler send cache control header of 7 days for the browser and 30 days for the CDN func CacheControlHeaderHandler(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) { w.Header().Set("Cache-Control", fmt.Sprintf("public, s-maxage=%d, max-age=%d", 60*60*24*30, 60*60*24*7)) } // ContentTypeOptionHeaderHandler for nosniff content type option func ContentTypeOptionHeaderHandler(w http.ResponseWriter, r *http.Request, info RoutingInfo, file fs.File) { w.Header().Set("X-Content-Type-Options", "nosniff") }