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

     1  package download
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io"
     7  	"mime"
     8  	"net/http"
     9  	"os"
    10  	"path/filepath"
    11  	"regexp"
    12  
    13  	"github.com/MakeNowJust/heredoc"
    14  	"github.com/ungtb10d/cli/v2/api"
    15  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    16  	"github.com/ungtb10d/cli/v2/pkg/cmd/release/shared"
    17  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    18  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    19  	"github.com/spf13/cobra"
    20  )
    21  
    22  type DownloadOptions struct {
    23  	HttpClient        func() (*http.Client, error)
    24  	IO                *iostreams.IOStreams
    25  	BaseRepo          func() (ghrepo.Interface, error)
    26  	OverwriteExisting bool
    27  	SkipExisting      bool
    28  	TagName           string
    29  	FilePatterns      []string
    30  	Destination       string
    31  	OutputFile        string
    32  
    33  	// maximum number of simultaneous downloads
    34  	Concurrency int
    35  
    36  	ArchiveType string
    37  }
    38  
    39  func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command {
    40  	opts := &DownloadOptions{
    41  		IO:         f.IOStreams,
    42  		HttpClient: f.HttpClient,
    43  	}
    44  
    45  	cmd := &cobra.Command{
    46  		Use:   "download [<tag>]",
    47  		Short: "Download release assets",
    48  		Long: heredoc.Doc(`
    49  			Download assets from a GitHub release.
    50  
    51  			Without an explicit tag name argument, assets are downloaded from the
    52  			latest release in the project. In this case, '--pattern' is required.
    53  		`),
    54  		Example: heredoc.Doc(`
    55  			# download all assets from a specific release
    56  			$ gh release download v1.2.3
    57  
    58  			# download only Debian packages for the latest release
    59  			$ gh release download --pattern '*.deb'
    60  
    61  			# specify multiple file patterns
    62  			$ gh release download -p '*.deb' -p '*.rpm'
    63  
    64  			# download the archive of the source code for a release
    65  			$ gh release download v1.2.3 --archive=zip
    66  		`),
    67  		Args: cobra.MaximumNArgs(1),
    68  		RunE: func(cmd *cobra.Command, args []string) error {
    69  			// support `-R, --repo` override
    70  			opts.BaseRepo = f.BaseRepo
    71  
    72  			if len(args) == 0 {
    73  				if len(opts.FilePatterns) == 0 && opts.ArchiveType == "" {
    74  					return cmdutil.FlagErrorf("`--pattern` or `--archive` is required when downloading the latest release")
    75  				}
    76  			} else {
    77  				opts.TagName = args[0]
    78  			}
    79  
    80  			if err := cmdutil.MutuallyExclusive("specify only one of `--clobber` or `--skip-existing`", opts.OverwriteExisting, opts.SkipExisting); err != nil {
    81  				return err
    82  			}
    83  
    84  			if err := cmdutil.MutuallyExclusive("specify only one of `--dir` or `--output`", opts.Destination != ".", opts.OutputFile != ""); err != nil {
    85  				return err
    86  			}
    87  
    88  			// check archive type option validity
    89  			if err := checkArchiveTypeOption(opts); err != nil {
    90  				return err
    91  			}
    92  
    93  			opts.Concurrency = 5
    94  
    95  			if runF != nil {
    96  				return runF(opts)
    97  			}
    98  			return downloadRun(opts)
    99  		},
   100  	}
   101  
   102  	cmd.Flags().StringVarP(&opts.OutputFile, "output", "O", "", "The `file` to write a single asset to (use \"-\" to write to standard output)")
   103  	cmd.Flags().StringVarP(&opts.Destination, "dir", "D", ".", "The `directory` to download files into")
   104  	cmd.Flags().StringArrayVarP(&opts.FilePatterns, "pattern", "p", nil, "Download only assets that match a glob pattern")
   105  	cmd.Flags().StringVarP(&opts.ArchiveType, "archive", "A", "", "Download the source code archive in the specified `format` (zip or tar.gz)")
   106  	cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing files of the same name")
   107  	cmd.Flags().BoolVar(&opts.SkipExisting, "skip-existing", false, "Skip downloading when files of the same name exist")
   108  
   109  	return cmd
   110  }
   111  
   112  func checkArchiveTypeOption(opts *DownloadOptions) error {
   113  	if len(opts.ArchiveType) == 0 {
   114  		return nil
   115  	}
   116  
   117  	if err := cmdutil.MutuallyExclusive(
   118  		"specify only one of '--pattern' or '--archive'",
   119  		true, // ArchiveType len > 0
   120  		len(opts.FilePatterns) > 0,
   121  	); err != nil {
   122  		return err
   123  	}
   124  
   125  	if opts.ArchiveType != "zip" && opts.ArchiveType != "tar.gz" {
   126  		return cmdutil.FlagErrorf("the value for `--archive` must be one of \"zip\" or \"tar.gz\"")
   127  	}
   128  	return nil
   129  }
   130  
   131  func downloadRun(opts *DownloadOptions) error {
   132  	httpClient, err := opts.HttpClient()
   133  	if err != nil {
   134  		return err
   135  	}
   136  
   137  	baseRepo, err := opts.BaseRepo()
   138  	if err != nil {
   139  		return err
   140  	}
   141  
   142  	opts.IO.StartProgressIndicator()
   143  	defer opts.IO.StopProgressIndicator()
   144  
   145  	var release *shared.Release
   146  	if opts.TagName == "" {
   147  		release, err = shared.FetchLatestRelease(httpClient, baseRepo)
   148  		if err != nil {
   149  			return err
   150  		}
   151  	} else {
   152  		release, err = shared.FetchRelease(httpClient, baseRepo, opts.TagName)
   153  		if err != nil {
   154  			return err
   155  		}
   156  	}
   157  
   158  	var toDownload []shared.ReleaseAsset
   159  	isArchive := false
   160  	if opts.ArchiveType != "" {
   161  		var archiveURL = release.ZipballURL
   162  		if opts.ArchiveType == "tar.gz" {
   163  			archiveURL = release.TarballURL
   164  		}
   165  		// create pseudo-Asset with no name and pointing to ZipBallURL or TarBallURL
   166  		toDownload = append(toDownload, shared.ReleaseAsset{APIURL: archiveURL})
   167  		isArchive = true
   168  	} else {
   169  		for _, a := range release.Assets {
   170  			if len(opts.FilePatterns) > 0 && !matchAny(opts.FilePatterns, a.Name) {
   171  				continue
   172  			}
   173  			toDownload = append(toDownload, a)
   174  		}
   175  	}
   176  
   177  	if len(toDownload) == 0 {
   178  		if len(release.Assets) > 0 {
   179  			return errors.New("no assets match the file pattern")
   180  		}
   181  		return errors.New("no assets to download")
   182  	}
   183  
   184  	if len(toDownload) > 1 && opts.OutputFile != "" {
   185  		return fmt.Errorf("unable to write more than one asset with `--output`, got %d assets", len(toDownload))
   186  	}
   187  
   188  	dest := destinationWriter{
   189  		file:         opts.OutputFile,
   190  		dir:          opts.Destination,
   191  		skipExisting: opts.SkipExisting,
   192  		overwrite:    opts.OverwriteExisting,
   193  		stdout:       opts.IO.Out,
   194  	}
   195  
   196  	return downloadAssets(&dest, httpClient, toDownload, opts.Concurrency, isArchive)
   197  }
   198  
   199  func matchAny(patterns []string, name string) bool {
   200  	for _, p := range patterns {
   201  		if isMatch, err := filepath.Match(p, name); err == nil && isMatch {
   202  			return true
   203  		}
   204  	}
   205  	return false
   206  }
   207  
   208  func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []shared.ReleaseAsset, numWorkers int, isArchive bool) error {
   209  	if numWorkers == 0 {
   210  		return errors.New("the number of concurrent workers needs to be greater than 0")
   211  	}
   212  
   213  	jobs := make(chan shared.ReleaseAsset, len(toDownload))
   214  	results := make(chan error, len(toDownload))
   215  
   216  	if len(toDownload) < numWorkers {
   217  		numWorkers = len(toDownload)
   218  	}
   219  
   220  	for w := 1; w <= numWorkers; w++ {
   221  		go func() {
   222  			for a := range jobs {
   223  				results <- downloadAsset(dest, httpClient, a.APIURL, a.Name, isArchive)
   224  			}
   225  		}()
   226  	}
   227  
   228  	for _, a := range toDownload {
   229  		jobs <- a
   230  	}
   231  	close(jobs)
   232  
   233  	var downloadError error
   234  	for i := 0; i < len(toDownload); i++ {
   235  		if err := <-results; err != nil && !errors.Is(err, errSkipped) {
   236  			downloadError = err
   237  		}
   238  	}
   239  
   240  	return downloadError
   241  }
   242  
   243  func downloadAsset(dest *destinationWriter, httpClient *http.Client, assetURL, fileName string, isArchive bool) error {
   244  	if err := dest.Check(fileName); err != nil {
   245  		return err
   246  	}
   247  
   248  	req, err := http.NewRequest("GET", assetURL, nil)
   249  	if err != nil {
   250  		return err
   251  	}
   252  
   253  	req.Header.Set("Accept", "application/octet-stream")
   254  	if isArchive {
   255  		// adding application/json to Accept header due to a bug in the zipball/tarball API endpoint that makes it mandatory
   256  		req.Header.Set("Accept", "application/octet-stream, application/json")
   257  
   258  		// override HTTP redirect logic to avoid "legacy" Codeload resources
   259  		oldClient := *httpClient
   260  		httpClient = &oldClient
   261  		httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
   262  			if len(via) == 1 {
   263  				req.URL.Path = removeLegacyFromCodeloadPath(req.URL.Path)
   264  			}
   265  			return nil
   266  		}
   267  	}
   268  
   269  	resp, err := httpClient.Do(req)
   270  	if err != nil {
   271  		return err
   272  	}
   273  	defer resp.Body.Close()
   274  
   275  	if resp.StatusCode > 299 {
   276  		return api.HandleHTTPError(resp)
   277  	}
   278  
   279  	if len(fileName) == 0 {
   280  		contentDisposition := resp.Header.Get("Content-Disposition")
   281  
   282  		_, params, err := mime.ParseMediaType(contentDisposition)
   283  		if err != nil {
   284  			return fmt.Errorf("unable to parse file name of archive: %w", err)
   285  		}
   286  		if serverFileName, ok := params["filename"]; ok {
   287  			fileName = serverFileName
   288  		} else {
   289  			return errors.New("unable to determine file name of archive")
   290  		}
   291  	}
   292  
   293  	return dest.Copy(fileName, resp.Body)
   294  }
   295  
   296  var codeloadLegacyRE = regexp.MustCompile(`^(/[^/]+/[^/]+/)legacy\.`)
   297  
   298  // removeLegacyFromCodeloadPath converts URLs for "legacy" Codeload archives into ones that match the format
   299  // when you choose to download "Source code (zip/tar.gz)" from a tagged release on the web. The legacy URLs
   300  // look like this:
   301  //
   302  //	https://codeload.github.com/OWNER/REPO/legacy.zip/refs/tags/TAGNAME
   303  //
   304  // Removing the "legacy." part results in a valid Codeload URL for our desired archive format.
   305  func removeLegacyFromCodeloadPath(p string) string {
   306  	return codeloadLegacyRE.ReplaceAllString(p, "$1")
   307  }
   308  
   309  var errSkipped = errors.New("skipped")
   310  
   311  // destinationWriter handles writing content into destination files
   312  type destinationWriter struct {
   313  	file         string
   314  	dir          string
   315  	skipExisting bool
   316  	overwrite    bool
   317  	stdout       io.Writer
   318  }
   319  
   320  func (w destinationWriter) makePath(name string) string {
   321  	if w.file == "" {
   322  		return filepath.Join(w.dir, name)
   323  	}
   324  	return w.file
   325  }
   326  
   327  // Check returns an error if a file already exists at destination
   328  func (w destinationWriter) Check(name string) error {
   329  	if name == "" {
   330  		// skip check as file name will only be known after the API request
   331  		return nil
   332  	}
   333  	fp := w.makePath(name)
   334  	if fp == "-" {
   335  		// writing to stdout should always proceed
   336  		return nil
   337  	}
   338  	return w.check(fp)
   339  }
   340  
   341  func (w destinationWriter) check(fp string) error {
   342  	if _, err := os.Stat(fp); err == nil {
   343  		if w.skipExisting {
   344  			return errSkipped
   345  		}
   346  		if !w.overwrite {
   347  			return fmt.Errorf(
   348  				"%s already exists (use `--clobber` to overwrite file or `--skip-existing` to skip file)",
   349  				fp,
   350  			)
   351  		}
   352  	}
   353  	return nil
   354  }
   355  
   356  // Copy writes the data from r into a file specified by name
   357  func (w destinationWriter) Copy(name string, r io.Reader) error {
   358  	fp := w.makePath(name)
   359  	if fp == "-" {
   360  		_, err := io.Copy(w.stdout, r)
   361  		return err
   362  	}
   363  	if err := w.check(fp); err != nil {
   364  		return err
   365  	}
   366  
   367  	if dir := filepath.Dir(fp); dir != "." {
   368  		if err := os.MkdirAll(dir, 0755); err != nil {
   369  			return err
   370  		}
   371  	}
   372  
   373  	f, err := os.OpenFile(fp, os.O_WRONLY|os.O_CREATE, 0644)
   374  	if err != nil {
   375  		return err
   376  	}
   377  	defer f.Close()
   378  
   379  	_, err = io.Copy(f, r)
   380  	return err
   381  }