mirror of
https://github.com/axllent/mailpit.git
synced 2025-08-13 20:04:49 +02:00
Chore: Switch version checks & self-updater to use ghru/v2
This commit is contained in:
@@ -6,7 +6,6 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/axllent/mailpit/config"
|
"github.com/axllent/mailpit/config"
|
||||||
"github.com/axllent/mailpit/internal/updater"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,29 +14,41 @@ var versionCmd = &cobra.Command{
|
|||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "Display the current version & update information",
|
Short: "Display the current version & update information",
|
||||||
Long: `Display the current version & update information (if available).`,
|
Long: `Display the current version & update information (if available).`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
|
||||||
updater.AllowPrereleases = true
|
|
||||||
|
|
||||||
update, _ := cmd.Flags().GetBool("update")
|
update, _ := cmd.Flags().GetBool("update")
|
||||||
|
|
||||||
if update {
|
if update {
|
||||||
return updateApp()
|
// Update the application
|
||||||
|
rel, err := config.GHRUConfig.SelfUpdate()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error updating: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Updated %s to version %s\n", os.Args[0], rel.Tag)
|
||||||
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s %s compiled with %s on %s/%s\n",
|
fmt.Printf("%s %s compiled with %s on %s/%s\n",
|
||||||
os.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
os.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||||
|
|
||||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
release, err := config.GHRUConfig.Latest()
|
||||||
if err == nil && updater.GreaterThan(latest, config.Version) {
|
if err != nil {
|
||||||
fmt.Printf(
|
fmt.Printf("Error checking for latest release: %s\n", err)
|
||||||
"\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n",
|
os.Exit(1)
|
||||||
latest,
|
|
||||||
os.Args[0],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
// The latest version is the same version
|
||||||
|
if release.Tag == config.Version {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newer release is available
|
||||||
|
fmt.Printf(
|
||||||
|
"\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n",
|
||||||
|
release.Tag,
|
||||||
|
os.Args[0],
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,13 +58,3 @@ func init() {
|
|||||||
versionCmd.Flags().
|
versionCmd.Flags().
|
||||||
BoolP("update", "u", false, "update to latest version")
|
BoolP("update", "u", false, "update to latest version")
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateApp() error {
|
|
||||||
rel, err := updater.GithubUpdate(config.Repo, config.RepoBinaryName, config.Version)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Updated %s to version %s\n", os.Args[0], rel)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
@@ -11,6 +11,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/axllent/ghru/v2"
|
||||||
"github.com/axllent/mailpit/internal/auth"
|
"github.com/axllent/mailpit/internal/auth"
|
||||||
"github.com/axllent/mailpit/internal/logger"
|
"github.com/axllent/mailpit/internal/logger"
|
||||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||||
@@ -19,6 +20,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
// Version is the Mailpit version, updated with every release
|
||||||
|
Version = "dev"
|
||||||
|
|
||||||
|
// GHRUConfig is the configuration for the GitHub Release Updater
|
||||||
|
// used to check for updates and self-update
|
||||||
|
GHRUConfig = ghru.Config{
|
||||||
|
Repo: "axllent/mailpit",
|
||||||
|
ArchiveName: "mailpit-{{.OS}}-{{.Arch}}",
|
||||||
|
BinaryName: "mailpit",
|
||||||
|
CurrentVersion: Version,
|
||||||
|
}
|
||||||
|
|
||||||
// SMTPListen to listen on <interface>:<port>
|
// SMTPListen to listen on <interface>:<port>
|
||||||
SMTPListen = "[::]:1025"
|
SMTPListen = "[::]:1025"
|
||||||
|
|
||||||
@@ -198,15 +211,6 @@ var (
|
|||||||
// Empty = disabled, true= use existing web server, address = separate server
|
// Empty = disabled, true= use existing web server, address = separate server
|
||||||
PrometheusListen string
|
PrometheusListen string
|
||||||
|
|
||||||
// Version is the default application version, updated on release
|
|
||||||
Version = "dev"
|
|
||||||
|
|
||||||
// Repo on Github for updater
|
|
||||||
Repo = "axllent/mailpit"
|
|
||||||
|
|
||||||
// RepoBinaryName on Github for updater
|
|
||||||
RepoBinaryName = "mailpit"
|
|
||||||
|
|
||||||
// ChaosTriggers are parsed and set in the chaos module
|
// ChaosTriggers are parsed and set in the chaos module
|
||||||
ChaosTriggers string
|
ChaosTriggers string
|
||||||
|
|
||||||
|
2
go.mod
2
go.mod
@@ -5,6 +5,7 @@ go 1.24.3
|
|||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.10.3
|
github.com/PuerkitoBio/goquery v1.10.3
|
||||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||||
|
github.com/axllent/ghru/v2 v2.0.1
|
||||||
github.com/axllent/semver v0.0.1
|
github.com/axllent/semver v0.0.1
|
||||||
github.com/goccy/go-yaml v1.18.0
|
github.com/goccy/go-yaml v1.18.0
|
||||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
|
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
|
||||||
@@ -63,6 +64,7 @@ require (
|
|||||||
github.com/vanng822/css v1.0.1 // indirect
|
github.com/vanng822/css v1.0.1 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/image v0.28.0 // indirect
|
golang.org/x/image v0.28.0 // indirect
|
||||||
|
golang.org/x/mod v0.25.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
modernc.org/libc v1.66.0 // indirect
|
modernc.org/libc v1.66.0 // indirect
|
||||||
|
2
go.sum
2
go.sum
@@ -6,6 +6,8 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
|
|||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||||
|
github.com/axllent/ghru/v2 v2.0.1 h1:/9XHbMqJRGRxlyNMq2XiHQpBLz7hlq8xCdgJ4ZhjCoM=
|
||||||
|
github.com/axllent/ghru/v2 v2.0.1/go.mod h1:seMMjx8/0r5ZAL7c0vwTPIRoyN0AoTUqAylZEWZWGK4=
|
||||||
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
|
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
|
||||||
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
|
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/axllent/mailpit/config"
|
"github.com/axllent/mailpit/config"
|
||||||
"github.com/axllent/mailpit/internal/logger"
|
"github.com/axllent/mailpit/internal/logger"
|
||||||
"github.com/axllent/mailpit/internal/storage"
|
"github.com/axllent/mailpit/internal/storage"
|
||||||
"github.com/axllent/mailpit/internal/updater"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stores cached version along with its expiry time and error count.
|
// Stores cached version along with its expiry time and error count.
|
||||||
@@ -113,10 +112,10 @@ func Load() AppInformation {
|
|||||||
if time.Now().Before(vCache.expiry) {
|
if time.Now().Before(vCache.expiry) {
|
||||||
info.LatestVersion = vCache.value
|
info.LatestVersion = vCache.value
|
||||||
} else {
|
} else {
|
||||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
latest, err := config.GHRUConfig.Latest()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
vCache = versionCache{value: latest, expiry: time.Now().Add(15 * time.Minute)}
|
vCache = versionCache{value: latest.Tag, expiry: time.Now().Add(15 * time.Minute)}
|
||||||
info.LatestVersion = latest
|
info.LatestVersion = latest.Tag
|
||||||
} else {
|
} else {
|
||||||
logger.Log().Errorf("Failed to fetch latest version: %v", err)
|
logger.Log().Errorf("Failed to fetch latest version: %v", err)
|
||||||
vCache.errCount++
|
vCache.errCount++
|
||||||
|
@@ -1,218 +0,0 @@
|
|||||||
package updater
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"bufio"
|
|
||||||
"compress/gzip"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TarGZExtract extracts a archive from the file inputFilePath.
|
|
||||||
// It tries to create the directory structure outputFilePath contains if it doesn't exist.
|
|
||||||
// It returns potential errors to be checked or nil if everything works.
|
|
||||||
func TarGZExtract(inputFilePath, outputFilePath string) (err error) {
|
|
||||||
outputFilePath = stripTrailingSlashes(outputFilePath)
|
|
||||||
inputFilePath, outputFilePath, err = makeAbsolute(inputFilePath, outputFilePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
undoDir, err := mkdirAll(outputFilePath, 0750)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
undoDir()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return extract(inputFilePath, outputFilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creates all directories with os.MakedirAll and returns a function to remove the first created directory so cleanup is possible.
|
|
||||||
func mkdirAll(dirPath string, perm os.FileMode) (func(), error) {
|
|
||||||
var undoDir string
|
|
||||||
|
|
||||||
for p := dirPath; ; p = filepath.Dir(p) {
|
|
||||||
finfo, err := os.Stat(p)
|
|
||||||
if err == nil {
|
|
||||||
if finfo.IsDir() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
finfo, err = os.Lstat(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if finfo.IsDir() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("mkdirAll (%s): %v", p, syscall.ENOTDIR)
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
undoDir = p
|
|
||||||
} else {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if undoDir == "" {
|
|
||||||
return func() {}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(dirPath, perm); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
if err := os.RemoveAll(undoDir); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove trailing slash if any.
|
|
||||||
func stripTrailingSlashes(path string) string {
|
|
||||||
if len(path) > 0 && path[len(path)-1] == '/' {
|
|
||||||
path = path[0 : len(path)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make input and output paths absolute.
|
|
||||||
func makeAbsolute(inputFilePath, outputFilePath string) (string, string, error) {
|
|
||||||
inputFilePath, err := filepath.Abs(inputFilePath)
|
|
||||||
if err == nil {
|
|
||||||
outputFilePath, err = filepath.Abs(outputFilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return inputFilePath, outputFilePath, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the file in filePath to directory.
|
|
||||||
func extract(filePath string, directory string) error {
|
|
||||||
file, err := os.Open(filepath.Clean(filePath))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
fmt.Printf("Error closing file: %s\n", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
gzipReader, err := gzip.NewReader(bufio.NewReader(file))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = gzipReader.Close() }()
|
|
||||||
|
|
||||||
tarReader := tar.NewReader(gzipReader)
|
|
||||||
|
|
||||||
// Post extraction directory permissions & timestamps
|
|
||||||
type DirInfo struct {
|
|
||||||
Path string
|
|
||||||
Header *tar.Header
|
|
||||||
}
|
|
||||||
|
|
||||||
// slice to add all extracted directory info for post-processing
|
|
||||||
postExtraction := []DirInfo{}
|
|
||||||
|
|
||||||
for {
|
|
||||||
header, err := tarReader.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInfo := header.FileInfo()
|
|
||||||
// paths could contain a '..', is used in a file system operations
|
|
||||||
if strings.Contains(fileInfo.Name(), "..") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dir := filepath.Join(directory, filepath.Dir(header.Name))
|
|
||||||
filename := filepath.Join(dir, fileInfo.Name())
|
|
||||||
|
|
||||||
if fileInfo.IsDir() {
|
|
||||||
// create the directory 755 in case writing permissions prohibit writing before files added
|
|
||||||
if err := os.MkdirAll(filename, 0750); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// set file ownership (if allowed)
|
|
||||||
// Chtimes() && Chmod() only set after once extraction is complete
|
|
||||||
_ = os.Chown(filename, header.Uid, header.Gid)
|
|
||||||
|
|
||||||
// add directory info to slice to process afterwards
|
|
||||||
postExtraction = append(postExtraction, DirInfo{filename, header})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure parent directory exists (may not be included in tar)
|
|
||||||
if !fileInfo.IsDir() && !isDir(dir) {
|
|
||||||
err = os.MkdirAll(dir, 0750)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Create(filepath.Clean(filename))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
writer := bufio.NewWriter(file)
|
|
||||||
|
|
||||||
buffer := make([]byte, 4096)
|
|
||||||
for {
|
|
||||||
n, err := tarReader.Read(buffer)
|
|
||||||
if err != nil && err != io.EOF {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = writer.Write(buffer[:n])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = writer.Flush()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = file.Close()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// set file permissions, timestamps & uid/gid
|
|
||||||
_ = os.Chmod(filename, os.FileMode(header.Mode)) // #nosec
|
|
||||||
_ = os.Chtimes(filename, header.AccessTime, header.ModTime)
|
|
||||||
_ = os.Chown(filename, header.Uid, header.Gid)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(postExtraction) > 0 {
|
|
||||||
for _, dir := range postExtraction {
|
|
||||||
_ = os.Chtimes(dir.Path, dir.Header.AccessTime, dir.Header.ModTime)
|
|
||||||
_ = os.Chmod(dir.Path, dir.Header.FileInfo().Mode().Perm())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@@ -1,76 +0,0 @@
|
|||||||
package updater
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Unzip will decompress a zip archive, moving all files and folders
|
|
||||||
// within the zip file (src) to an output directory (dest).
|
|
||||||
func Unzip(src string, dest string) ([]string, error) {
|
|
||||||
|
|
||||||
var filenames []string
|
|
||||||
|
|
||||||
r, err := zip.OpenReader(src)
|
|
||||||
if err != nil {
|
|
||||||
return filenames, err
|
|
||||||
}
|
|
||||||
defer func() { _ = r.Close() }()
|
|
||||||
|
|
||||||
for _, f := range r.File {
|
|
||||||
|
|
||||||
// Store filename/path for returning and using later on
|
|
||||||
fpath := filepath.Join(dest, filepath.Clean(f.Name))
|
|
||||||
|
|
||||||
// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
|
|
||||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
|
||||||
return filenames, fmt.Errorf("%s: illegal file path", fpath)
|
|
||||||
}
|
|
||||||
|
|
||||||
filenames = append(filenames, fpath)
|
|
||||||
|
|
||||||
if f.FileInfo().IsDir() {
|
|
||||||
// Make Folder
|
|
||||||
if err := os.MkdirAll(fpath, os.ModePerm); /* #nosec */ err != nil {
|
|
||||||
return filenames, err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make File
|
|
||||||
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); /* #nosec */ err != nil {
|
|
||||||
return filenames, err
|
|
||||||
}
|
|
||||||
|
|
||||||
outFile, err := os.OpenFile(filepath.Clean(fpath), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
|
||||||
if err != nil {
|
|
||||||
return filenames, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rc, err := f.Open()
|
|
||||||
if err != nil {
|
|
||||||
return filenames, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(outFile, rc) // #nosec - file is streamed from zip to file
|
|
||||||
|
|
||||||
// Close the file without defer to close before next iteration of loop
|
|
||||||
if err := outFile.Close(); err != nil {
|
|
||||||
return filenames, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rc.Close(); err != nil {
|
|
||||||
return filenames, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return filenames, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filenames, nil
|
|
||||||
}
|
|
@@ -1,348 +0,0 @@
|
|||||||
// Package updater checks and downloads new versions
|
|
||||||
package updater
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/axllent/mailpit/config"
|
|
||||||
"github.com/axllent/mailpit/internal/logger"
|
|
||||||
"github.com/axllent/semver"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// AllowPrereleases defines whether pre-releases may be included
|
|
||||||
AllowPrereleases = false
|
|
||||||
|
|
||||||
// temporary directory
|
|
||||||
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)
|
|
||||||
|
|
||||||
timeout := time.Duration(5 * time.Second)
|
|
||||||
|
|
||||||
client := http.Client{
|
|
||||||
Timeout: timeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", releaseURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mailpit/"+config.Version)
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
body, err := io.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 "", "", "", errors.New("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 "", errors.New("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" {
|
|
||||||
err := os.Chmod(newExec, 0755) // #nosec
|
|
||||||
if 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 func() { _ = 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", err.Error())
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 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
|
|
||||||
return os.Remove(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: %s", err.Error())
|
|
||||||
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) // #nosec
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
Reference in New Issue
Block a user