github.com/pbberlin/go-pwa@v0.0.0-20220328105622-7c26e0ca1ab8/pkg/static/dirs.go (about) 1 /* package static - preparing and serving static files. 2 It assumes a directory ./app-bucket containing directories by mime-types. 3 /css, /js, /img... 4 The package also supports typical special files: robots.txt, service-worker.js, favicon.ico 5 being served under special URIs. 6 The package takes care of 7 * template execution 8 * mime typing 9 * HTTP caching 10 * service worker pre-caching 11 * consistent versioning 12 * gzipping 13 * registering routes with a http.ServeMux 14 * handler funcs for HTTP request serving 15 16 Template execution allows custom funcs for arbitrary dynamic preparations. 17 18 A few are provided, to prepare Google PWA config files 19 dynamically from whatever is in the directories under ./app-bucket. 20 21 We dynamically generate 22 * a list of files for the service worker pre-cache 23 * a list of icons for manifest.json 24 * a version constant for indexed DB schema version in db.js 25 26 All file preparation logic is put together in the HTTP handle func PrepareStatic(...). 27 Thus you whenever you changed any static file contents, 28 call PrepareStatic(), and you get a *consistent* new version of all static files, 29 and you force your HTTP client (aka browser) to load 30 31 Todo: 32 * Make the config loadable via JSON 33 * Javascript templating is done in a highly inappropriate way; cannot get idiomatic way to work 34 * Markdown with some pre-processing is missing 35 36 */ 37 package static 38 39 import ( 40 "fmt" 41 "io" 42 "io/fs" 43 "log" 44 "net/http" 45 "os" 46 "path" 47 "strings" 48 49 "github.com/pbberlin/go-pwa/pkg/cfg" 50 "github.com/pbberlin/go-pwa/pkg/gziphandler" 51 "github.com/pbberlin/go-pwa/pkg/gzipper" 52 ) 53 54 func init() { 55 // cfg.RunHandleFunc(prepareStatic, "/prepare-static") 56 } 57 58 // serviceWorkerPreCache contains settings 59 // to include directory files into service worker install pre-cache 60 type serviceWorkerPreCache struct { 61 cache bool // part of service worker install pre-cache 62 includeExtensions []string // only include files with these extensions, i.e. ".webp" 63 } 64 65 // dirT contains settings for a directory of static files 66 type dirT struct { 67 isSingleFile bool // mostly directories, a few special files like favicon.ico, robots.txt 68 69 srcTpl string // template file to generate src in pre-pre run 70 tplExecutor func(dirsT, dirT, http.ResponseWriter) 71 72 src string // server side directory name; app path 73 fn string // file name - for single files 74 75 urlPath string // client side URI 76 MimeType string 77 HTTPCache int // HTTP response header caching time 78 79 includeExtensions []string // only include files with these extensions into directory processing, i.e. ".webp" 80 81 preGZIP bool // file is gzipped during preprocess 82 liveGZIP bool // file is gzipped at each request time 83 swpc serviceWorkerPreCache 84 85 HeadTemplate string // template for HTML head section, i.e. "<script src=..." or "<link href=..." 86 87 } 88 89 type dirsT map[string]dirT 90 91 // filesOfDir is a helper returning all files and directories inside dirSrc 92 func filesOfDir(dirSrc string) ([]fs.FileInfo, error) { 93 dirHandle, err := os.Open(dirSrc) // open 94 if err != nil { 95 return nil, fmt.Errorf("could not open dir %v, error %v\n", dirSrc, err) 96 } 97 files, err := dirHandle.Readdir(0) // get files 98 if err != nil { 99 return nil, fmt.Errorf("could not read dir contents of %v, error %v\n", dirSrc, err) 100 } 101 return files, nil 102 } 103 104 func zipOrCopy(w http.ResponseWriter, dir dirT, fn string) { 105 106 if dir.isSingleFile { 107 fn = dir.fn 108 } 109 110 dirDst := path.Join(dir.src, fmt.Sprint(cfg.Get().TS)) 111 err := os.MkdirAll(dirDst, 0755) 112 if err != nil { 113 fmt.Fprintf(w, "could not create dir %v, %v\n", dir.src, err) 114 return 115 } 116 117 pthSrc := path.Join(dir.src, fn) 118 pthDst := path.Join(dirDst, fn) 119 120 // gzip JavaScript and CSS, but not images 121 if dir.preGZIP { 122 // 123 // closure because of defer 124 zipIt := func() { 125 gzw, err := gzipper.New(pthDst) 126 if err != nil { 127 fmt.Fprint(w, err) 128 return 129 } 130 defer gzw.Close() 131 // gzw.WriteString("gzipper.go created this file.\n") 132 err = gzw.WriteFile(pthSrc) 133 if err != nil { 134 fmt.Fprint(w, err) 135 return 136 } 137 } 138 zipIt() 139 } else { 140 // dont gzip 141 bts, err := os.ReadFile(pthSrc) 142 if err != nil { 143 fmt.Fprint(w, err) 144 return 145 } 146 os.WriteFile(pthDst, bts, 0644) 147 } 148 149 fmt.Fprintf(w, "\t %v\n", fn) 150 151 } 152 153 // PrepareStatic creates directories with versioned files 154 // PrepareStatic zips JS and CSS files 155 // PrepareStatic compiles "<script src..." and "<link href="/" blocks 156 func (dirs dirsT) PrepareStatic(w http.ResponseWriter, req *http.Request) { 157 158 w.Header().Set("Content-Type", "text/plain") 159 fmt.Fprint(w, "prepare static files\n") 160 161 for _, dir := range dirs { 162 163 if dir.isSingleFile { 164 165 // if dir.srcTpl != "" { 166 // dir.execServiceWorker(w) 167 if dir.tplExecutor != nil { 168 dir.tplExecutor(dirs, dir, w) 169 } 170 171 if dir.urlPath == "" { 172 continue 173 } 174 175 zipOrCopy(w, dir, "") 176 177 continue // separate loop 178 } 179 180 fmt.Fprintf(w, "start src dir %v\n", dir.src) 181 182 sb := &strings.Builder{} 183 184 files, err := filesOfDir(dir.src) 185 if err != nil { 186 fmt.Fprint(w, err) 187 return 188 } 189 190 for _, file := range files { 191 192 if strings.HasSuffix(file.Name(), ".gzip") { 193 continue 194 } 195 196 if len(dir.includeExtensions) > 0 { 197 for _, ext := range dir.includeExtensions { 198 if strings.HasSuffix(file.Name(), ext) { 199 continue 200 } 201 } 202 } 203 204 if file.IsDir() { 205 continue 206 } 207 208 if dir.HeadTemplate != "" { 209 // fmt.Fprintf(sb, ` <script src="/js/%s/%v" nonce="%s" ></script>`, cfg.Get().TS, file.Name(), cfg.Get().TS) 210 fmt.Fprintf(sb, dir.HeadTemplate, cfg.Get().TS, file.Name(), cfg.Get().TS) 211 fmt.Fprint(sb, "\n") 212 } 213 214 zipOrCopy(w, dir, file.Name()) 215 216 } 217 218 if strings.HasPrefix(dir.urlPath, "/js/") { 219 cfg.Set().JS = sb.String() 220 } 221 if strings.HasPrefix(dir.urlPath, "/css/") { 222 cfg.Set().CSS = sb.String() 223 } 224 225 fmt.Fprintf(w, "stop src dir %v\n\n", dir.src) 226 227 } 228 229 } 230 231 // addVersion is a helper to serveStatic; its purpose: 232 // if images or json files are requested without any version 233 // then serve current version 234 func addVersion(r *http.Request) *http.Request { 235 236 parts := strings.Split(r.URL.Path, "/") 237 if len(parts) > 3 { // behold empty token from leading "/" 238 return r 239 } 240 // log.Printf("parts are %+v", parts) 241 needle := fmt.Sprintf("/%v/", parts[1]) 242 replacement := fmt.Sprintf("/%v/%v/", parts[1], cfg.Get().TS) 243 r.URL.Path = strings.Replace(r.URL.Path, needle, replacement, 1) 244 // log.Printf("new URL %v", r.URL.Path) 245 return r 246 } 247 248 func (dirs dirsT) serveStatic(w http.ResponseWriter, r *http.Request) { 249 250 path := strings.TrimPrefix(r.URL.Path, "/") // avoid empty first token with split below 251 prefs := strings.Split(path, "/") 252 pref := "/" + prefs[0] 253 // log.Printf("static server path %v - prefix %v", path, pref) 254 255 dir, ok := dirs[pref] 256 if ok { 257 if dir.isSingleFile { 258 // log.Printf("static server path %v - prefix %v", path, pref) 259 } 260 // r = addVersion(r) 261 if dir.MimeType != "" { 262 w.Header().Set("Content-Type", dir.MimeType) 263 } 264 if dir.HTTPCache > 0 { 265 w.Header().Set("Cache-Control", fmt.Sprintf("public,max-age=%d", dir.HTTPCache)) 266 } 267 268 } else { 269 log.Printf("no static server info for %v", pref) 270 fmt.Fprintf(w, "no static server info for %v", pref) 271 return 272 } 273 274 if dir.isSingleFile { 275 // r.URL.Path = fmt.Sprintf("/txt/%v/robots.txt", cfg.Get().TS) 276 // r.URL.Path = fmt.Sprintf("/img/%v/favicon.ico", cfg.Get().TS) 277 // r.URL.Path = fmt.Sprintf("/js-service-worker/%v/service-worker.js", cfg.Get().TS) 278 279 r.URL.Path = fmt.Sprintf("%v%v/%v", dir.src, cfg.Get().TS, dir.fn) 280 r.URL.Path = strings.TrimPrefix(r.URL.Path, "./app-bucket") 281 } 282 283 pth := "./app-bucket" + r.URL.Path 284 285 if cfg.Get().StaticFilesGZIP && dir.preGZIP { 286 // everything but images 287 // is supposed to be pre-compressed 288 289 // if 290 // HasPrefix(r.URL.Path, "/js/") || 291 // HasPrefix(r.URL.Path, "/css/") || ... 292 pth += ".gzip" 293 w.Header().Set("Content-Encoding", "gzip") 294 } 295 296 file, err := os.Open(pth) 297 if err != nil { 298 log.Printf("error: %v", err) // path is in error msg 299 log.Printf("dir: %+v", dir) 300 referrer := r.Header.Get("Referer") 301 if referrer != "" { 302 log.Printf("\tfrom referrer %+v", referrer) 303 } 304 return 305 } 306 defer file.Close() 307 308 _, err = io.Copy(w, file) 309 if err != nil { 310 log.Printf("error writing filecontents into response: %v, %v", pth, err) 311 return 312 } 313 // log.Printf("%8v bytes written from %v", n, pth) 314 315 } 316 317 // Register URL handlers 318 func (dirs dirsT) Register(mux *http.ServeMux) { 319 320 for _, dir := range dirs { 321 if dir.urlPath == "" { 322 continue 323 } 324 if dir.isSingleFile { 325 mux.HandleFunc(dir.urlPath, dirs.serveStatic) 326 } else { 327 if cfg.Get().StaticFilesGZIP && dir.preGZIP { 328 mux.HandleFunc(dir.urlPath, dirs.serveStatic) 329 } else if cfg.Get().StaticFilesGZIP && dir.liveGZIP { 330 mux.Handle(dir.urlPath, gziphandler.GzipHandler(http.HandlerFunc(dirs.serveStatic))) 331 } else { 332 mux.HandleFunc(dir.urlPath, dirs.serveStatic) 333 // mux.Handle(dir.urlPath, http.HandlerFunc(dirs.serveStatic)) 334 } 335 } 336 } 337 338 }