Update checker

This commit is contained in:
binwiederhier
2026-01-17 20:36:15 -05:00
parent 603273ab9d
commit cc9f9c0d24
8 changed files with 265 additions and 3 deletions

View File

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

View File

@@ -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 {

View File

@@ -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 {

View File

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

View File

@@ -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",

View File

@@ -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;

View File

@@ -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 = () => {
/>
</Main>
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
<Snackbar
open={versionChanged}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
sx={{ bottom: { xs: 70, md: 24 } }}
>
<Alert
severity="info"
variant="filled"
action={
<Button color="inherit" size="small" onClick={handleRefresh}>
{t("common_refresh")}
</Button>
}
>
{t("version_update_available")}
</Alert>
</Snackbar>
</Box>
);
};

View File

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