Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 75 additions & 18 deletions weft/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
)

type asset struct {
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -179,9 +194,11 @@ func CreateSubResourcePreload(args ...string) (template.HTML, error) {
// }
// </script>
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
}
Expand Down Expand Up @@ -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}
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
139 changes: 139 additions & 0 deletions weft/assets_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package weft

import (
"io/fs"
"os"
"path/filepath"
"testing"
)

Expand Down Expand Up @@ -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 {
Expand Down
Loading