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  }