binwiederhier 1 месяц назад
Родитель
Сommit
cc9f9c0d24

+ 87 - 0
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))
+}

+ 12 - 0
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 {

+ 6 - 0
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 {

+ 1 - 0
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
 };

+ 2 - 0
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",

+ 93 - 0
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;

+ 49 - 3
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 = () => {
         />
       </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>
   );
 };

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