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  }