This commit is contained in:
binwiederhier
2026-01-18 10:46:15 -05:00
parent 5a1aa68ead
commit 856f150958
9 changed files with 117 additions and 91 deletions

View File

@@ -128,6 +128,12 @@ Examples:
ntfy serve --listen-http :8080 # Starts server with alternate port`, 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 { func execServe(c *cli.Context) error {
if c.NArg() > 0 { if c.NArg() > 0 {
return errors.New("no arguments expected, see 'ntfy serve --help' for help") 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.WebPushStartupQueries = webPushStartupQueries
conf.WebPushExpiryDuration = webPushExpiryDuration conf.WebPushExpiryDuration = webPushExpiryDuration
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration 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 // Check if we should run as a Windows service
if ranAsService, err := maybeRunAsService(conf); err != nil { 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 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
}

19
main.go
View File

@@ -2,12 +2,14 @@ package main
import ( import (
"fmt" "fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/cmd"
"os" "os"
"runtime" "runtime"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/cmd"
) )
// These variables are set during build time using -ldflags
var ( var (
version = "dev" version = "dev"
commit = "unknown" commit = "unknown"
@@ -24,13 +26,24 @@ the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
ntfy %s (%s), runtime %s, built at %s ntfy %s (%s), runtime %s, built at %s
Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2 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 := cmd.New()
app.Version = version app.Version = version
app.Metadata = map[string]any{
cmd.MetadataKeyDate: date,
cmd.MetadataKeyCommit: commit,
}
if err := app.Run(os.Args); err != nil { if err := app.Run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err.Error()) fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1) os.Exit(1)
} }
} }
func maybeShortCommit(commit string) string {
if len(commit) > 7 {
return commit[:7]
}
return commit
}

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"net/netip" "net/netip"
"reflect"
"text/template" "text/template"
"time" "time"
@@ -182,7 +183,9 @@ type Config struct {
WebPushStartupQueries string WebPushStartupQueries string
WebPushExpiryDuration time.Duration WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration 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 // NewConfig instantiates a default new server config
@@ -269,23 +272,31 @@ func NewConfig() *Config {
EnableReservations: false, EnableReservations: false,
RequireLogin: false, RequireLogin: false,
AccessControlAllowOrigin: "*", AccessControlAllowOrigin: "*",
Version: "",
WebPushPrivateKey: "", WebPushPrivateKey: "",
WebPushPublicKey: "", WebPushPublicKey: "",
WebPushFile: "", WebPushFile: "",
WebPushEmailAddress: "", WebPushEmailAddress: "",
WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryDuration: DefaultWebPushExpiryDuration,
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
BuildVersion: "",
BuildDate: "",
} }
} }
// Hash computes a SHA-256 hash of the configuration. This is used to detect // Hash computes an SHA-256 hash of the configuration. This is used to detect
// configuration changes for the web app version check feature. // configuration changes for the web app version check feature. It uses reflection
// to include all JSON-serializable fields automatically.
func (c *Config) Hash() string { func (c *Config) Hash() string {
b, err := json.Marshal(c) v := reflect.ValueOf(*c)
if err != nil { t := v.Type()
fmt.Println(err) 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([]byte(result)))
return fmt.Sprintf("%x", sha256.Sum256(b))
} }

View File

@@ -90,7 +90,7 @@ var (
matrixPushPath = "/_matrix/push/v1/notify" matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics" metricsPath = "/metrics"
apiHealthPath = "/v1/health" apiHealthPath = "/v1/health"
apiVersionPath = "/v1/version" apiConfigPath = "/v1/config"
apiStatsPath = "/v1/stats" apiStatsPath = "/v1/stats"
apiWebPushPath = "/v1/webpush" apiWebPushPath = "/v1/webpush"
apiTiersPath = "/v1/tiers" apiTiersPath = "/v1/tiers"
@@ -278,9 +278,9 @@ func (s *Server) Run() error {
if s.config.ProfileListenHTTP != "" { if s.config.ProfileListenHTTP != "" {
listenStr += fmt.Sprintf(" %s[http/profile]", 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() { 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()) fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File())
} }
mux := http.NewServeMux() 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) return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath { } else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
return s.handleHealth(w, r, v) return s.handleHealth(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath { } else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
return s.handleVersion(w, r, v) return s.handleConfig(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath { } 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) return s.writeJSON(w, response)
} }
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request, _ *visitor) error { func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
response := &apiVersionResponse{ w.Header().Set("Cache-Control", "no-cache")
Version: s.config.Version, return s.writeJSON(w, s.configResponse())
ConfigHash: s.config.Hash(),
}
return s.writeJSON(w, response)
} }
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { 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 BaseURL: "", // Will translate to window.location.origin
AppRoot: s.config.WebRoot, AppRoot: s.config.WebRoot,
EnableLogin: s.config.EnableLogin, EnableLogin: s.config.EnableLogin,
@@ -628,14 +636,6 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
DisallowedTopics: s.config.DisallowedTopics, DisallowedTopics: s.config.DisallowedTopics,
ConfigHash: s.config.Hash(), 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) // 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") logvm(v, m).Err(err).Warn("Unable to publish poll request")
return 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) req.Header.Set("X-Poll-ID", m.ID)
if s.config.UpstreamAccessToken != "" { if s.config.UpstreamAccessToken != "" {
req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken)) req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken))

View File

@@ -125,7 +125,7 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) {
if err != nil { if err != nil {
return "", err 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.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
@@ -149,7 +149,7 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha
if err != nil { if err != nil {
return err 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.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
@@ -175,7 +175,7 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber
if err != nil { if err != nil {
return err 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.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)

View File

@@ -317,11 +317,6 @@ type apiHealthResponse struct {
Healthy bool `json:"healthy"` Healthy bool `json:"healthy"`
} }
type apiVersionResponse struct {
Version string `json:"version"`
ConfigHash string `json:"config_hash"`
}
type apiStatsResponse struct { type apiStatsResponse struct {
Messages int64 `json:"messages"` Messages int64 `json:"messages"`
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second MessagesRate float64 `json:"messages_rate"` // Average number of messages per second

View File

@@ -19,7 +19,11 @@ class Pruner {
} }
stopWorker() { stopWorker() {
clearTimeout(this.timer); if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
console.log("[VersionChecker] Stopped pruner checker");
} }
async prune() { async prune() {

View File

@@ -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. * or configuration changes, prompting users to refresh the page.
*/ */
@@ -9,7 +9,8 @@ class VersionChecker {
constructor() { constructor() {
this.initialConfigHash = null; this.initialConfigHash = null;
this.listener = null; this.listener = null;
this.intervalId = null; console.log("XXXXXXxxxx set listener null");
this.timer = null;
} }
/** /**
@@ -18,73 +19,52 @@ class VersionChecker {
*/ */
startWorker() { startWorker() {
// Store initial config hash from the config loaded at page load // Store initial config hash from the config loaded at page load
this.initialConfigHash = window.config?.config_hash || null; this.initialConfigHash = window.config?.config_hash || "";
console.log("[VersionChecker] Starting version checker");
if (!this.initialConfigHash) { this.timer = setInterval(() => this.checkVersion(), CHECK_INTERVAL);
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() { stopWorker() {
if (this.intervalId) { if (this.timer) {
clearInterval(this.intervalId); clearInterval(this.timer);
this.intervalId = null; this.timer = null;
} }
console.log("[VersionChecker] Stopped version checker"); 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) { registerListener(listener) {
this.listener = listener; this.listener = listener;
} }
/**
* Resets the listener.
*/
resetListener() { resetListener() {
this.listener = null; this.listener = null;
} }
/**
* Fetches the current version from the server and compares it with the initial config hash.
*/
async checkVersion() { async checkVersion() {
if (!this.initialConfigHash) { if (!this.initialConfigHash) {
return; return;
} }
try { try {
const response = await fetch(`${window.config?.base_url || ""}/v1/version`); const response = await fetch(`${window.config?.base_url || ""}/v1/config`);
if (!response.ok) { if (!response.ok) {
console.log("[VersionChecker] Failed to fetch version:", response.status); console.log("[VersionChecker] Failed to fetch config:", response.status);
return; return;
} }
const data = await response.json(); const data = await response.json();
const currentHash = data.config_hash; const currentHash = data.config_hash;
console.log("[VersionChecker] Checked version, initial:", this.initialConfigHash, "current:", currentHash);
if (currentHash && currentHash !== this.initialConfigHash) { 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) { if (this.listener) {
this.listener(); this.listener();
} }
} else {
console.log("[VersionChecker] No version change detected");
} }
} catch (error) { } catch (error) {
console.log("[VersionChecker] Error checking version:", error); console.log("[VersionChecker] Error checking config:", error);
} }
} }
} }

View File

@@ -1,27 +1,27 @@
import { import {
Drawer,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Divider,
List,
Alert, Alert,
AlertTitle, AlertTitle,
Badge, Badge,
Box,
Button,
CircularProgress, CircularProgress,
Divider,
Drawer,
IconButton,
Link, Link,
List,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader, ListSubheader,
Portal, Portal,
Toolbar,
Tooltip, Tooltip,
Typography, Typography,
Box,
IconButton,
Button,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import * as React from "react"; import * as React from "react";
import { useCallback, useContext, useState } from "react"; import { useContext, useState } from "react";
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
import Person from "@mui/icons-material/Person"; import Person from "@mui/icons-material/Person";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
@@ -93,9 +93,9 @@ const NavList = (props) => {
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
const [versionChanged, setVersionChanged] = useState(false); const [versionChanged, setVersionChanged] = useState(false);
const handleVersionChange = useCallback(() => { const handleVersionChange = () => {
setVersionChanged(true); setVersionChanged(true);
}, []); };
useVersionChangeListener(handleVersionChange); useVersionChangeListener(handleVersionChange);