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" >
+
@@ -77,6 +78,7 @@ import { useMacroStore } from '@/stores/macro' import { useTagStore } from '@/stores/tag' import PageHeader from './components/layout/PageHeader.vue' import ViewForm from '@/features/view/ViewForm.vue' +import AppUpdate from '@/components/update/AppUpdate.vue' import api from '@/api' import { toast as sooner } from 'vue-sonner' import Sidebar from '@/components/sidebar/Sidebar.vue' diff --git a/frontend/src/components/update/AppUpdate.vue b/frontend/src/components/update/AppUpdate.vue new file mode 100644 index 00000000..ca0ccc95 --- /dev/null +++ b/frontend/src/components/update/AppUpdate.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js index b33f795c..d60063d7 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,6 +1,7 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import { createI18n } from 'vue-i18n' +import { useAppSettingsStore } from './stores/appSettings' import router from './router' import mitt from 'mitt' import api from './api' @@ -38,12 +39,16 @@ async function initApp () { const i18n = createI18n(i18nConfig) const app = createApp(Root) const pinia = createPinia() + app.use(pinia) + + // Store app settings in Pinia + const settingsStore = useAppSettingsStore() + settingsStore.setSettings(settings) // Add emitter to global properties. app.config.globalProperties.emitter = emitter app.use(router) - app.use(pinia) app.use(i18n) app.mount('#app') } diff --git a/frontend/src/stores/appSettings.js b/frontend/src/stores/appSettings.js new file mode 100644 index 00000000..65dab292 --- /dev/null +++ b/frontend/src/stores/appSettings.js @@ -0,0 +1,12 @@ +import { defineStore } from 'pinia' + +export const useAppSettingsStore = defineStore('settings', { + state: () => ({ + settings: {} + }), + actions: { + setSettings (newSettings) { + this.settings = newSettings + } + } +}) diff --git a/internal/user/user.go b/internal/user/user.go index 9fdc09e0..68eb25d5 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -335,7 +335,7 @@ func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error { if err := updateSystemUserPassword(db, hashedPassword); err != nil { return fmt.Errorf("error updating system user password: %v", err) } - fmt.Println("password updated successfully.") + fmt.Println("password updated successfully. Login with email 'System' and the new password.") return nil }