github.com/10XDev/rclone@v1.52.3-0.20200626220027-16af9ab76b2a/bin/get-github-release.go (about)

     1  // +build ignore
     2  
     3  // Get the latest release from a github project
     4  //
     5  // If GITHUB_USER and GITHUB_TOKEN are set then these will be used to
     6  // authenticate the request which is useful to avoid rate limits.
     7  
     8  package main
     9  
    10  import (
    11  	"archive/tar"
    12  	"compress/bzip2"
    13  	"compress/gzip"
    14  	"encoding/json"
    15  	"flag"
    16  	"fmt"
    17  	"io"
    18  	"io/ioutil"
    19  	"log"
    20  	"net/http"
    21  	"net/url"
    22  	"os"
    23  	"os/exec"
    24  	"path"
    25  	"path/filepath"
    26  	"regexp"
    27  	"runtime"
    28  	"strings"
    29  	"time"
    30  
    31  	"github.com/rclone/rclone/lib/rest"
    32  	"golang.org/x/net/html"
    33  	"golang.org/x/sys/unix"
    34  )
    35  
    36  var (
    37  	// Flags
    38  	install = flag.Bool("install", false, "Install the downloaded package using sudo dpkg -i.")
    39  	extract = flag.String("extract", "", "Extract the named executable from the .tar.gz and install into bindir.")
    40  	bindir  = flag.String("bindir", defaultBinDir(), "Directory to install files downloaded with -extract.")
    41  	useAPI  = flag.Bool("use-api", false, "Use the API for finding the release instead of scraping the page.")
    42  	// Globals
    43  	matchProject = regexp.MustCompile(`^([\w-]+)/([\w-]+)$`)
    44  	osAliases    = map[string][]string{
    45  		"darwin": []string{"macos", "osx"},
    46  	}
    47  	archAliases = map[string][]string{
    48  		"amd64": []string{"x86_64"},
    49  	}
    50  )
    51  
    52  // A github release
    53  //
    54  // Made by pasting the JSON into https://mholt.github.io/json-to-go/
    55  type Release struct {
    56  	URL             string `json:"url"`
    57  	AssetsURL       string `json:"assets_url"`
    58  	UploadURL       string `json:"upload_url"`
    59  	HTMLURL         string `json:"html_url"`
    60  	ID              int    `json:"id"`
    61  	TagName         string `json:"tag_name"`
    62  	TargetCommitish string `json:"target_commitish"`
    63  	Name            string `json:"name"`
    64  	Draft           bool   `json:"draft"`
    65  	Author          struct {
    66  		Login             string `json:"login"`
    67  		ID                int    `json:"id"`
    68  		AvatarURL         string `json:"avatar_url"`
    69  		GravatarID        string `json:"gravatar_id"`
    70  		URL               string `json:"url"`
    71  		HTMLURL           string `json:"html_url"`
    72  		FollowersURL      string `json:"followers_url"`
    73  		FollowingURL      string `json:"following_url"`
    74  		GistsURL          string `json:"gists_url"`
    75  		StarredURL        string `json:"starred_url"`
    76  		SubscriptionsURL  string `json:"subscriptions_url"`
    77  		OrganizationsURL  string `json:"organizations_url"`
    78  		ReposURL          string `json:"repos_url"`
    79  		EventsURL         string `json:"events_url"`
    80  		ReceivedEventsURL string `json:"received_events_url"`
    81  		Type              string `json:"type"`
    82  		SiteAdmin         bool   `json:"site_admin"`
    83  	} `json:"author"`
    84  	Prerelease  bool      `json:"prerelease"`
    85  	CreatedAt   time.Time `json:"created_at"`
    86  	PublishedAt time.Time `json:"published_at"`
    87  	Assets      []struct {
    88  		URL      string `json:"url"`
    89  		ID       int    `json:"id"`
    90  		Name     string `json:"name"`
    91  		Label    string `json:"label"`
    92  		Uploader struct {
    93  			Login             string `json:"login"`
    94  			ID                int    `json:"id"`
    95  			AvatarURL         string `json:"avatar_url"`
    96  			GravatarID        string `json:"gravatar_id"`
    97  			URL               string `json:"url"`
    98  			HTMLURL           string `json:"html_url"`
    99  			FollowersURL      string `json:"followers_url"`
   100  			FollowingURL      string `json:"following_url"`
   101  			GistsURL          string `json:"gists_url"`
   102  			StarredURL        string `json:"starred_url"`
   103  			SubscriptionsURL  string `json:"subscriptions_url"`
   104  			OrganizationsURL  string `json:"organizations_url"`
   105  			ReposURL          string `json:"repos_url"`
   106  			EventsURL         string `json:"events_url"`
   107  			ReceivedEventsURL string `json:"received_events_url"`
   108  			Type              string `json:"type"`
   109  			SiteAdmin         bool   `json:"site_admin"`
   110  		} `json:"uploader"`
   111  		ContentType        string    `json:"content_type"`
   112  		State              string    `json:"state"`
   113  		Size               int       `json:"size"`
   114  		DownloadCount      int       `json:"download_count"`
   115  		CreatedAt          time.Time `json:"created_at"`
   116  		UpdatedAt          time.Time `json:"updated_at"`
   117  		BrowserDownloadURL string    `json:"browser_download_url"`
   118  	} `json:"assets"`
   119  	TarballURL string `json:"tarball_url"`
   120  	ZipballURL string `json:"zipball_url"`
   121  	Body       string `json:"body"`
   122  }
   123  
   124  // checks if a path has write access
   125  func writable(path string) bool {
   126  	return unix.Access(path, unix.W_OK) == nil
   127  }
   128  
   129  // Directory to install releases in by default
   130  //
   131  // Find writable directories on $PATH.  Use $GOPATH/bin if that is on
   132  // the path and writable or use the first writable directory which is
   133  // in $HOME or failing that the first writable directory.
   134  //
   135  // Returns "" if none of the above were found
   136  func defaultBinDir() string {
   137  	home := os.Getenv("HOME")
   138  	var (
   139  		bin       string
   140  		homeBin   string
   141  		goHomeBin string
   142  		gopath    = os.Getenv("GOPATH")
   143  	)
   144  	for _, dir := range strings.Split(os.Getenv("PATH"), ":") {
   145  		if writable(dir) {
   146  			if strings.HasPrefix(dir, home) {
   147  				if homeBin != "" {
   148  					homeBin = dir
   149  				}
   150  				if gopath != "" && strings.HasPrefix(dir, gopath) && goHomeBin == "" {
   151  					goHomeBin = dir
   152  				}
   153  			}
   154  			if bin == "" {
   155  				bin = dir
   156  			}
   157  		}
   158  	}
   159  	if goHomeBin != "" {
   160  		return goHomeBin
   161  	}
   162  	if homeBin != "" {
   163  		return homeBin
   164  	}
   165  	return bin
   166  }
   167  
   168  // read the body or an error message
   169  func readBody(in io.Reader) string {
   170  	data, err := ioutil.ReadAll(in)
   171  	if err != nil {
   172  		return fmt.Sprintf("Error reading body: %v", err.Error())
   173  	}
   174  	return string(data)
   175  }
   176  
   177  // Get an asset URL and name
   178  func getAsset(project string, matchName *regexp.Regexp) (string, string) {
   179  	url := "https://api.github.com/repos/" + project + "/releases/latest"
   180  	log.Printf("Fetching asset info for %q from %q", project, url)
   181  	user, pass := os.Getenv("GITHUB_USER"), os.Getenv("GITHUB_TOKEN")
   182  	req, err := http.NewRequest("GET", url, nil)
   183  	if err != nil {
   184  		log.Fatalf("Failed to make http request %q: %v", url, err)
   185  	}
   186  	if user != "" && pass != "" {
   187  		log.Printf("Fetching using GITHUB_USER and GITHUB_TOKEN")
   188  		req.SetBasicAuth(user, pass)
   189  	}
   190  	resp, err := http.DefaultClient.Do(req)
   191  	if err != nil {
   192  		log.Fatalf("Failed to fetch release info %q: %v", url, err)
   193  	}
   194  	if resp.StatusCode != http.StatusOK {
   195  		log.Printf("Error: %s", readBody(resp.Body))
   196  		log.Fatalf("Bad status %d when fetching %q release info: %s", resp.StatusCode, url, resp.Status)
   197  	}
   198  	var release Release
   199  	err = json.NewDecoder(resp.Body).Decode(&release)
   200  	if err != nil {
   201  		log.Fatalf("Failed to decode release info: %v", err)
   202  	}
   203  	err = resp.Body.Close()
   204  	if err != nil {
   205  		log.Fatalf("Failed to close body: %v", err)
   206  	}
   207  
   208  	for _, asset := range release.Assets {
   209  		//log.Printf("Finding %s", asset.Name)
   210  		if matchName.MatchString(asset.Name) && isOurOsArch(asset.Name) {
   211  			return asset.BrowserDownloadURL, asset.Name
   212  		}
   213  	}
   214  	log.Fatalf("Didn't find asset in info")
   215  	return "", ""
   216  }
   217  
   218  // Get an asset URL and name by scraping the downloads page
   219  //
   220  // This doesn't use the API so isn't rate limited when not using GITHUB login details
   221  func getAssetFromReleasesPage(project string, matchName *regexp.Regexp) (assetURL string, assetName string) {
   222  	baseURL := "https://github.com/" + project + "/releases"
   223  	log.Printf("Fetching asset info for %q from %q", project, baseURL)
   224  	base, err := url.Parse(baseURL)
   225  	if err != nil {
   226  		log.Fatalf("URL Parse failed: %v", err)
   227  	}
   228  	resp, err := http.Get(baseURL)
   229  	if err != nil {
   230  		log.Fatalf("Failed to fetch release info %q: %v", baseURL, err)
   231  	}
   232  	defer resp.Body.Close()
   233  	if resp.StatusCode != http.StatusOK {
   234  		log.Printf("Error: %s", readBody(resp.Body))
   235  		log.Fatalf("Bad status %d when fetching %q release info: %s", resp.StatusCode, baseURL, resp.Status)
   236  	}
   237  	doc, err := html.Parse(resp.Body)
   238  	if err != nil {
   239  		log.Fatalf("Failed to parse web page: %v", err)
   240  	}
   241  	var walk func(*html.Node)
   242  	walk = func(n *html.Node) {
   243  		if n.Type == html.ElementNode && n.Data == "a" {
   244  			for _, a := range n.Attr {
   245  				if a.Key == "href" {
   246  					if name := path.Base(a.Val); matchName.MatchString(name) && isOurOsArch(name) {
   247  						if u, err := rest.URLJoin(base, a.Val); err == nil {
   248  							if assetName == "" {
   249  								assetName = name
   250  								assetURL = u.String()
   251  							}
   252  						}
   253  					}
   254  					break
   255  				}
   256  			}
   257  		}
   258  		for c := n.FirstChild; c != nil; c = c.NextSibling {
   259  			walk(c)
   260  		}
   261  	}
   262  	walk(doc)
   263  	if assetName == "" || assetURL == "" {
   264  		log.Fatalf("Didn't find URL in page")
   265  	}
   266  	return assetURL, assetName
   267  }
   268  
   269  // isOurOsArch returns true if s contains our OS and our Arch
   270  func isOurOsArch(s string) bool {
   271  	s = strings.ToLower(s)
   272  	check := func(base string, aliases map[string][]string) bool {
   273  		names := []string{base}
   274  		names = append(names, aliases[base]...)
   275  		for _, name := range names {
   276  			if strings.Contains(s, name) {
   277  				return true
   278  			}
   279  		}
   280  		return false
   281  	}
   282  	return check(runtime.GOARCH, archAliases) && check(runtime.GOOS, osAliases)
   283  }
   284  
   285  // get a file for download
   286  func getFile(url, fileName string) {
   287  	log.Printf("Downloading %q from %q", fileName, url)
   288  
   289  	out, err := os.Create(fileName)
   290  	if err != nil {
   291  		log.Fatalf("Failed to open %q: %v", fileName, err)
   292  	}
   293  
   294  	resp, err := http.Get(url)
   295  	if err != nil {
   296  		log.Fatalf("Failed to fetch asset %q: %v", url, err)
   297  	}
   298  	if resp.StatusCode != http.StatusOK {
   299  		log.Printf("Error: %s", readBody(resp.Body))
   300  		log.Fatalf("Bad status %d when fetching %q asset: %s", resp.StatusCode, url, resp.Status)
   301  	}
   302  
   303  	n, err := io.Copy(out, resp.Body)
   304  	if err != nil {
   305  		log.Fatalf("Error while downloading: %v", err)
   306  	}
   307  
   308  	err = resp.Body.Close()
   309  	if err != nil {
   310  		log.Fatalf("Failed to close body: %v", err)
   311  	}
   312  	err = out.Close()
   313  	if err != nil {
   314  		log.Fatalf("Failed to close output file: %v", err)
   315  	}
   316  
   317  	log.Printf("Downloaded %q (%d bytes)", fileName, n)
   318  }
   319  
   320  // run a shell command
   321  func run(args ...string) {
   322  	cmd := exec.Command(args[0], args[1:]...)
   323  	cmd.Stdout = os.Stdout
   324  	cmd.Stderr = os.Stderr
   325  	err := cmd.Run()
   326  	if err != nil {
   327  		log.Fatalf("Failed to run %v: %v", args, err)
   328  	}
   329  }
   330  
   331  // Untars fileName from srcFile
   332  func untar(srcFile, fileName, extractDir string) {
   333  	f, err := os.Open(srcFile)
   334  	if err != nil {
   335  		log.Fatalf("Couldn't open tar: %v", err)
   336  	}
   337  	defer func() {
   338  		err := f.Close()
   339  		if err != nil {
   340  			log.Fatalf("Couldn't close tar: %v", err)
   341  		}
   342  	}()
   343  
   344  	var in io.Reader = f
   345  
   346  	srcExt := filepath.Ext(srcFile)
   347  	if srcExt == ".gz" || srcExt == ".tgz" {
   348  		gzf, err := gzip.NewReader(f)
   349  		if err != nil {
   350  			log.Fatalf("Couldn't open gzip: %v", err)
   351  		}
   352  		in = gzf
   353  	} else if srcExt == ".bz2" {
   354  		in = bzip2.NewReader(f)
   355  	}
   356  
   357  	tarReader := tar.NewReader(in)
   358  
   359  	for {
   360  		header, err := tarReader.Next()
   361  		if err == io.EOF {
   362  			break
   363  		}
   364  		if err != nil {
   365  			log.Fatalf("Trouble reading tar file: %v", err)
   366  		}
   367  		name := header.Name
   368  		switch header.Typeflag {
   369  		case tar.TypeReg:
   370  			baseName := filepath.Base(name)
   371  			if baseName == fileName {
   372  				outPath := filepath.Join(extractDir, fileName)
   373  				out, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0777)
   374  				if err != nil {
   375  					log.Fatalf("Couldn't open output file: %v", err)
   376  				}
   377  				defer func() {
   378  					err := out.Close()
   379  					if err != nil {
   380  						log.Fatalf("Couldn't close output: %v", err)
   381  					}
   382  				}()
   383  				n, err := io.Copy(out, tarReader)
   384  				if err != nil {
   385  					log.Fatalf("Couldn't write output file: %v", err)
   386  				}
   387  				log.Printf("Wrote %s (%d bytes) as %q", fileName, n, outPath)
   388  			}
   389  		}
   390  	}
   391  }
   392  
   393  func main() {
   394  	flag.Parse()
   395  	args := flag.Args()
   396  	if len(args) != 2 {
   397  		log.Fatalf("Syntax: %s <user/project> <name reg exp>", os.Args[0])
   398  	}
   399  	project, nameRe := args[0], args[1]
   400  	if !matchProject.MatchString(project) {
   401  		log.Fatalf("Project %q must be in form user/project", project)
   402  	}
   403  	matchName, err := regexp.Compile(nameRe)
   404  	if err != nil {
   405  		log.Fatalf("Invalid regexp for name %q: %v", nameRe, err)
   406  	}
   407  
   408  	var assetURL, assetName string
   409  	if *useAPI {
   410  		assetURL, assetName = getAsset(project, matchName)
   411  	} else {
   412  		assetURL, assetName = getAssetFromReleasesPage(project, matchName)
   413  	}
   414  	fileName := filepath.Join(os.TempDir(), assetName)
   415  	getFile(assetURL, fileName)
   416  
   417  	if *install {
   418  		log.Printf("Installing %s", fileName)
   419  		run("sudo", "dpkg", "--force-bad-version", "-i", fileName)
   420  		log.Printf("Installed %s", fileName)
   421  	} else if *extract != "" {
   422  		if *bindir == "" {
   423  			log.Fatalf("Need to set -bindir")
   424  		}
   425  		log.Printf("Unpacking %s from %s and installing into %s", *extract, fileName, *bindir)
   426  		untar(fileName, *extract, *bindir+"/")
   427  	}
   428  }