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]);
+};