diff --git a/cmd/init.go b/cmd/init.go index b60ece09..8537a2a6 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -549,7 +549,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox. return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err) } - log.Printf("`%s` inbox successfully initialized. %d SMTP servers. %d IMAP clients.", inboxRecord.Name, len(config.SMTP), len(config.IMAP)) + log.Printf("`%s` inbox successfully initialized", inboxRecord.Name) return inbox, nil } diff --git a/cmd/main.go b/cmd/main.go index ebcc32c3..733e1ebe 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,8 +6,10 @@ import ( "log" "os" "os/signal" + "sync" "sync/atomic" "syscall" + "time" "github.com/abhinavxd/libredesk/internal/ai" auth_ "github.com/abhinavxd/libredesk/internal/auth" @@ -83,6 +85,10 @@ type App struct { ai *ai.Manager search *search.Manager notifier *notifier.Service + + // Global state that stores data on an available app update. + update *AppUpdate + sync.Mutex } func main() { @@ -242,6 +248,11 @@ func main() { } }() + // Start the app update checker. + if ko.Bool("app.check_updates") { + go checkUpdates(versionString, time.Hour*24, app) + } + // Wait for shutdown signal. <-ctx.Done() colorlog.Red("Shutting down HTTP server...") diff --git a/cmd/settings.go b/cmd/settings.go index a7088999..41e25c32 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -20,7 +20,15 @@ func handleGetGeneralSettings(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } - return r.SendEnvelope(out) + // Unmarshal to add the app.update to the settings. + var settings map[string]interface{} + if err := json.Unmarshal(out, &settings); err != nil { + app.lo.Error("error unmarshalling settings", "err", err) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error fetching settings", nil)) + } + // Add the app.update to the settings, adding `app` prefix to the key to match the settings structure in db. + settings["app.update"] = app.update + return r.SendEnvelope(settings) } // handleUpdateGeneralSettings updates general settings. diff --git a/cmd/updates.go b/cmd/updates.go new file mode 100644 index 00000000..d35a9a78 --- /dev/null +++ b/cmd/updates.go @@ -0,0 +1,98 @@ +// Copyright Kailash Nadh (https://github.com/knadh/listmonk) +// SPDX-License-Identifier: AGPL-3.0 +// Adapted from listmonk for Libredesk. + +package main + +import ( + "encoding/json" + "io" + "net/http" + "regexp" + "time" + + "golang.org/x/mod/semver" +) + +const updateCheckURL = "https://updates.libredesk.io/updates.json" + +type AppUpdate struct { + Update struct { + ReleaseVersion string `json:"release_version"` + ReleaseDate string `json:"release_date"` + URL string `json:"url"` + Description string `json:"description"` + + // This is computed and set locally based on the local version. + IsNew bool `json:"is_new"` + } `json:"update"` + Messages []struct { + Date string `json:"date"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + Priority string `json:"priority"` + } `json:"messages"` +} + +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, "") + + fnCheck := func() { + resp, err := http.Get(updateCheckURL) + if err != nil { + app.lo.Error("error checking for app updates", "err", err) + return + } + + if resp.StatusCode != 200 { + app.lo.Error("non-ok status code checking for app updates", "status", resp.StatusCode) + return + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + app.lo.Error("error reading response body", "err", err) + return + } + resp.Body.Close() + + var out AppUpdate + if err := json.Unmarshal(b, &out); err != nil { + app.lo.Error("error unmarshalling response body", "err", err) + return + } + + // There is an update. Set it on the global app state. + if semver.IsValid(out.Update.ReleaseVersion) { + v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "") + if semver.Compare(v, curVersion) > 0 { + out.Update.IsNew = true + app.lo.Info("new update available", "version", out.Update.ReleaseVersion) + } + } + + app.Lock() + app.update = &out + app.Unlock() + } + + // Give a 15 minute buffer after app start in case the admin wants to disable + // update checks entirely and not make a request to upstream. + time.Sleep(time.Minute * 15) + fnCheck() + + // Thereafter, check every $interval. + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + fnCheck() + } +} diff --git a/config.sample.toml b/config.sample.toml index 27268438..386ad53b 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -2,6 +2,7 @@ [app] log_level = "debug" env = "dev" +check_updates = true # HTTP server. [app.server] @@ -45,7 +46,7 @@ max_lifetime = "300s" # Redis. [redis] -# If using docker compose, use the service name as the host. e.g. redis +# If using docker compose, use the service name as the host. e.g. redis:6379 address = "127.0.0.1:6379" password = "" db = 0 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4ed7f1ed..63416f8c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -48,6 +48,7 @@ @delete-view="deleteView" >