diff --git a/cmd/serve.go b/cmd/serve.go index 5acf048b..0208055b 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -128,6 +128,12 @@ Examples: ntfy serve --listen-http :8080 # Starts server with alternate port`, } +// App metadata fields used to pass from +const ( + MetadataKeyCommit = "commit" + MetadataKeyDate = "date" +) + func execServe(c *cli.Context) error { if c.NArg() > 0 { return errors.New("no arguments expected, see 'ntfy serve --help' for help") @@ -501,7 +507,9 @@ func execServe(c *cli.Context) error { conf.WebPushStartupQueries = webPushStartupQueries conf.WebPushExpiryDuration = webPushExpiryDuration conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration - conf.Version = c.App.Version + conf.BuildVersion = c.App.Version + conf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate) + conf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit) // Check if we should run as a Windows service if ranAsService, err := maybeRunAsService(conf); err != nil { @@ -655,3 +663,18 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok } return tokens, nil } + +func maybeFromMetadata(m map[string]any, key string) string { + if m == nil { + return "" + } + v, exists := m[key] + if !exists { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return s +} diff --git a/main.go b/main.go index 4e01a0d6..b9bef369 100644 --- a/main.go +++ b/main.go @@ -2,12 +2,14 @@ package main import ( "fmt" - "github.com/urfave/cli/v2" - "heckel.io/ntfy/v2/cmd" "os" "runtime" + + "github.com/urfave/cli/v2" + "heckel.io/ntfy/v2/cmd" ) +// These variables are set during build time using -ldflags var ( version = "dev" commit = "unknown" @@ -24,13 +26,24 @@ the Matrix room (https://matrix.to/#/#ntfy:matrix.org). ntfy %s (%s), runtime %s, built at %s Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2 -`, version, commit[:7], runtime.Version(), date) +`, version, maybeShortCommit(commit), runtime.Version(), date) app := cmd.New() app.Version = version + app.Metadata = map[string]any{ + cmd.MetadataKeyDate: date, + cmd.MetadataKeyCommit: commit, + } if err := app.Run(os.Args); err != nil { fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) } } + +func maybeShortCommit(commit string) string { + if len(commit) > 7 { + return commit[:7] + } + return commit +} diff --git a/server/config.go b/server/config.go index 8a111ed4..26b00b8b 100644 --- a/server/config.go +++ b/server/config.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "net/netip" + "reflect" "text/template" "time" @@ -182,7 +183,9 @@ type Config struct { WebPushStartupQueries string WebPushExpiryDuration time.Duration WebPushExpiryWarningDuration time.Duration - Version string // Injected by App + BuildVersion string // Injected by App + BuildDate string // Injected by App + BuildCommit string // Injected by App } // NewConfig instantiates a default new server config @@ -269,23 +272,31 @@ func NewConfig() *Config { EnableReservations: false, RequireLogin: false, AccessControlAllowOrigin: "*", - Version: "", WebPushPrivateKey: "", WebPushPublicKey: "", WebPushFile: "", WebPushEmailAddress: "", WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, + BuildVersion: "", + BuildDate: "", } } -// Hash computes a SHA-256 hash of the configuration. This is used to detect -// configuration changes for the web app version check feature. +// Hash computes an SHA-256 hash of the configuration. This is used to detect +// configuration changes for the web app version check feature. It uses reflection +// to include all JSON-serializable fields automatically. func (c *Config) Hash() string { - b, err := json.Marshal(c) - if err != nil { - fmt.Println(err) + v := reflect.ValueOf(*c) + t := v.Type() + var result string + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldName := t.Field(i).Name + // Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix) + if b, err := json.Marshal(field.Interface()); err == nil { + result += fmt.Sprintf("%s:%s|", fieldName, string(b)) + } } - fmt.Println(string(b)) - return fmt.Sprintf("%x", sha256.Sum256(b)) + return fmt.Sprintf("%x", sha256.Sum256([]byte(result))) } diff --git a/server/server.go b/server/server.go index f2c30fb0..d3c72029 100644 --- a/server/server.go +++ b/server/server.go @@ -90,7 +90,7 @@ var ( matrixPushPath = "/_matrix/push/v1/notify" metricsPath = "/metrics" apiHealthPath = "/v1/health" - apiVersionPath = "/v1/version" + apiConfigPath = "/v1/config" apiStatsPath = "/v1/stats" apiWebPushPath = "/v1/webpush" apiTiersPath = "/v1/tiers" @@ -278,9 +278,9 @@ func (s *Server) Run() error { if s.config.ProfileListenHTTP != "" { listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP) } - log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String()) + log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.BuildVersion, log.CurrentLevel().String()) if log.IsFile() { - fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version) + fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.BuildVersion) fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File()) } mux := http.NewServeMux() @@ -461,8 +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 == apiConfigPath { + return s.handleConfig(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 { @@ -603,16 +603,24 @@ 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) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + w.Header().Set("Cache-Control", "no-cache") + return s.writeJSON(w, s.configResponse()) } func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { - response := &apiConfigResponse{ + b, err := json.MarshalIndent(s.configResponse(), "", " ") + if err != nil { + return err + } + w.Header().Set("Content-Type", "text/javascript") + w.Header().Set("Cache-Control", "no-cache") + _, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b))) + return err +} + +func (s *Server) configResponse() *apiConfigResponse { + return &apiConfigResponse{ BaseURL: "", // Will translate to window.location.origin AppRoot: s.config.WebRoot, EnableLogin: s.config.EnableLogin, @@ -628,14 +636,6 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi DisallowedTopics: s.config.DisallowedTopics, ConfigHash: s.config.Hash(), } - b, err := json.MarshalIndent(response, "", " ") - if err != nil { - return err - } - w.Header().Set("Content-Type", "text/javascript") - w.Header().Set("Cache-Control", "no-cache") - _, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b))) - return err } // handleWebManifest serves the web app manifest for the progressive web app (PWA) @@ -1003,7 +1003,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { logvm(v, m).Err(err).Warn("Unable to publish poll request") return } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion) req.Header.Set("X-Poll-ID", m.ID) if s.config.UpstreamAccessToken != "" { req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken)) diff --git a/server/server_twilio.go b/server/server_twilio.go index 6a613d49..c1761613 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -125,7 +125,7 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) { if err != nil { return "", err } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) resp, err := http.DefaultClient.Do(req) @@ -149,7 +149,7 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha if err != nil { return err } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) resp, err := http.DefaultClient.Do(req) @@ -175,7 +175,7 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber if err != nil { return err } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) + req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) resp, err := http.DefaultClient.Do(req) diff --git a/server/types.go b/server/types.go index a8ffc2bc..fae44e5b 100644 --- a/server/types.go +++ b/server/types.go @@ -317,11 +317,6 @@ 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 diff --git a/web/src/app/Pruner.js b/web/src/app/Pruner.js index f9568a33..f08fb770 100644 --- a/web/src/app/Pruner.js +++ b/web/src/app/Pruner.js @@ -19,7 +19,11 @@ class Pruner { } stopWorker() { - clearTimeout(this.timer); + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + console.log("[VersionChecker] Stopped pruner checker"); } async prune() { diff --git a/web/src/app/VersionChecker.js b/web/src/app/VersionChecker.js index 07459cb2..53746126 100644 --- a/web/src/app/VersionChecker.js +++ b/web/src/app/VersionChecker.js @@ -1,5 +1,5 @@ /** - * VersionChecker polls the /v1/version endpoint to detect server restarts + * VersionChecker polls the /v1/config endpoint to detect new server versions * or configuration changes, prompting users to refresh the page. */ @@ -9,7 +9,8 @@ class VersionChecker { constructor() { this.initialConfigHash = null; this.listener = null; - this.intervalId = null; + console.log("XXXXXXxxxx set listener null"); + this.timer = null; } /** @@ -18,73 +19,52 @@ class VersionChecker { */ 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); + this.initialConfigHash = window.config?.config_hash || ""; + console.log("[VersionChecker] Starting version checker"); + this.timer = setInterval(() => this.checkVersion(), CHECK_INTERVAL); } - /** - * Stops the version checker worker. - */ stopWorker() { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; + if (this.timer) { + clearInterval(this.timer); + this.timer = 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`); + const response = await fetch(`${window.config?.base_url || ""}/v1/config`); if (!response.ok) { - console.log("[VersionChecker] Failed to fetch version:", response.status); + console.log("[VersionChecker] Failed to fetch config:", 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"); + console.log("[VersionChecker] Version or config changed, showing banner"); if (this.listener) { this.listener(); } + } else { + console.log("[VersionChecker] No version change detected"); } } catch (error) { - console.log("[VersionChecker] Error checking version:", error); + console.log("[VersionChecker] Error checking config:", error); } } } diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx index 75b587ea..89381cb3 100644 --- a/web/src/components/Navigation.jsx +++ b/web/src/components/Navigation.jsx @@ -1,27 +1,27 @@ import { - Drawer, - ListItemButton, - ListItemIcon, - ListItemText, - Toolbar, - Divider, - List, Alert, AlertTitle, Badge, + Box, + Button, CircularProgress, + Divider, + Drawer, + IconButton, Link, + List, + ListItemButton, + ListItemIcon, + ListItemText, ListSubheader, Portal, + Toolbar, Tooltip, Typography, - Box, - IconButton, - Button, useTheme, } from "@mui/material"; import * as React from "react"; -import { useCallback, useContext, useState } from "react"; +import { useContext, useState } from "react"; import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; import Person from "@mui/icons-material/Person"; import SettingsIcon from "@mui/icons-material/Settings"; @@ -93,9 +93,9 @@ const NavList = (props) => { const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); const [versionChanged, setVersionChanged] = useState(false); - const handleVersionChange = useCallback(() => { + const handleVersionChange = () => { setVersionChanged(true); - }, []); + }; useVersionChangeListener(handleVersionChange);