diff --git a/server/config.go b/server/config.go index 804c0980..5136e82e 100644 --- a/server/config.go +++ b/server/config.go @@ -1,6 +1,9 @@ package server import ( + "crypto/sha256" + "encoding/json" + "fmt" "io/fs" "net/netip" "text/template" @@ -275,3 +278,87 @@ func NewConfig() *Config { WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, } } + +// configHashData is a subset of Config fields used for computing the config hash. +// It excludes sensitive fields (keys, passwords, tokens) and runtime-only fields. +type configHashData struct { + BaseURL string + ListenHTTP string + ListenHTTPS string + ListenUnix string + CacheDuration time.Duration + AttachmentTotalSizeLimit int64 + AttachmentFileSizeLimit int64 + AttachmentExpiryDuration time.Duration + KeepaliveInterval time.Duration + ManagerInterval time.Duration + DisallowedTopics []string + WebRoot string + MessageDelayMin time.Duration + MessageDelayMax time.Duration + MessageSizeLimit int + TotalTopicLimit int + VisitorSubscriptionLimit int + VisitorAttachmentTotalSizeLimit int64 + VisitorAttachmentDailyBandwidthLimit int64 + VisitorRequestLimitBurst int + VisitorRequestLimitReplenish time.Duration + VisitorMessageDailyLimit int + VisitorEmailLimitBurst int + VisitorEmailLimitReplenish time.Duration + EnableSignup bool + EnableLogin bool + RequireLogin bool + EnableReservations bool + EnableMetrics bool + EnablePayments bool + EnableCalls bool + EnableEmails bool + EnableWebPush bool + BillingContact string + Version string +} + +// Hash computes a SHA-256 hash of the configuration. This is used to detect +// configuration changes for the web app version check feature. +func (c *Config) Hash() string { + data := configHashData{ + BaseURL: c.BaseURL, + ListenHTTP: c.ListenHTTP, + ListenHTTPS: c.ListenHTTPS, + ListenUnix: c.ListenUnix, + CacheDuration: c.CacheDuration, + AttachmentTotalSizeLimit: c.AttachmentTotalSizeLimit, + AttachmentFileSizeLimit: c.AttachmentFileSizeLimit, + AttachmentExpiryDuration: c.AttachmentExpiryDuration, + KeepaliveInterval: c.KeepaliveInterval, + ManagerInterval: c.ManagerInterval, + DisallowedTopics: c.DisallowedTopics, + WebRoot: c.WebRoot, + MessageDelayMin: c.MessageDelayMin, + MessageDelayMax: c.MessageDelayMax, + MessageSizeLimit: c.MessageSizeLimit, + TotalTopicLimit: c.TotalTopicLimit, + VisitorSubscriptionLimit: c.VisitorSubscriptionLimit, + VisitorAttachmentTotalSizeLimit: c.VisitorAttachmentTotalSizeLimit, + VisitorAttachmentDailyBandwidthLimit: c.VisitorAttachmentDailyBandwidthLimit, + VisitorRequestLimitBurst: c.VisitorRequestLimitBurst, + VisitorRequestLimitReplenish: c.VisitorRequestLimitReplenish, + VisitorMessageDailyLimit: c.VisitorMessageDailyLimit, + VisitorEmailLimitBurst: c.VisitorEmailLimitBurst, + VisitorEmailLimitReplenish: c.VisitorEmailLimitReplenish, + EnableSignup: c.EnableSignup, + EnableLogin: c.EnableLogin, + RequireLogin: c.RequireLogin, + EnableReservations: c.EnableReservations, + EnableMetrics: c.EnableMetrics, + EnablePayments: c.StripeSecretKey != "", + EnableCalls: c.TwilioAccount != "", + EnableEmails: c.SMTPSenderFrom != "", + EnableWebPush: c.WebPushPublicKey != "", + BillingContact: c.BillingContact, + Version: c.Version, + } + b, _ := json.Marshal(data) + return fmt.Sprintf("%x", sha256.Sum256(b)) +} diff --git a/server/server.go b/server/server.go index 3bd53ea6..f2c30fb0 100644 --- a/server/server.go +++ b/server/server.go @@ -90,6 +90,7 @@ var ( matrixPushPath = "/_matrix/push/v1/notify" metricsPath = "/metrics" apiHealthPath = "/v1/health" + apiVersionPath = "/v1/version" apiStatsPath = "/v1/stats" apiWebPushPath = "/v1/webpush" apiTiersPath = "/v1/tiers" @@ -460,6 +461,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleEmpty)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath { return s.handleHealth(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath { + return s.handleVersion(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webManifestPath { @@ -600,6 +603,14 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor return s.writeJSON(w, response) } +func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + response := &apiVersionResponse{ + Version: s.config.Version, + ConfigHash: s.config.Hash(), + } + return s.writeJSON(w, response) +} + func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { response := &apiConfigResponse{ BaseURL: "", // Will translate to window.location.origin @@ -615,6 +626,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi BillingContact: s.config.BillingContact, WebPushPublicKey: s.config.WebPushPublicKey, DisallowedTopics: s.config.DisallowedTopics, + ConfigHash: s.config.Hash(), } b, err := json.MarshalIndent(response, "", " ") if err != nil { diff --git a/server/types.go b/server/types.go index 6464222f..a8ffc2bc 100644 --- a/server/types.go +++ b/server/types.go @@ -317,6 +317,11 @@ type apiHealthResponse struct { Healthy bool `json:"healthy"` } +type apiVersionResponse struct { + Version string `json:"version"` + ConfigHash string `json:"config_hash"` +} + type apiStatsResponse struct { Messages int64 `json:"messages"` MessagesRate float64 `json:"messages_rate"` // Average number of messages per second @@ -482,6 +487,7 @@ type apiConfigResponse struct { BillingContact string `json:"billing_contact"` WebPushPublicKey string `json:"web_push_public_key"` DisallowedTopics []string `json:"disallowed_topics"` + ConfigHash string `json:"config_hash"` } type apiAccountBillingPrices struct { diff --git a/web/public/config.js b/web/public/config.js index fcc567aa..62f49ed4 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -19,4 +19,5 @@ var config = { billing_contact: "", web_push_public_key: "", disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"], + config_hash: "dev", // Placeholder for development; actual value is generated server-side }; diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index b0d3c545..bfbcf610 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -4,6 +4,8 @@ "common_add": "Add", "common_back": "Back", "common_copy_to_clipboard": "Copy to clipboard", + "common_refresh": "Refresh", + "version_update_available": "New ntfy version available. Please refresh the page.", "signup_title": "Create a ntfy account", "signup_form_username": "Username", "signup_form_password": "Password", diff --git a/web/src/app/VersionChecker.js b/web/src/app/VersionChecker.js new file mode 100644 index 00000000..ae0272b4 --- /dev/null +++ b/web/src/app/VersionChecker.js @@ -0,0 +1,93 @@ +/** + * VersionChecker polls the /v1/version endpoint to detect server restarts + * or configuration changes, prompting users to refresh the page. + */ + +const CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes + +class VersionChecker { + constructor() { + this.initialConfigHash = null; + this.listener = null; + this.intervalId = null; + } + + /** + * Starts the version checker worker. It stores the initial config hash + * from the config.js and polls the server every 5 minutes. + */ + startWorker() { + // Store initial config hash from the config loaded at page load + this.initialConfigHash = window.config?.config_hash || null; + + if (!this.initialConfigHash) { + console.log("[VersionChecker] No initial config_hash found, version checking disabled"); + return; + } + + console.log("[VersionChecker] Starting version checker with initial hash:", this.initialConfigHash); + + // Start polling + this.intervalId = setInterval(() => this.checkVersion(), CHECK_INTERVAL); + } + + /** + * Stops the version checker worker. + */ + stopWorker() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + console.log("[VersionChecker] Stopped version checker"); + } + + /** + * Registers a listener that will be called when a version change is detected. + * @param {function} listener - Callback function that receives no arguments + */ + registerListener(listener) { + this.listener = listener; + } + + /** + * Resets the listener. + */ + resetListener() { + this.listener = null; + } + + /** + * Fetches the current version from the server and compares it with the initial config hash. + */ + async checkVersion() { + if (!this.initialConfigHash) { + return; + } + + try { + const response = await fetch(`${window.config?.base_url || ""}/v1/version`); + if (!response.ok) { + console.log("[VersionChecker] Failed to fetch version:", response.status); + return; + } + + const data = await response.json(); + const currentHash = data.config_hash; + + console.log("[VersionChecker] Checked version, initial:", this.initialConfigHash, "current:", currentHash); + + if (currentHash && currentHash !== this.initialConfigHash) { + console.log("[VersionChecker] Config hash changed, notifying listener"); + if (this.listener) { + this.listener(); + } + } + } catch (error) { + console.log("[VersionChecker] Error checking version:", error); + } + } +} + +const versionChecker = new VersionChecker(); +export default versionChecker; diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 9a2c3e66..e6157b86 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -1,6 +1,18 @@ import * as React from "react"; -import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react"; -import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material"; +import { createContext, Suspense, useContext, useEffect, useState, useMemo, useCallback } from "react"; +import { + Box, + Toolbar, + CssBaseline, + Backdrop, + CircularProgress, + useMediaQuery, + ThemeProvider, + createTheme, + Snackbar, + Button, + Alert, +} from "@mui/material"; import { useLiveQuery } from "dexie-react-hooks"; import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -14,7 +26,13 @@ import userManager from "../app/UserManager"; import { expandUrl, getKebabCaseLangStr } from "../app/utils"; import ErrorBoundary from "./ErrorBoundary"; import routes from "./routes"; -import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks"; +import { + useAccountListener, + useBackgroundProcesses, + useConnectionListeners, + useWebPushTopics, + useVersionChangeListener, +} from "./hooks"; import PublishDialog from "./PublishDialog"; import Messaging from "./Messaging"; import Login from "./Login"; @@ -100,10 +118,12 @@ const updateTitle = (newNotificationsCount) => { }; const Layout = () => { + const { t } = useTranslation(); const params = useParams(); const { account, setAccount } = useContext(AccountContext); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); + const [versionChanged, setVersionChanged] = useState(false); const users = useLiveQuery(() => userManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all()); const webPushTopics = useWebPushTopics(); @@ -115,9 +135,18 @@ const Layout = () => { (config.base_url === s.baseUrl && params.topic === s.topic) ); + const handleVersionChange = useCallback(() => { + setVersionChanged(true); + }, []); + + const handleRefresh = useCallback(() => { + window.location.reload(); + }, []); + useConnectionListeners(account, subscriptions, users, webPushTopics); useAccountListener(setAccount); useBackgroundProcesses(); + useVersionChangeListener(handleVersionChange); useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); return ( @@ -140,6 +169,23 @@ const Layout = () => { /> + + + {t("common_refresh")} + + } + > + {t("version_update_available")} + + ); }; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 9dadd551..a9268dd2 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -9,6 +9,7 @@ import poller from "../app/Poller"; import pruner from "../app/Pruner"; import session from "../app/Session"; import accountApi from "../app/AccountApi"; +import versionChecker from "../app/VersionChecker"; import { UnauthorizedError } from "../app/errors"; import notifier from "../app/Notifier"; import prefs from "../app/Prefs"; @@ -292,12 +293,14 @@ const startWorkers = () => { poller.startWorker(); pruner.startWorker(); accountApi.startWorker(); + versionChecker.startWorker(); }; const stopWorkers = () => { poller.stopWorker(); pruner.stopWorker(); accountApi.stopWorker(); + versionChecker.stopWorker(); }; export const useBackgroundProcesses = () => { @@ -323,3 +326,15 @@ export const useAccountListener = (setAccount) => { }; }, []); }; + +/** + * Hook to detect version/config changes and call the provided callback when a change is detected. + */ +export const useVersionChangeListener = (onVersionChange) => { + useEffect(() => { + versionChecker.registerListener(onVersionChange); + return () => { + versionChecker.resetListener(); + }; + }, [onVersionChange]); +};