diff --git a/Makefile b/Makefile index c280699..032ca75 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ deps: # Build steps. .PHONY: build build: - go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}'" cmd/*.go + go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go .PHONY: build-frontend build-frontend: diff --git a/cmd/admin.go b/cmd/admin.go index 36607d7..767fd55 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -13,11 +13,12 @@ import ( ) type configScript struct { - RootURL string `json:"rootURL"` - FromEmail string `json:"fromEmail"` - Messengers []string `json:"messengers"` - MediaProvider string `json:"mediaProvider"` - NeedsRestart bool `json:"needsRestart"` + RootURL string `json:"rootURL"` + FromEmail string `json:"fromEmail"` + Messengers []string `json:"messengers"` + MediaProvider string `json:"mediaProvider"` + NeedsRestart bool `json:"needsRestart"` + Update *AppUpdate `json:"update"` } // handleGetConfigScript returns general configuration as a Javascript @@ -35,6 +36,7 @@ func handleGetConfigScript(c echo.Context) error { app.Lock() out.NeedsRestart = app.needsRestart + out.Update = app.update app.Unlock() var ( diff --git a/cmd/handlers.go b/cmd/handlers.go index b3c481c..ed641e9 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -34,7 +34,6 @@ var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[ func registerHTTPHandlers(e *echo.Echo) { // Group of private handlers with BasicAuth. g := e.Group("", middleware.BasicAuth(basicAuth)) - g.GET("/", handleIndexPage) g.GET("/api/health", handleHealthCheck) g.GET("/api/config.js", handleGetConfigScript) diff --git a/cmd/main.go b/cmd/main.go index 6af1e37..c16be5b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -42,6 +42,9 @@ type App struct { // Global variable that stores the state indicating that a restart is required // after a settings update. needsRestart bool + + // Global state that stores data on an available remote update. + update *AppUpdate sync.Mutex } @@ -53,7 +56,8 @@ var ( db *sqlx.DB queries *Queries - buildString string + buildString string + versionString string ) func init() { @@ -137,6 +141,9 @@ func main() { // Start the app server. srv := initHTTPServer(app) + // Star the update checker. + go checkUpdates(versionString, time.Hour*24, app) + // Wait for the reload signal with a callback to gracefully shut down resources. // The `wait` channel is passed to awaitReload to wait for the callback to finish // within N seconds, or do a force reload. diff --git a/cmd/updates.go b/cmd/updates.go new file mode 100644 index 0000000..085618a --- /dev/null +++ b/cmd/updates.go @@ -0,0 +1,78 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "regexp" + "time" + + "golang.org/x/mod/semver" +) + +const updateCheckURL = "https://api.github.com/repos/knadh/listmonk/releases/latest" + +type remoteUpdateResp struct { + Version string `json:"tag_name"` + URL string `json:"html_url"` +} + +// AppUpdate contains information of a new update available to the app that +// is sent to the frontend. +type AppUpdate struct { + Version string `json:"version"` + URL string `json:"url"` +} + +var reSemver = regexp.MustCompile(`-(.*)`) + +// checkUpdates is a blocking function that checks for updates to the app +// at the given intervals. On detecting a new update (new semver), it +// sets the global update status that renders a prompt on the UI. +func checkUpdates(curVersion string, interval time.Duration, app *App) { + // Strip -* suffix. + curVersion = reSemver.ReplaceAllString(curVersion, "") + + time.Sleep(time.Second * 1) + ticker := time.NewTicker(interval) + for ; true; <-ticker.C { + resp, err := http.Get(updateCheckURL) + if err != nil { + app.log.Printf("error checking for remote update: %v", err) + continue + } + + if resp.StatusCode != 200 { + app.log.Printf("non 200 response on remote update check: %d", resp.StatusCode) + continue + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + app.log.Printf("error reading remote update payload: %v", err) + continue + } + resp.Body.Close() + + var up remoteUpdateResp + if err := json.Unmarshal(b, &up); err != nil { + app.log.Printf("error unmarshalling remote update payload: %v", err) + continue + } + + // There is an update. Set it on the global app state. + if semver.IsValid(up.Version) { + v := reSemver.ReplaceAllString(up.Version, "") + if semver.Compare(v, curVersion) > 0 { + app.Lock() + app.update = &AppUpdate{ + Version: up.Version, + URL: up.URL, + } + app.Unlock() + + app.log.Printf("new update %s found", up.Version) + } + } + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index bf3f308..770639f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -84,7 +84,7 @@