diff --git a/weft/assets.go b/weft/assets.go index 10bb22a..d40f25b 100644 --- a/weft/assets.go +++ b/weft/assets.go @@ -16,6 +16,7 @@ import ( "path/filepath" "sort" "strings" + "sync" ) type asset struct { @@ -32,16 +33,24 @@ func (a asset) String() string { return fmt.Sprintf("asset{path:%s, hashedPath:%s, mime:%s, fileType:%s, sri:%s}", a.path, a.hashedPath, a.mime, a.fileType, a.sri) } -// assets is populated during init and then is only used for reading. -var assets map[string]*asset - -// assetHashes maps asset filename to the corresponding hash-prefixed asset pathname. -var assetHashes map[string]string -var assetError error +var assetStore AssetStore + +type AssetStore struct { + mu sync.RWMutex + // The directory to pull assets from + directory string + // The prefix of each asset (this will be stripped from the asset path). + prefix string + // assets is populated during init. + assets map[string]*asset + // hashes maps asset filename to the corresponding hash-prefixed asset pathname. + hashes map[string]string + error error +} func init() { // optionally, one can call InitAssets() to re-init to another directory - assetError = InitAssets("assets/assets", "assets") + _ = InitAssets("assets/assets", "assets") } // As part of Subresource Integrity we need to calculate the hash of the asset, we do this when the asset is loaded into memory @@ -122,6 +131,9 @@ func createSubResourcePreloadTag(a *asset, nonce string) (string, error) { // args can be 1~3 strings: 1. the asset path, 2. nonce for script attribute, // 3. script loading attribute ("defer" or "async"). func CreateSubResourceTag(args ...string) (template.HTML, error) { + assetStore.mu.RLock() + defer assetStore.mu.RUnlock() + var nonce string if len(args) > 1 { nonce = args[1] @@ -132,11 +144,11 @@ func CreateSubResourceTag(args ...string) (template.HTML, error) { attr = args[2] } } - hashedPath, ok := assetHashes[args[0]] + hashedPath, ok := assetStore.hashes[args[0]] if !ok { return template.HTML(""), fmt.Errorf("hashed pathname for asset not found for '%s", args[0]) } - a, ok := assets[hashedPath] + a, ok := assetStore.assets[hashedPath] if !ok { return template.HTML(""), fmt.Errorf("asset does not exist at path '%v'", hashedPath) } @@ -150,15 +162,18 @@ func CreateSubResourceTag(args ...string) (template.HTML, error) { // allow the file to be fetched in parallel with the module file that imports it, and also allows us // to set the SRI attribute of imported modules. func CreateSubResourcePreload(args ...string) (template.HTML, error) { + assetStore.mu.RLock() + defer assetStore.mu.RUnlock() + var nonce string if len(args) > 1 { nonce = args[1] } - hashedPath, ok := assetHashes[args[0]] + hashedPath, ok := assetStore.hashes[args[0]] if !ok { return template.HTML(""), fmt.Errorf("hashed pathname for asset not found for '%s", args[0]) } - a, ok := assets[hashedPath] + a, ok := assetStore.assets[hashedPath] if !ok { return template.HTML(""), fmt.Errorf("asset does not exist at path '%v'", hashedPath) } @@ -179,9 +194,11 @@ func CreateSubResourcePreload(args ...string) (template.HTML, error) { // } // func CreateImportMap(nonce string) template.HTML { + assetStore.mu.RLock() + defer assetStore.mu.RUnlock() importMapping := make(map[string]string, 0) - for k, v := range assetHashes { + for k, v := range assetStore.hashes { if !strings.HasSuffix(k, ".mjs") { continue } @@ -232,16 +249,19 @@ func createImportMapTag(importMapping map[string]string, nonce string) string { // // The finger printed path can be looked up with AssetPath. func AssetHandler(r *http.Request, h http.Header, b *bytes.Buffer) error { + assetStore.mu.RLock() + defer assetStore.mu.RUnlock() + err := CheckQuery(r, []string{"GET"}, []string{}, []string{"v"}) if err != nil { return err } - if assetError != nil { - return assetError + if assetStore.error != nil { + return assetStore.error } - a := assets[r.URL.Path] + a := assetStore.assets[r.URL.Path] if a == nil { return StatusError{Code: http.StatusNotFound} } @@ -255,6 +275,34 @@ func AssetHandler(r *http.Request, h http.Header, b *bytes.Buffer) error { return nil } +// UpdateAsset adds a single asset file to the assetStore. This is useful +// in development to support hot reloading changes to asset files. +func UpdateAsset(file string) error { + assetStore.mu.Lock() + defer assetStore.mu.Unlock() + + // Ignore adding assets that aren't in the store's chosen directory + if !strings.HasPrefix(file, assetStore.directory) { + return fmt.Errorf("asset not in assetStore's directory. directory: %s , asset path: %s", assetStore.directory, file) + } + + a, err := loadAsset(file, assetStore.prefix) + if err != nil { + return err + } + // Remove existing asset + existing := assetStore.assets[strings.TrimPrefix(file, assetStore.prefix)] + delete(assetStore.assets, existing.hashedPath) + delete(assetStore.assets, existing.path) + delete(assetStore.hashes, existing.path) + + // Add updated asset + assetStore.assets[a.hashedPath] = a + assetStore.assets[a.path] = a + assetStore.hashes[a.path] = a.hashedPath + return nil +} + // loadAsset loads file and finger prints it with a sha256 hash. prefix is stripped // from path members in the returned asset. func loadAsset(file, prefix string) (*asset, error) { @@ -332,11 +380,14 @@ func loadAsset(file, prefix string) (*asset, error) { // InitAssets loads all assets below dir into global maps. func InitAssets(dir, prefix string) error { + assetStore.mu.Lock() + defer assetStore.mu.Unlock() + var fileList []string - assets = make(map[string]*asset) - assetHashes = make(map[string]string) - assetError = func() error { + assets := make(map[string]*asset) + assetHashes := make(map[string]string) + assetError := func() error { err := filepath.Walk(dir, func(path string, f os.FileInfo, err error) error { fileList = append(fileList, path) return nil @@ -365,5 +416,11 @@ func InitAssets(dir, prefix string) error { return nil }() + assetStore.directory = dir + assetStore.prefix = prefix + assetStore.assets = assets + assetStore.hashes = assetHashes + assetStore.error = assetError + return assetError } diff --git a/weft/assets_test.go b/weft/assets_test.go index cb04c50..3c5ce00 100644 --- a/weft/assets_test.go +++ b/weft/assets_test.go @@ -1,6 +1,9 @@ package weft import ( + "io/fs" + "os" + "path/filepath" "testing" ) @@ -88,6 +91,142 @@ func TestLoadAssets(t *testing.T) { } } +func TestUpdateAsset(t *testing.T) { + + testData := []struct { + testName string + filename string + append string + expectedInitial *asset + expectedInitialBytes int + expectedResult *asset + }{ + { + "Update CSS file", + "testdata/leaflet.css", + "abc", + &asset{ + path: "/leaflet.css", + hashedPath: "/07800b98-leaflet.css", + mime: "text/css", + fileType: "css", + sri: "sha384-9oKBsxAYdVVBJcv3hwG8RjuoJhw9GwYLqXdQRDxi2q0t1AImNHOap8y6Qt7REVd4", + }, + 13429, + &asset{ + path: "/leaflet.css", + hashedPath: "/35aea7ae-leaflet.css", + mime: "text/css", + fileType: "css", + sri: "sha384-pQdxLofki9LA7dW8kunwJTtCD/uhhLglB46EU576cEgXCtj7bJqASfVDb7IVDxnC", + }, + }, + } + + for _, d := range testData { + + t.Run(d.testName, func(t *testing.T) { + + // Make a copy of test data into temp directory, and count number of files. + tmpDir := t.TempDir() + + count := 0 + err := filepath.WalkDir("testdata", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + destPath := filepath.Join(tmpDir, d.Name()) + + input, err := os.ReadFile(path) //nolint:gosec + if err != nil { + t.Fatalf("failed to read source file: %v", err) + } + if err := os.WriteFile(destPath, input, 0600); err != nil { //nolint: gosec + t.Fatalf("failed to copy file to temp dir: %v", err) + } + count++ + } + return nil + }) + if err != nil { + t.Error(err) + } + if count < 1 { + t.Fatal("should be at least one test file in testdata") + } + + err = InitAssets(tmpDir, tmpDir) + if err != nil { + t.Error(err) + } + + assetsLength := len(assetStore.assets) + hashesLength := len(assetStore.hashes) + + if assetsLength != count*2 { + t.Errorf("expected %v files in asset store, found %v", count*2, assetsLength) + } + if hashesLength != count { + t.Errorf("expected %v files in asset store hashes, found %v", count, hashesLength) + } + + // Append to end of file to make a change + destPath := filepath.Join(tmpDir, d.expectedInitial.path) + f, err := os.OpenFile(destPath, os.O_APPEND|os.O_WRONLY, 0600) //nolint:gosec + if err != nil { + t.Fatal(err) + } + _, err = f.WriteString(d.append) + if err != nil { + t.Fatal(err) + } + err = f.Close() + if err != nil { + t.Fatal(err) + } + + // Action + if err := UpdateAsset(destPath); err != nil { + t.Errorf("failed to update asset: %v", err) + } + + // Assert + got, ok := assetStore.assets[d.expectedResult.path] + if !ok { + t.Fatalf("path %s not found in store", d.expectedResult.path) + } + gotHashed, ok := assetStore.assets[d.expectedResult.hashedPath] + if !ok { + t.Fatalf("hashed path %s not found in store", d.expectedResult.path) + } + if got.hashedPath != gotHashed.hashedPath || got.sri != gotHashed.sri { + t.Fatalf("expected asset for path and hashedPath to be the same") + } + + expectedLength := d.expectedInitialBytes + len(d.append) + if len(got.b) != expectedLength { + t.Errorf("expected %d bytes, got %d", expectedLength, len(got.b)) + } + if got.hashedPath != d.expectedResult.hashedPath { + t.Errorf("expected hashed path %s instead got %s", d.expectedResult.hashedPath, got.hashedPath) + } + if got.sri != d.expectedResult.sri { + t.Errorf("expected sri hash %s instead got %s", d.expectedResult.sri, got.sri) + } + newAssetsLength := len(assetStore.assets) + newHashesLength := len(assetStore.hashes) + + if newAssetsLength != assetsLength { + t.Errorf("asset store assets unexpected length (expected no change). Expected: %v Found: %v", assetsLength, newAssetsLength) + } + if newHashesLength != hashesLength { + t.Errorf("asset store hashes unexpected length (expected no change). Expected: %v Found: %v", hashesLength, newHashesLength) + } + }) + } +} + func TestCreateSubResourceTag(t *testing.T) { err := InitAssets("testdata", "testdata") if err != nil {