github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/app/fetcher_http.go (about) 1 package app 2 3 import ( 4 "archive/tar" 5 "bytes" 6 "compress/gzip" 7 "crypto/sha256" 8 "encoding/hex" 9 "errors" 10 "hash" 11 "io" 12 "net/http" 13 "net/url" 14 "os" 15 "path" 16 "strings" 17 "time" 18 19 "github.com/cozy/cozy-stack/pkg/appfs" 20 "github.com/cozy/cozy-stack/pkg/config/config" 21 "github.com/cozy/cozy-stack/pkg/logger" 22 "github.com/cozy/cozy-stack/pkg/prefixer" 23 "github.com/cozy/cozy-stack/pkg/utils" 24 "github.com/labstack/echo/v4" 25 ) 26 27 var httpClient = http.Client{ 28 Timeout: 60 * time.Second, 29 } 30 31 type httpFetcher struct { 32 manFilename string 33 prefix string 34 log logger.Logger 35 } 36 37 func newHTTPFetcher(manFilename string, log logger.Logger) *httpFetcher { 38 return &httpFetcher{ 39 manFilename: manFilename, 40 log: log, 41 } 42 } 43 44 func (f *httpFetcher) FetchManifest(src *url.URL) (r io.ReadCloser, err error) { 45 req, err := http.NewRequest(http.MethodGet, src.String(), nil) 46 if err != nil { 47 return nil, err 48 } 49 resp, err := httpClient.Do(req) 50 if err != nil { 51 return nil, err 52 } 53 defer func() { 54 if err != nil { 55 // Flush the body, so that the connecion can be reused by keep-alive 56 _, _ = io.Copy(io.Discard, resp.Body) 57 resp.Body.Close() 58 } 59 }() 60 if resp.StatusCode != 200 { 61 return nil, ErrManifestNotReachable 62 } 63 64 var reader io.Reader = resp.Body 65 66 contentType := resp.Header.Get(echo.HeaderContentType) 67 switch contentType { 68 case 69 "application/gzip", 70 "application/x-gzip", 71 "application/x-tgz", 72 "application/tar+gzip": 73 reader, err = gzip.NewReader(reader) 74 if err != nil { 75 return nil, ErrManifestNotReachable 76 } 77 } 78 79 tarReader := tar.NewReader(reader) 80 for { 81 hdr, err := tarReader.Next() 82 if errors.Is(err, io.EOF) { 83 return nil, ErrManifestNotReachable 84 } 85 if err != nil { 86 return nil, ErrManifestNotReachable 87 } 88 if hdr.Typeflag != tar.TypeReg { 89 continue 90 } 91 baseName := path.Base(hdr.Name) 92 if baseName != f.manFilename { 93 continue 94 } 95 if baseName != hdr.Name { 96 f.prefix = path.Dir(hdr.Name) + "/" 97 } 98 return utils.ReadCloser(tarReader, func() error { 99 return resp.Body.Close() 100 }), nil 101 } 102 } 103 104 func (f *httpFetcher) Fetch(src *url.URL, fs appfs.Copier, man Manifest) (err error) { 105 var shasum []byte 106 if frag := src.Fragment; frag != "" { 107 shasum, _ = hex.DecodeString(frag) 108 } 109 return fetchHTTP(src, shasum, fs, man, f.prefix) 110 } 111 112 func fetchHTTP(src *url.URL, shasum []byte, fs appfs.Copier, man Manifest, prefix string) (err error) { 113 // Happy path: it exists and we don't need to acquire the lock. 114 exists, err := fs.Exist(man.Slug(), man.Version(), man.Checksum()) 115 if err != nil || exists { 116 return err 117 } 118 119 // For the lock key, we use the checksum when available, and else, we 120 // fallback on the app name. 121 mu := config.Lock().ReadWrite(prefixer.GlobalPrefixer, "app-"+man.Slug()+"-"+man.Checksum()) 122 if err = mu.Lock(); err != nil { 123 log := logger.WithNamespace("fetcher") 124 log.Infof("cannot get lock: %s", err) 125 return err 126 } 127 defer mu.Unlock() 128 129 // We need to check again if it exists, as the app can have been installed 130 // while we were waiting for the lock. 131 exists, err = fs.Start(man.Slug(), man.Version(), man.Checksum()) 132 if err != nil || exists { 133 return err 134 } 135 defer func() { 136 if err != nil { 137 _ = fs.Abort() 138 } else { 139 err = fs.Commit() 140 } 141 }() 142 143 req, err := http.NewRequest(http.MethodGet, src.String(), nil) 144 if err != nil { 145 return err 146 } 147 start := time.Now() 148 resp, err := httpClient.Do(req) 149 elapsed := time.Since(start) 150 if err != nil { 151 log := logger.WithNamespace("fetcher") 152 log.Infof("cannot fetch %s: %s", src.String(), err) 153 return err 154 } 155 defer resp.Body.Close() 156 if elapsed.Seconds() >= 10 { 157 log := logger.WithNamespace("fetcher") 158 log.Infof("slow request on %s (%s)", src.String(), elapsed) 159 } 160 if resp.StatusCode != 200 { 161 return ErrSourceNotReachable 162 } 163 164 var reader io.Reader = resp.Body 165 var h hash.Hash 166 167 if len(shasum) > 0 { 168 h = sha256.New() 169 reader = io.TeeReader(reader, h) 170 } 171 172 contentType := resp.Header.Get(echo.HeaderContentType) 173 switch contentType { 174 case 175 "application/gzip", 176 "application/x-gzip", 177 "application/x-tgz", 178 "application/tar+gzip": 179 reader, err = gzip.NewReader(reader) 180 if err != nil { 181 return err 182 } 183 case "application/octet-stream": 184 if r, err := gzip.NewReader(reader); err == nil { 185 reader = r 186 } 187 } 188 189 tarReader := tar.NewReader(reader) 190 for { 191 hdr, err := tarReader.Next() 192 if errors.Is(err, io.EOF) { 193 break 194 } 195 if err != nil { 196 return err 197 } 198 if hdr.Typeflag != tar.TypeReg { 199 continue 200 } 201 name := hdr.Name 202 if len(prefix) > 0 && strings.HasPrefix(path.Join("/", name), path.Join("/", prefix)) { 203 name = name[len(prefix):] 204 } 205 fileinfo := appfs.NewFileInfo(name, hdr.Size, os.FileMode(hdr.Mode)) 206 err = fs.Copy(fileinfo, tarReader) 207 if err != nil { 208 return err 209 } 210 } 211 if len(shasum) > 0 && !bytes.Equal(shasum, h.Sum(nil)) { 212 return ErrBadChecksum 213 } 214 return nil 215 }