1
0
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:
Ralph Slooten
2025-06-28 23:33:23 +12:00
parent 7c7d915059
commit 7b805ef7cd
8 changed files with 45 additions and 679 deletions

View File

@@ -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
}

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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++

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}