1
0
mirror of https://github.com/axllent/mailpit.git synced 2024-12-30 23:17:59 +02:00
mailpit/updater/updater.go

345 lines
7.7 KiB
Go

package updater
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/axllent/mailpit/logger"
"github.com/axllent/semver"
)
var (
// AllowPrereleases defines whether pre-releases may be included
AllowPrereleases = false
tempDir string
)
// Releases struct for Github releases json
type Releases []struct {
Name string `json:"name"` // release name
Tag string `json:"tag_name"` // release tag
Prerelease bool `json:"prerelease"` // Github pre-release
Assets []struct {
BrowserDownloadURL string `json:"browser_download_url"`
ID int64 `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
} `json:"assets"`
}
// Release struct contains the file data for downloadable release
type Release struct {
Name string
Tag string
URL string
Size int64
}
// GithubLatest fetches the latest release info & returns release tag, filename & download url
func GithubLatest(repo, name string) (string, string, string, error) {
releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/releases", repo)
resp, err := http.Get(releaseURL) // #nosec
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", "", "", err
}
linkOS := runtime.GOOS
linkArch := runtime.GOARCH
linkExt := ".tar.gz"
if linkOS == "windows" {
// Windows uses .zip instead
linkExt = ".zip"
}
var allReleases = []Release{}
var releases Releases
if err := json.Unmarshal(body, &releases); err != nil {
return "", "", "", err
}
archiveName := fmt.Sprintf("%s-%s-%s%s", name, linkOS, linkArch, linkExt)
// loop through releases
for _, r := range releases {
if !semver.IsValid(r.Tag) {
// Invalid semversion, skip
continue
}
if !AllowPrereleases && (semver.Prerelease(r.Tag) != "" || r.Prerelease) {
// we don't accept AllowPrereleases, skip
continue
}
for _, a := range r.Assets {
if a.Name == archiveName {
thisRelease := Release{a.Name, r.Tag, a.BrowserDownloadURL, a.Size}
allReleases = append(allReleases, thisRelease)
break
}
}
}
if len(allReleases) == 0 {
// no releases with suitable assets found
return "", "", "", fmt.Errorf("No binary releases found")
}
var latestRelease = Release{}
for _, r := range allReleases {
// detect the latest release
if semver.Compare(r.Tag, latestRelease.Tag) == 1 {
latestRelease = r
}
}
return latestRelease.Tag, latestRelease.Name, latestRelease.URL, nil
}
// GreaterThan compares the current version to a different version
// returning < 1 not upgradeable
func GreaterThan(toVer, fromVer string) bool {
return semver.Compare(toVer, fromVer) == 1
}
// GithubUpdate the running binary with the latest release binary from Github
func GithubUpdate(repo, appName, currentVersion string) (string, error) {
ver, filename, downloadURL, err := GithubLatest(repo, appName)
if err != nil {
return "", err
}
if ver == currentVersion {
return "", fmt.Errorf("No new release found")
}
if semver.Compare(ver, currentVersion) < 1 {
return "", fmt.Errorf("No newer releases found (latest %s)", ver)
}
tmpDir := getTempDir()
// outFile can be a tar.gz or a zip, depending on architecture
outFile := filepath.Join(tmpDir, filename)
if err := downloadToFile(downloadURL, outFile); err != nil {
return "", err
}
newExec := filepath.Join(tmpDir, "mailpit")
if runtime.GOOS == "windows" {
if _, err := Unzip(outFile, tmpDir); err != nil {
return "", err
}
newExec = filepath.Join(tmpDir, "mailpit.exe")
} else {
if err := TarGZExtract(outFile, tmpDir); err != nil {
return "", err
}
}
if runtime.GOOS != "windows" {
/* #nosec G302 */
if err := os.Chmod(newExec, 0755); err != nil {
return "", err
}
}
// ensure the new binary is executable (mainly for inconsistent darwin builds)
/* #nosec G204 */
cmd := exec.Command(newExec, "-h")
if err := cmd.Run(); err != nil {
return "", err
}
// get the running binary
oldExec, err := os.Executable()
if err != nil {
return "", err
}
if err = replaceFile(oldExec, newExec); err != nil {
return "", err
}
return ver, nil
}
// DownloadToFile downloads a URL to a file
func downloadToFile(url, fileName string) error {
// Get the data
resp, err := http.Get(url) // #nosec
if err != nil {
return err
}
defer resp.Body.Close()
// Create the file
out, err := os.Create(filepath.Clean(fileName))
if err != nil {
return err
}
defer func() {
if err := out.Close(); err != nil {
logger.Log().Errorf("Error closing file: %s\n", err)
}
}()
// Write the body to file
_, err = io.Copy(out, resp.Body)
return err
}
// ReplaceFile replaces one file with another.
// Running files cannot be overwritten, so it has to be moved
// and the new binary saved to the original path. This requires
// read & write permissions to both the original file and directory.
// Note, on Windows it is not possible to delete a running program,
// so the old exe is renamed and moved to os.TempDir()
func replaceFile(dst, src string) error {
// open the source file for reading
source, err := os.Open(filepath.Clean(src))
if err != nil {
return err
}
// destination directory eg: /usr/local/bin
dstDir := filepath.Dir(dst)
// binary filename
binaryFilename := filepath.Base(dst)
// old binary tmp name
dstOld := fmt.Sprintf("%s.old", binaryFilename)
// new binary tmp name
dstNew := fmt.Sprintf("%s.new", binaryFilename)
// absolute path of new tmp file
newTmpAbs := filepath.Join(dstDir, dstNew)
// absolute path of old tmp file
oldTmpAbs := filepath.Join(dstDir, dstOld)
// get src permissions
fi, _ := os.Stat(dst)
srcPerms := fi.Mode().Perm()
// create the new file
tmpNew, err := os.OpenFile(filepath.Clean(newTmpAbs), os.O_CREATE|os.O_RDWR, srcPerms) // #nosec
if err != nil {
return err
}
// copy new binary to <binary>.new
if _, err := io.Copy(tmpNew, source); err != nil {
return err
}
// close immediately else Windows has a fit
if err := tmpNew.Close(); err != nil {
return err
}
if err := source.Close(); err != nil {
return err
}
// rename the current executable to <binary>.old
if err := os.Rename(dst, oldTmpAbs); err != nil {
return err
}
// rename the <binary>.new to current executable
if err := os.Rename(newTmpAbs, dst); err != nil {
return err
}
// delete the old binary
if runtime.GOOS == "windows" {
tmpDir := os.TempDir()
delFile := filepath.Join(tmpDir, filepath.Base(oldTmpAbs))
if err := os.Rename(oldTmpAbs, delFile); err != nil {
return err
}
} else {
if err := os.Remove(oldTmpAbs); err != nil {
return err
}
}
// remove the src file
if err := os.Remove(src); err != nil {
return err
}
return nil
}
// GetTempDir will create & return a temporary directory if one has not been specified
func getTempDir() string {
if tempDir == "" {
randBytes := make([]byte, 6)
if _, err := rand.Read(randBytes); err != nil {
panic(err)
}
tempDir = filepath.Join(os.TempDir(), "updater-"+hex.EncodeToString(randBytes))
}
if err := mkDirIfNotExists(tempDir); err != nil {
// need a better way to exit
logger.Log().Errorf("Error: %v", err)
os.Exit(2)
}
return tempDir
}
// MkDirIfNotExists will create a directory if it doesn't exist
func mkDirIfNotExists(path string) error {
if !isDir(path) {
return os.MkdirAll(path, os.ModePerm)
}
return nil
}
// IsFile returns if a path is a file
func isFile(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return true
}
// IsDir returns if a path is a directory
func isDir(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.IsDir() {
return false
}
return true
}