Add automatic update checks.

- A check for new versions on the GitHub releases pages happens
  once every 24 hours. When a new version is available, a notice
  is displayed on the admin UI.
This commit is contained in:
Kailash Nadh 2020-08-08 16:59:47 +05:30
parent f3e80da339
commit d8a60d1295
7 changed files with 107 additions and 11 deletions

View File

@ -20,7 +20,7 @@ deps:
# Build steps. # Build steps.
.PHONY: build .PHONY: build
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 .PHONY: build-frontend
build-frontend: build-frontend:

View File

@ -13,11 +13,12 @@ import (
) )
type configScript struct { type configScript struct {
RootURL string `json:"rootURL"` RootURL string `json:"rootURL"`
FromEmail string `json:"fromEmail"` FromEmail string `json:"fromEmail"`
Messengers []string `json:"messengers"` Messengers []string `json:"messengers"`
MediaProvider string `json:"mediaProvider"` MediaProvider string `json:"mediaProvider"`
NeedsRestart bool `json:"needsRestart"` NeedsRestart bool `json:"needsRestart"`
Update *AppUpdate `json:"update"`
} }
// handleGetConfigScript returns general configuration as a Javascript // handleGetConfigScript returns general configuration as a Javascript
@ -35,6 +36,7 @@ func handleGetConfigScript(c echo.Context) error {
app.Lock() app.Lock()
out.NeedsRestart = app.needsRestart out.NeedsRestart = app.needsRestart
out.Update = app.update
app.Unlock() app.Unlock()
var ( var (

View File

@ -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) { func registerHTTPHandlers(e *echo.Echo) {
// Group of private handlers with BasicAuth. // Group of private handlers with BasicAuth.
g := e.Group("", middleware.BasicAuth(basicAuth)) g := e.Group("", middleware.BasicAuth(basicAuth))
g.GET("/", handleIndexPage) g.GET("/", handleIndexPage)
g.GET("/api/health", handleHealthCheck) g.GET("/api/health", handleHealthCheck)
g.GET("/api/config.js", handleGetConfigScript) g.GET("/api/config.js", handleGetConfigScript)

View File

@ -42,6 +42,9 @@ type App struct {
// Global variable that stores the state indicating that a restart is required // Global variable that stores the state indicating that a restart is required
// after a settings update. // after a settings update.
needsRestart bool needsRestart bool
// Global state that stores data on an available remote update.
update *AppUpdate
sync.Mutex sync.Mutex
} }
@ -53,7 +56,8 @@ var (
db *sqlx.DB db *sqlx.DB
queries *Queries queries *Queries
buildString string buildString string
versionString string
) )
func init() { func init() {
@ -137,6 +141,9 @@ func main() {
// Start the app server. // Start the app server.
srv := initHTTPServer(app) 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. // 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 // The `wait` channel is passed to awaitReload to wait for the callback to finish
// within N seconds, or do a force reload. // within N seconds, or do a force reload.

78
cmd/updates.go Normal file
View File

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

View File

@ -84,7 +84,7 @@
<!-- body //--> <!-- body //-->
<div class="main"> <div class="main">
<div class="global-notices" v-if="serverConfig.needsRestart"> <div class="global-notices" v-if="serverConfig.needsRestart || serverConfig.update">
<div v-if="serverConfig.needsRestart" class="notification is-danger"> <div v-if="serverConfig.needsRestart" class="notification is-danger">
Settings have changed. Pause all running campaigns and restart the app Settings have changed. Pause all running campaigns and restart the app
&mdash; &mdash;
@ -94,6 +94,10 @@
Restart Restart
</b-button> </b-button>
</div> </div>
<div v-if="serverConfig.update" class="notification is-success">
A new update ({{ serverConfig.update.version }}) is available.
<a :href="serverConfig.update.url" target="_blank">View</a>
</div>
</div> </div>
<router-view :key="$route.fullPath" /> <router-view :key="$route.fullPath" />

View File

@ -158,11 +158,17 @@ section {
} }
.notification { .notification {
padding: 10px 15px; padding: 10px 15px;
border-left: 5px solid #eee;
&.is-danger { &.is-danger {
background: $white-ter; background: $white-ter;
color: $black; color: $black;
border-left: 5px solid $red; border-left-color: $red;
font-weight: bold; }
&.is-success {
background: $white-ter;
color: $black;
border-left-color: $green;
} }
} }