commands/release/download/download.go (208 lines of code) (raw):

package download import ( "errors" "fmt" "io" "net/http" "net/url" "os" "path" "path/filepath" "strings" "github.com/MakeNowJust/heredoc/v2" "github.com/spf13/cobra" gitlab "gitlab.com/gitlab-org/api/client-go" "gitlab.com/gitlab-org/cli/api" "gitlab.com/gitlab-org/cli/commands/cmdutils" "gitlab.com/gitlab-org/cli/commands/release/releaseutils/upload" "gitlab.com/gitlab-org/cli/internal/config" "gitlab.com/gitlab-org/cli/internal/glrepo" "gitlab.com/gitlab-org/cli/pkg/iostreams" ) type DownloadOpts struct { TagName string Asset string AssetNames []string Dir string IO *iostreams.IOStreams HTTPClient func() (*gitlab.Client, error) BaseRepo func() (glrepo.Interface, error) Config func() (config.Config, error) } func NewCmdDownload(f *cmdutils.Factory, runE func(opts *DownloadOpts) error) *cobra.Command { opts := &DownloadOpts{ IO: f.IO, Config: f.Config, } cmd := &cobra.Command{ Use: "download <tag>", Short: "Download asset files from a GitLab release.", Long: heredoc.Docf(`Download asset files from a GitLab release. If no tag is specified, downloads assets from the latest release. To specify a file name to download from the release assets, use %[1]s--asset-name%[1]s. %[1]s--asset-name%[1]s flag accepts glob patterns. `, "`"), Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` # Download all assets from the latest release $ glab release download # Download all assets from the specified release tag $ glab release download v1.1.0 # Download assets with names matching the glob pattern $ glab release download v1.10.1 --asset-name="*.tar.gz" `), RunE: func(cmd *cobra.Command, args []string) error { opts.HTTPClient = f.HttpClient opts.BaseRepo = f.BaseRepo if len(args) == 1 { opts.TagName = args[0] } if runE != nil { return runE(opts) } return downloadRun(opts) }, } cmd.Flags().StringArrayVarP(&opts.AssetNames, "asset-name", "n", []string{}, "Download only assets that match the name or a glob pattern.") cmd.Flags().StringVarP(&opts.Dir, "dir", "D", ".", "Directory to download the release assets to.") return cmd } func downloadRun(opts *DownloadOpts) error { client, err := opts.HTTPClient() if err != nil { return err } repo, err := opts.BaseRepo() if err != nil { return err } color := opts.IO.Color() var resp *gitlab.Response var release *gitlab.Release var downloadableAssets []*upload.ReleaseAsset if opts.TagName == "" { opts.IO.Logf("%s fetching latest release %s=%s\n", color.ProgressIcon(), color.Blue("repo"), repo.FullName()) releases, _, err := client.Releases.ListReleases(repo.FullName(), &gitlab.ListReleasesOptions{}) if err != nil { return cmdutils.WrapError(err, "could not fetch latest release.") } if len(releases) < 1 { return cmdutils.WrapError(errors.New("not found"), fmt.Sprintf("no release found for %q", repo.FullName())) } release = releases[0] opts.TagName = release.TagName } else { opts.IO.Logf("%s fetching release %s=%s %s=%s.\n", color.ProgressIcon(), color.Blue("repo"), repo.FullName(), color.Blue("tag"), opts.TagName) release, resp, err = client.Releases.GetRelease(repo.FullName(), opts.TagName) if err != nil { if resp != nil && (resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden) { return cmdutils.WrapError(err, "release does not exist.") } return cmdutils.WrapError(err, "failed to fetch release.") } } for _, link := range release.Assets.Links { if len(opts.AssetNames) > 0 && (!matchAny(opts.AssetNames, link.Name)) { continue } downloadableAssets = append(downloadableAssets, &upload.ReleaseAsset{ Name: &link.Name, URL: &link.URL, }) } for _, source := range release.Assets.Sources { source := source name := path.Base(source.URL) if len(opts.AssetNames) > 0 && (!matchAny(opts.AssetNames, name)) { continue } downloadableAssets = append(downloadableAssets, &upload.ReleaseAsset{ Name: &name, URL: &source.URL, }) } if len(downloadableAssets) < 1 { opts.IO.Logf("%s no release assets found!\n", color.DotWarnIcon()) return nil } opts.IO.Logf("%s downloading release assets %s=%s %s=%s\n", color.ProgressIcon(), color.Blue("repo"), repo.FullName(), color.Blue("tag"), opts.TagName) err = downloadAssets(api.GetClient(), opts.IO, downloadableAssets, opts.Dir) if err != nil { return cmdutils.WrapError(err, "failed to download release.") } opts.IO.Logf(color.Bold("%s release %q downloaded\n"), color.RedCheck(), release.Name) return nil } func matchAny(patterns []string, name string) bool { for _, p := range patterns { matched, err := filepath.Match(p, name) if err == nil && matched { return true } } return false } func downloadAssets(httpClient *api.Client, io *iostreams.IOStreams, toDownload []*upload.ReleaseAsset, destDir string) error { color := io.Color() for _, asset := range toDownload { io.Logf("%s downloading file %s=%s %s=%s.\n", color.ProgressIcon(), color.Blue("name"), *asset.Name, color.Blue("url"), *asset.URL) var sanitizedAssetName string if asset.Name != nil { sanitizedAssetName = sanitizeAssetName(*asset.Name) } destDir, err := filepath.Abs(destDir) if err != nil { return fmt.Errorf("resolving absolute download directory path: %v", err) } destPath := filepath.Join(destDir, sanitizedAssetName) if !strings.HasPrefix(destPath, destDir) { return fmt.Errorf("invalid file path name.") } err = downloadAsset(httpClient, *asset.URL, destPath) if err != nil { return err } } return nil } func sanitizeAssetName(asset string) string { if !strings.HasPrefix(asset, "/") { // Prefix the asset with "/" ensures that filepath.Clean removes all `/..` // See rule 4 of filepath.Clean for more information: https://pkg.go.dev/path/filepath#Clean asset = "/" + asset } return filepath.Clean(asset) } func downloadAsset(client *api.Client, assetURL, destinationPath string) error { var body io.Reader // color := streams.Color() baseURL, _ := url.Parse(assetURL) req, err := api.NewHTTPRequest(client, http.MethodGet, baseURL, body, []string{"Accept:application/octet-stream"}, false) if err != nil { return err } resp, err := client.HTTPClient().Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode > 299 { return errors.New(resp.Status) } f, err := os.OpenFile(destinationPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { return err } defer f.Close() _, err = io.Copy(f, resp.Body) return err }