diff --git a/cmd/version.go b/cmd/version.go index 2f1e155..2838f2c 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -6,7 +6,6 @@ import ( "runtime" "github.com/axllent/mailpit/config" - "github.com/axllent/mailpit/internal/updater" "github.com/spf13/cobra" ) @@ -15,29 +14,41 @@ var versionCmd = &cobra.Command{ Use: "version", Short: "Display the current version & update information", Long: `Display the current version & update information (if available).`, - RunE: func(cmd *cobra.Command, args []string) error { - - updater.AllowPrereleases = true - + Run: func(cmd *cobra.Command, args []string) { update, _ := cmd.Flags().GetBool("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", os.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH) - latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName) - if err == nil && updater.GreaterThan(latest, config.Version) { - fmt.Printf( - "\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n", - latest, - os.Args[0], - ) + release, err := config.GHRUConfig.Latest() + if err != nil { + fmt.Printf("Error checking for latest release: %s\n", err) + os.Exit(1) } - 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(). 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 -} diff --git a/config/config.go b/config/config.go index 1518084..5c7ceef 100644 --- a/config/config.go +++ b/config/config.go @@ -11,6 +11,7 @@ import ( "regexp" "strings" + "github.com/axllent/ghru/v2" "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/smtpd/chaos" @@ -19,6 +20,18 @@ import ( ) 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 : SMTPListen = "[::]:1025" @@ -198,15 +211,6 @@ var ( // Empty = disabled, true= use existing web server, address = separate server 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 string diff --git a/go.mod b/go.mod index 6c41138..91c5c57 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.3 require ( github.com/PuerkitoBio/goquery v1.10.3 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/goccy/go-yaml v1.18.0 github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b @@ -63,6 +64,7 @@ require ( github.com/vanng822/css v1.0.1 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // 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 google.golang.org/protobuf v1.36.6 // indirect modernc.org/libc v1.66.0 // indirect diff --git a/go.sum b/go.sum index 4daadb3..2ac40f7 100644 --- a/go.sum +++ b/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/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/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/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/internal/stats/stats.go b/internal/stats/stats.go index 785b5e6..339f432 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -9,7 +9,6 @@ import ( "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/storage" - "github.com/axllent/mailpit/internal/updater" ) // Stores cached version along with its expiry time and error count. @@ -113,10 +112,10 @@ func Load() AppInformation { if time.Now().Before(vCache.expiry) { info.LatestVersion = vCache.value } else { - latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName) + latest, err := config.GHRUConfig.Latest() if err == nil { - vCache = versionCache{value: latest, expiry: time.Now().Add(15 * time.Minute)} - info.LatestVersion = latest + vCache = versionCache{value: latest.Tag, expiry: time.Now().Add(15 * time.Minute)} + info.LatestVersion = latest.Tag } else { logger.Log().Errorf("Failed to fetch latest version: %v", err) vCache.errCount++ diff --git a/internal/updater/targz.go b/internal/updater/targz.go deleted file mode 100644 index b138d1c..0000000 --- a/internal/updater/targz.go +++ /dev/null @@ -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 -} diff --git a/internal/updater/unzip.go b/internal/updater/unzip.go deleted file mode 100644 index 3fc2d57..0000000 --- a/internal/updater/unzip.go +++ /dev/null @@ -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 -} diff --git a/internal/updater/updater.go b/internal/updater/updater.go deleted file mode 100644 index 7f6bf27..0000000 --- a/internal/updater/updater.go +++ /dev/null @@ -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 .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 .old - if err := os.Rename(dst, oldTmpAbs); err != nil { - return err - } - - // rename the .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 -}