Compare commits

..

6 Commits

Author SHA1 Message Date
binwiederhier
886be722bc Release notes 2026-01-18 10:52:27 -05:00
binwiederhier
6886ca24b1 Self-review 2026-01-18 10:51:36 -05:00
binwiederhier
856f150958 Better 2026-01-18 10:46:15 -05:00
binwiederhier
5a1aa68ead Refine 2026-01-18 09:44:21 -05:00
binwiederhier
cc9f9c0d24 Update checker 2026-01-17 20:36:15 -05:00
Philipp C. Heckel
603273ab9d Merge pull request #1552 from binwiederhier/windows-server
Support "ntfy serve" on Windows
2026-01-17 18:12:37 -05:00
13 changed files with 228 additions and 33 deletions

View File

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

View File

@@ -1607,6 +1607,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911))
* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
### ntfy Android app v1.22.x (UNRELEASED)

19
main.go
View File

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

View File

@@ -1,8 +1,12 @@
package server
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io/fs"
"net/netip"
"reflect"
"text/template"
"time"
@@ -179,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
@@ -266,12 +272,31 @@ func NewConfig() *Config {
EnableReservations: false,
RequireLogin: false,
AccessControlAllowOrigin: "*",
Version: "",
WebPushPrivateKey: "",
WebPushPublicKey: "",
WebPushFile: "",
WebPushEmailAddress: "",
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
BuildVersion: "",
BuildDate: "",
}
}
// 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 {
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))
}
}
return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
}

View File

@@ -90,6 +90,7 @@ var (
matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics"
apiHealthPath = "/v1/health"
apiConfigPath = "/v1/config"
apiStatsPath = "/v1/stats"
apiWebPushPath = "/v1/webpush"
apiTiersPath = "/v1/tiers"
@@ -277,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()
@@ -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 == 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 {
@@ -600,8 +603,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor
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,
@@ -615,15 +634,8 @@ 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 {
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)
@@ -991,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))

View File

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

View File

@@ -482,6 +482,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,9 @@
"common_add": "Add",
"common_back": "Back",
"common_copy_to_clipboard": "Copy to clipboard",
"common_refresh": "Refresh",
"version_update_available_title": "New version available",
"version_update_available_description": "The ntfy server has been updated. Please refresh the page.",
"signup_title": "Create a ntfy account",
"signup_form_username": "Username",
"signup_form_password": "Password",

View File

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

View File

@@ -0,0 +1,72 @@
/**
* VersionChecker polls the /v1/config endpoint to detect new server versions
* or configuration changes, prompting users to refresh the page.
*/
const intervalMillis = 5 * 60 * 1000; // 5 minutes
class VersionChecker {
constructor() {
this.initialConfigHash = null;
this.listener = null;
this.timer = 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 || "";
console.log("[VersionChecker] Starting version checker");
this.timer = setInterval(() => this.checkVersion(), intervalMillis);
}
stopWorker() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
console.log("[VersionChecker] Stopped version checker");
}
registerListener(listener) {
this.listener = listener;
}
resetListener() {
this.listener = null;
}
async checkVersion() {
if (!this.initialConfigHash) {
return;
}
try {
const response = await fetch(`${window.config?.base_url || ""}/v1/config`);
if (!response.ok) {
console.log("[VersionChecker] Failed to fetch config:", response.status);
return;
}
const data = await response.json();
const currentHash = data.config_hash;
if (currentHash && currentHash !== this.initialConfigHash) {
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 config:", error);
}
}
}
const versionChecker = new VersionChecker();
export default versionChecker;

View File

@@ -1,23 +1,23 @@
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";
@@ -44,7 +44,7 @@ import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import { SubscriptionPopup } from "./SubscriptionPopup";
import { useNotificationPermissionListener } from "./hooks";
import { useNotificationPermissionListener, useVersionChangeListener } from "./hooks";
const navWidth = 280;
@@ -91,6 +91,13 @@ const NavList = (props) => {
const { account } = useContext(AccountContext);
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
const [versionChanged, setVersionChanged] = useState(false);
const handleVersionChange = () => {
setVersionChanged(true);
};
useVersionChangeListener(handleVersionChange);
const handleSubscribeReset = () => {
setSubscribeDialogOpen(false);
@@ -119,6 +126,7 @@ const NavList = (props) => {
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
const alertVisible =
versionChanged ||
showNotificationPermissionRequired ||
showNotificationPermissionDenied ||
showNotificationIOSInstallRequired ||
@@ -129,6 +137,7 @@ const NavList = (props) => {
<>
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
<List component="nav" sx={{ paddingTop: { xs: 0, sm: alertVisible ? 0 : "" } }}>
{versionChanged && <VersionUpdateBanner />}
{showNotificationPermissionRequired && <NotificationPermissionRequired />}
{showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
@@ -425,4 +434,20 @@ const NotificationContextNotSupportedAlert = () => {
);
};
const VersionUpdateBanner = () => {
const { t } = useTranslation();
const handleRefresh = () => {
window.location.reload();
};
return (
<Alert severity="info" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("version_update_available_title")}</AlertTitle>
<Typography gutterBottom>{t("version_update_available_description")}</Typography>
<Button sx={{ float: "right" }} color="inherit" size="small" onClick={handleRefresh}>
{t("common_refresh")}
</Button>
</Alert>
);
};
export default Navigation;

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