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 }