github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/pkg/assets/dynamic/dynamic.go (about)

     1  package dynamic
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/sha256"
     7  	"encoding/hex"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"net/url"
    13  	"os"
    14  	"time"
    15  
    16  	"github.com/andybalholm/brotli"
    17  	"github.com/cozy/cozy-stack/pkg/assets/model"
    18  	"github.com/cozy/cozy-stack/pkg/logger"
    19  	"github.com/hashicorp/go-multierror"
    20  	"github.com/ncw/swift/v2"
    21  )
    22  
    23  // ErrDynAssetNotFound is the error returned when a dynamic asset cannot be
    24  // found.
    25  var ErrDynAssetNotFound = errors.New("Dynamic asset was not found")
    26  
    27  var assetsClient = &http.Client{
    28  	Timeout: 30 * time.Second,
    29  }
    30  
    31  // CheckStatus checks that the FS for dynamic asset is available, or returns an
    32  // error if it is not the case. It also returns the latency.
    33  func CheckStatus(ctx context.Context) (time.Duration, error) {
    34  	if assetFS == nil {
    35  		return 0, nil
    36  	}
    37  	return assetFS.CheckStatus(ctx)
    38  }
    39  
    40  // ListAssets returns the list of the dynamic assets.
    41  func ListAssets() (map[string][]*model.Asset, error) {
    42  	return assetFS.List()
    43  }
    44  
    45  // GetAsset retrieves a raw asset from the dynamic FS and builds a fs.Asset
    46  func GetAsset(context, name string) (*model.Asset, error) {
    47  	// In unit tests, the assetFS is often not initialized
    48  	if assetFS == nil {
    49  		return nil, ErrDynAssetNotFound
    50  	}
    51  
    52  	return getAsset(assetFS, context, name)
    53  }
    54  
    55  func getAsset(fs AssetsFS, context, name string) (*model.Asset, error) {
    56  	// Re-constructing the asset struct from the dyn FS content
    57  	content, err := fs.Get(context, name)
    58  	if err != nil {
    59  		if errors.Is(err, swift.ObjectNotFound) || os.IsNotExist(err) {
    60  			return nil, ErrDynAssetNotFound
    61  		}
    62  		return nil, err
    63  	}
    64  
    65  	h := sha256.New()
    66  	_, err = h.Write(content)
    67  	if err != nil {
    68  		return nil, err
    69  	}
    70  	suma := h.Sum(nil)
    71  	sumx := hex.EncodeToString(suma)
    72  
    73  	buf := new(bytes.Buffer)
    74  	bw := brotli.NewWriter(buf)
    75  	if _, err = bw.Write(content); err != nil {
    76  		return nil, err
    77  	}
    78  	if err = bw.Close(); err != nil {
    79  		return nil, err
    80  	}
    81  	brotliContent := buf.Bytes()
    82  
    83  	asset := model.NewAsset(model.AssetOption{
    84  		Shasum:   sumx,
    85  		Name:     name,
    86  		Context:  context,
    87  		IsCustom: true,
    88  	}, content, brotliContent)
    89  
    90  	return asset, nil
    91  }
    92  
    93  // RemoveAsset removes a dynamic asset from Swift
    94  func RemoveAsset(context, name string) error {
    95  	return assetFS.Remove(context, name)
    96  }
    97  
    98  // RegisterCustomExternals ensures that the assets are in the Swift, and load
    99  // them from their source if they are not yet available.
   100  func RegisterCustomExternals(opts []model.AssetOption, maxTryCount int) error {
   101  	if len(opts) == 0 {
   102  		return nil
   103  	}
   104  
   105  	assetsCh := make(chan model.AssetOption)
   106  	doneCh := make(chan error)
   107  
   108  	for range opts {
   109  		go func() {
   110  			var err error
   111  			sleepDuration := 500 * time.Millisecond
   112  			opt := <-assetsCh
   113  
   114  			for tryCount := 0; tryCount < maxTryCount+1; tryCount++ {
   115  				err = registerCustomExternal(opt)
   116  				if err == nil {
   117  					break
   118  				}
   119  				logger.WithNamespace("statik").
   120  					Errorf("Could not load asset from %q, retrying in %s", opt.URL, sleepDuration)
   121  				time.Sleep(sleepDuration)
   122  				sleepDuration *= 4
   123  			}
   124  
   125  			doneCh <- err
   126  		}()
   127  	}
   128  
   129  	for _, opt := range opts {
   130  		assetsCh <- opt
   131  	}
   132  	close(assetsCh)
   133  
   134  	var errm error
   135  	for i := 0; i < len(opts); i++ {
   136  		if err := <-doneCh; err != nil {
   137  			errm = multierror.Append(errm, err)
   138  		}
   139  	}
   140  	return errm
   141  }
   142  
   143  func registerCustomExternal(opt model.AssetOption) error {
   144  	if opt.Context == "" {
   145  		logger.WithNamespace("custom assets").
   146  			Warnf("Could not load asset %s with empty context", opt.URL)
   147  		return nil
   148  	}
   149  
   150  	opt.IsCustom = true
   151  
   152  	assetURL := opt.URL
   153  
   154  	var body io.Reader
   155  
   156  	u, err := url.Parse(assetURL)
   157  	if err != nil {
   158  		return err
   159  	}
   160  
   161  	switch u.Scheme {
   162  	case "http", "https":
   163  		req, err := http.NewRequest(http.MethodGet, assetURL, nil)
   164  		if err != nil {
   165  			return err
   166  		}
   167  		res, err := assetsClient.Do(req)
   168  		if err != nil {
   169  			return err
   170  		}
   171  		defer res.Body.Close()
   172  		if res.StatusCode != http.StatusOK {
   173  			return fmt.Errorf("could not load external asset on %s: status code %d", assetURL, res.StatusCode)
   174  		}
   175  		body = res.Body
   176  	case "file":
   177  		f, err := os.Open(u.Path)
   178  		if err != nil {
   179  			return err
   180  		}
   181  		defer f.Close()
   182  		body = f
   183  	default:
   184  		return fmt.Errorf("does not support externals assets with scheme %q", u.Scheme)
   185  	}
   186  
   187  	h := sha256.New()
   188  	brotliBuf := new(bytes.Buffer)
   189  	bw := brotli.NewWriter(brotliBuf)
   190  
   191  	teeReader := io.TeeReader(body, io.MultiWriter(h, bw))
   192  	rawData, err := io.ReadAll(teeReader)
   193  	if err != nil {
   194  		return err
   195  	}
   196  	if errc := bw.Close(); errc != nil {
   197  		return errc
   198  	}
   199  
   200  	sum := h.Sum(nil)
   201  
   202  	if opt.Shasum == "" {
   203  		opt.Shasum = hex.EncodeToString(sum)
   204  		log := logger.WithNamespace("custom_external")
   205  		log.Warnf("shasum was not provided for file %s, inserting unsafe content %s: %s",
   206  			opt.Name, opt.URL, opt.Shasum)
   207  	}
   208  
   209  	if hex.EncodeToString(sum) != opt.Shasum {
   210  		return fmt.Errorf("external content checksum do not match: expected %s got %x on url %s",
   211  			opt.Shasum, sum, assetURL)
   212  	}
   213  
   214  	asset := model.NewAsset(opt, rawData, brotliBuf.Bytes())
   215  
   216  	err = assetFS.Add(asset.Context, asset.Name, asset)
   217  	if err != nil {
   218  		return err
   219  	}
   220  
   221  	return nil
   222  }