github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/release/shared/upload.go (about)

     1  package shared
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"io"
     7  	"mime"
     8  	"net/http"
     9  	"net/url"
    10  	"os"
    11  	"path"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/ungtb10d/cli/v2/api"
    16  )
    17  
    18  type AssetForUpload struct {
    19  	Name  string
    20  	Label string
    21  
    22  	Size     int64
    23  	MIMEType string
    24  	Open     func() (io.ReadCloser, error)
    25  
    26  	ExistingURL string
    27  }
    28  
    29  func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) {
    30  	for _, arg := range args {
    31  		var label string
    32  		fn := arg
    33  		if idx := strings.IndexRune(arg, '#'); idx > 0 {
    34  			fn = arg[0:idx]
    35  			label = arg[idx+1:]
    36  		}
    37  
    38  		var fi os.FileInfo
    39  		fi, err = os.Stat(fn)
    40  		if err != nil {
    41  			return
    42  		}
    43  
    44  		assets = append(assets, &AssetForUpload{
    45  			Open: func() (io.ReadCloser, error) {
    46  				return os.Open(fn)
    47  			},
    48  			Size:     fi.Size(),
    49  			Name:     fi.Name(),
    50  			Label:    label,
    51  			MIMEType: typeForFilename(fi.Name()),
    52  		})
    53  	}
    54  	return
    55  }
    56  
    57  func typeForFilename(fn string) string {
    58  	ext := fileExt(fn)
    59  	switch ext {
    60  	case ".zip":
    61  		return "application/zip"
    62  	case ".js":
    63  		return "application/javascript"
    64  	case ".tar":
    65  		return "application/x-tar"
    66  	case ".tgz", ".tar.gz":
    67  		return "application/x-gtar"
    68  	case ".bz2":
    69  		return "application/x-bzip2"
    70  	case ".dmg":
    71  		return "application/x-apple-diskimage"
    72  	case ".rpm":
    73  		return "application/x-rpm"
    74  	case ".deb":
    75  		return "application/x-debian-package"
    76  	}
    77  
    78  	t := mime.TypeByExtension(ext)
    79  	if t == "" {
    80  		return "application/octet-stream"
    81  	}
    82  	return t
    83  }
    84  
    85  func fileExt(fn string) string {
    86  	fn = strings.ToLower(fn)
    87  	if strings.HasSuffix(fn, ".tar.gz") {
    88  		return ".tar.gz"
    89  	}
    90  	return path.Ext(fn)
    91  }
    92  
    93  func ConcurrentUpload(httpClient *http.Client, uploadURL string, numWorkers int, assets []*AssetForUpload) error {
    94  	if numWorkers == 0 {
    95  		return errors.New("the number of concurrent workers needs to be greater than 0")
    96  	}
    97  
    98  	jobs := make(chan AssetForUpload, len(assets))
    99  	results := make(chan error, len(assets))
   100  
   101  	if len(assets) < numWorkers {
   102  		numWorkers = len(assets)
   103  	}
   104  
   105  	for w := 1; w <= numWorkers; w++ {
   106  		go func() {
   107  			for a := range jobs {
   108  				results <- uploadWithDelete(httpClient, uploadURL, a)
   109  			}
   110  		}()
   111  	}
   112  
   113  	for _, a := range assets {
   114  		jobs <- *a
   115  	}
   116  	close(jobs)
   117  
   118  	var uploadError error
   119  	for i := 0; i < len(assets); i++ {
   120  		if err := <-results; err != nil {
   121  			uploadError = err
   122  		}
   123  	}
   124  	return uploadError
   125  }
   126  
   127  const maxRetries = 3
   128  
   129  func uploadWithDelete(httpClient *http.Client, uploadURL string, a AssetForUpload) error {
   130  	if a.ExistingURL != "" {
   131  		err := deleteAsset(httpClient, a.ExistingURL)
   132  		if err != nil {
   133  			return err
   134  		}
   135  	}
   136  
   137  	retries := 0
   138  	for {
   139  		var httpError api.HTTPError
   140  		_, err := uploadAsset(httpClient, uploadURL, a)
   141  		// retry upload several times upon receiving HTTP 5xx
   142  		if err == nil || !errors.As(err, &httpError) || httpError.StatusCode < 500 || retries < maxRetries {
   143  			return err
   144  		}
   145  		retries++
   146  		time.Sleep(time.Duration(retries) * time.Second)
   147  	}
   148  }
   149  
   150  func uploadAsset(httpClient *http.Client, uploadURL string, asset AssetForUpload) (*ReleaseAsset, error) {
   151  	u, err := url.Parse(uploadURL)
   152  	if err != nil {
   153  		return nil, err
   154  	}
   155  	params := u.Query()
   156  	params.Set("name", asset.Name)
   157  	params.Set("label", asset.Label)
   158  	u.RawQuery = params.Encode()
   159  
   160  	f, err := asset.Open()
   161  	if err != nil {
   162  		return nil, err
   163  	}
   164  	defer f.Close()
   165  
   166  	req, err := http.NewRequest("POST", u.String(), f)
   167  	if err != nil {
   168  		return nil, err
   169  	}
   170  	req.ContentLength = asset.Size
   171  	req.Header.Set("Content-Type", asset.MIMEType)
   172  	req.GetBody = asset.Open
   173  
   174  	resp, err := httpClient.Do(req)
   175  	if err != nil {
   176  		return nil, err
   177  	}
   178  	defer resp.Body.Close()
   179  
   180  	success := resp.StatusCode >= 200 && resp.StatusCode < 300
   181  	if !success {
   182  		return nil, api.HandleHTTPError(resp)
   183  	}
   184  
   185  	b, err := io.ReadAll(resp.Body)
   186  	if err != nil {
   187  		return nil, err
   188  	}
   189  
   190  	var newAsset ReleaseAsset
   191  	err = json.Unmarshal(b, &newAsset)
   192  	if err != nil {
   193  		return nil, err
   194  	}
   195  
   196  	return &newAsset, nil
   197  }
   198  
   199  func deleteAsset(httpClient *http.Client, assetURL string) error {
   200  	req, err := http.NewRequest("DELETE", assetURL, nil)
   201  	if err != nil {
   202  		return err
   203  	}
   204  
   205  	resp, err := httpClient.Do(req)
   206  	if err != nil {
   207  		return err
   208  	}
   209  	defer resp.Body.Close()
   210  
   211  	success := resp.StatusCode >= 200 && resp.StatusCode < 300
   212  	if !success {
   213  		return api.HandleHTTPError(resp)
   214  	}
   215  
   216  	return nil
   217  }