Fixes
build / build (push) Has been cancelled
docs / publish-docs (push) Has been cancelled
test / test (push) Has been cancelled

This commit is contained in:
binwiederhier committed 2026-10-07 14:46:32 +00:00
1 parent b76f3c8356
commit 9c709731df
9 files changed
+123 -124

No files matched your search

+5 -5
View File
@@ -282,22 +282,22 @@ test: cli-test web-test
testv: cli-testv web-test
cli-test: FORCE
go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
go test $(shell go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
cli-testv: FORCE
go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
go test -v $(shell go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
race: FORCE
go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
go test -v -race $(shell go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
coverage:
mkdir -p build/coverage
go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)')
go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)')
go tool cover -func build/coverage/coverage.txt
coverage-html:
mkdir -p build/coverage
go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
go tool cover -html build/coverage/coverage.txt
coverage-upload:
+1 -1
View File
@@ -2699,7 +2699,7 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
<tr><td><code>1 day</code></td><td>12/<b>11</b>/2021, 9am</td><td>24 hours from now</td></tr>
<tr><td><code>10am</code></td><td>12/10/2021, <b>10am</b></td><td>Today at 10am (same day, because it's only 9am)</td></tr>
<tr><td><code>8am</code></td><td>12/<b>11</b>/2021, <b>8am</b></td><td>Tomorrow at 8am (because it's 9am already)</td></tr>
<tr><td><code>2021-12-10T11:00:00-05:00</code></td><td>12/10/2021, <b>11am</b> (EST)</td><td>Absolute RFC3339 timestamp with timezone offset</td></tr>
<tr><td><code>2026-12-10T11:00:00-05:00</code></td><td>12/10/2026, <b>11am</b> (EST)</td><td>Absolute RFC3339 timestamp with timezone offset</td></tr>
<tr><td><code>1639152000</code></td><td>12/10/2021, 11am (EST)</td><td> Today at 11am (EST)</td></tr>
</tbody></table>
</td>
+4 -2
View File
@@ -25,11 +25,11 @@ v2.28.0, plus timezone and RFC 3339 support for delayed notifications.
**Features:**
* Support a timezone for delayed notifications via the `X-Timezone` header, `timezone` query parameter or JSON field, e.g. `At: tomorrow, 10am` with `Timezone: Asia/Tokyo`; the web app sends the browser's timezone automatically ([#1967](https://github.com/binwiederhier/ntfy/pull/1967)/[#1924](https://github.com/binwiederhier/ntfy/issues/1924), thanks to [@beemines](https://github.com/beemines) for the contribution)
* Support RFC 3339 timestamps for delayed notifications, e.g. `Delay: 2026-09-02T10:00:00Z` or `At: 2021-12-10T11:00:00-05:00`, to schedule a message at a precise point in time ([#1931](https://github.com/binwiederhier/ntfy/pull/1931), thanks to [@ALPHACOM-Brehmer](https://github.com/ALPHACOM-Brehmer) for the contribution)
* Support RFC 3339 timestamps for delayed notifications, e.g. `Delay: 2026-12-02T10:00:00Z` or `At: 2026-12-10T11:00:00-05:00`, to schedule a message at a precise point in time ([#1931](https://github.com/binwiederhier/ntfy/pull/1931), thanks to [@ALPHACOM-Brehmer](https://github.com/ALPHACOM-Brehmer) for the contribution)
**Bug fixes + maintenance:**
* Fix messages becoming visible to pollers only seconds later (and publishes stalling) under publish bursts: the message cache writer now stores each batch with a single multi-row `INSERT` instead of one statement per message (on PostgreSQL and SQLite), publishes no longer block while the writer is busy, and the per-publish lookup for a scheduled message with the same sequence ID is skipped when the client did not set one ([#2006](https://github.com/binwiederhier/ntfy/pull/2006))
* Fix messages becoming visible to pollers only seconds later (and publishes stalling) under publish bursts: the message cache writer now stores each batch with a single multi-row `INSERT` instead of one statement per message (on PostgreSQL and SQLite), publishes no longer wait behind each batch write (up to 100 batches are buffered before the writer applies backpressure), and the per-publish lookup for a scheduled message with the same sequence ID is skipped when the client did not set one ([#2006](https://github.com/binwiederhier/ntfy/pull/2006))
* Fix a recurring panic in the account token-update endpoint when a concurrent anonymous request from the same IP raced the authenticated request on the shared visitor (nil-user dereference); the request now fails with HTTP 401
* Fix accepted messages being dropped from the cache on shutdown: the batched-write queue is now flushed, and `ntfy serve` shuts down gracefully on `SIGTERM`/`SIGINT` instead of dying mid-batch
* Keep PostgreSQL pool connections idle instead of re-opening them: `pool_max_idle_conns` now defaults to `pool_max_conns`, so bursts no longer pay a DNS lookup, TCP/TLS handshake and authentication per query
@@ -37,6 +37,8 @@ v2.28.0, plus timezone and RFC 3339 support for delayed notifications.
* Use the PostgreSQL row estimate for the `messages_cached` metric instead of a full-table `COUNT(*)` every minute
* Detect Markdown when the `Content-Type` header has parameters or is uppercase, e.g. `text/markdown; charset=utf-8` ([#1995](https://github.com/binwiederhier/ntfy/pull/1995), thanks to [@cipherprofessor](https://github.com/cipherprofessor) for the contribution)
* Fix the built-in Alertmanager template showing a bogus `0001-01-01` end time for firing alerts and joining a resolved alert's start and end times onto one line ([#1946](https://github.com/binwiederhier/ntfy/pull/1946)/[#1940](https://github.com/binwiederhier/ntfy/issues/1940), thanks to [@justadityaraj](https://github.com/justadityaraj) for the contribution and [@deferred](https://github.com/deferred) for reporting)
* Ship the time zone database with the server (`time/tzdata`), so named time zones for delayed notifications work on the ARM Docker images and other minimal runtimes without system zone files
* Build with Go 1.27; the minimum Go version to build from source is now 1.26
* Groundwork for horizontal scaling: merged the first building blocks for running multiple ntfy servers as a cluster (node registry, util primitives, reserved experimental config options); clustering is not usable yet ([#1991](https://github.com/binwiederhier/ntfy/pull/1991), [#1997](https://github.com/binwiederhier/ntfy/pull/1997), [#2001](https://github.com/binwiederhier/ntfy/pull/2001), [#2003](https://github.com/binwiederhier/ntfy/pull/2003))
### ntfy server v2.28.0
+1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"runtime"
_ "time/tzdata" // Named time zones for delayed messages must work without system zone files (Alpine ARM images)
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/cmd"
+76
View File
@@ -14,6 +14,7 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"path/filepath"
"runtime/debug"
@@ -5664,3 +5665,78 @@ func TestServer_StopIsBoundedWhenAStoreBlocks(t *testing.T) {
t.Fatal("Stop did not return while a store was stuck")
}
}
func TestServer_PublishTimezone(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
for _, zone := range []string{"Asia/Tokyo", "America/New_York", "UTC"} {
for _, transport := range []string{"header", "x-header", "query", "json"} {
t.Run(zone+"/"+transport, func(t *testing.T) {
s := newTestServer(t, newTestConfig(t, databaseURL))
location, err := time.LoadLocation(zone)
require.NoError(t, err)
tomorrow := func() int64 {
now := time.Now().In(location)
return time.Date(now.Year(), now.Month(), now.Day()+1, 10, 0, 0, 0, location).Unix()
}
path, body := "/mytopic", "a message"
headers := map[string]string{"Delay": "tomorrow 10am"}
switch transport {
case "header":
headers["Timezone"] = zone
case "x-header":
headers["X-Timezone"] = zone
case "query":
path += "?timezone=" + url.QueryEscape(zone)
case "json":
path = "/"
body = fmt.Sprintf(`{"topic":"mytopic","message":"a message","delay":"tomorrow 10am","timezone":%q}`, zone)
headers = nil
}
before := tomorrow()
response := request(t, s, "POST", path, body, headers)
require.Equal(t, 200, response.Code, response.Body.String())
message := toMessage(t, response.Body.String())
require.Contains(t, []int64{before, tomorrow()}, message.Time)
})
}
}
})
}
func TestServer_PublishTimezoneRelativeAndUnix(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
for _, delay := range []string{"1h", fmt.Sprint(time.Now().Add(time.Hour).Unix())} {
before := time.Now().Add(time.Hour).Unix()
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"Delay": delay, "Timezone": "Asia/Tokyo",
})
require.Equal(t, 200, response.Code, response.Body.String())
require.InDelta(t, before, toMessage(t, response.Body.String()).Time, 2)
}
})
}
func TestServer_PublishTimezoneInvalid(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
for _, zone := range []string{"Unknown/Timezone", "../etc/passwd", "+09:00"} {
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"Delay": "1h", "Timezone": zone,
})
require.Equal(t, 400, response.Code)
require.Equal(t, errHTTPBadRequestTimezoneInvalid, toHTTPError(t, response.Body.String()))
}
})
}
func TestServer_PublishTimezoneWithoutDelay(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"Timezone": "Unknown/Timezone",
})
require.Equal(t, 200, response.Code)
require.InDelta(t, time.Now().Unix(), toMessage(t, response.Body.String()).Time, 2)
})
}
-85
View File
@@ -1,85 +0,0 @@
package server
import (
"fmt"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestServer_PublishTimezone(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
for _, zone := range []string{"Asia/Tokyo", "America/New_York", "UTC"} {
for _, transport := range []string{"header", "x-header", "query", "json"} {
t.Run(zone+"/"+transport, func(t *testing.T) {
s := newTestServer(t, newTestConfig(t, databaseURL))
location, err := time.LoadLocation(zone)
require.NoError(t, err)
tomorrow := func() int64 {
now := time.Now().In(location)
return time.Date(now.Year(), now.Month(), now.Day()+1, 10, 0, 0, 0, location).Unix()
}
path, body := "/mytopic", "a message"
headers := map[string]string{"Delay": "tomorrow 10am"}
switch transport {
case "header":
headers["Timezone"] = zone
case "x-header":
headers["X-Timezone"] = zone
case "query":
path += "?timezone=" + url.QueryEscape(zone)
case "json":
path = "/"
body = fmt.Sprintf(`{"topic":"mytopic","message":"a message","delay":"tomorrow 10am","timezone":%q}`, zone)
headers = nil
}
before := tomorrow()
response := request(t, s, "POST", path, body, headers)
require.Equal(t, 200, response.Code, response.Body.String())
message := toMessage(t, response.Body.String())
require.Contains(t, []int64{before, tomorrow()}, message.Time)
})
}
}
})
}
func TestServer_PublishTimezoneRelativeAndUnix(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
for _, delay := range []string{"1h", fmt.Sprint(time.Now().Add(time.Hour).Unix())} {
before := time.Now().Add(time.Hour).Unix()
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"Delay": delay, "Timezone": "Asia/Tokyo",
})
require.Equal(t, 200, response.Code, response.Body.String())
require.InDelta(t, before, toMessage(t, response.Body.String()).Time, 2)
}
})
}
func TestServer_PublishTimezoneInvalid(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
for _, zone := range []string{"Unknown/Timezone", "../etc/passwd", "+09:00"} {
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"Delay": "1h", "Timezone": zone,
})
require.Equal(t, 400, response.Code)
require.Equal(t, errHTTPBadRequestTimezoneInvalid, toHTTPError(t, response.Body.String()))
}
})
}
func TestServer_PublishTimezoneWithoutDelay(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"Timezone": "Unknown/Timezone",
})
require.Equal(t, 200, response.Code)
require.InDelta(t, time.Now().Unix(), toMessage(t, response.Body.String()).Time, 2)
})
}
+4 -1
View File
@@ -11,6 +11,7 @@ import (
var (
errInvalidDuration = errors.New("unable to parse duration")
errTimeInPast = errors.New("time is in the past")
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
)
@@ -50,6 +51,8 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) {
t, err = parseRFC3339Time(s, now)
if err == nil {
return t, nil
} else if errors.Is(err, errTimeInPast) {
return time.Time{}, err // Well-formed but expired; do not let the natural-language parser reinterpret it
}
t, err = parseNaturalTime(s, now)
if err == nil {
@@ -65,7 +68,7 @@ func parseRFC3339Time(s string, now time.Time) (time.Time, error) {
return time.Time{}, errInvalidDuration
}
if !t.After(now) {
return time.Time{}, errInvalidDuration
return time.Time{}, errTimeInPast
}
return t, nil
}
+32
View File
@@ -146,3 +146,35 @@ func TestParseFutureTimeRFC3339(t *testing.T) {
"expected %v, got %v", tt.expected, actual)
}
}
func TestParseFutureTime_RFC3339InThePastIsAnError(t *testing.T) {
// A well-formed RFC 3339 timestamp that already passed must be rejected, not handed to the
// natural-language parser (which used to turn it into "tomorrow")
now := time.Date(2026, 10, 7, 14, 0, 0, 0, time.UTC)
_, err := ParseFutureTime("2021-12-10T11:00:00-05:00", now)
require.Error(t, err)
_, err = ParseFutureTime("2026-10-07T13:59:59Z", now)
require.Error(t, err)
}
func TestParseFutureTime_TimezoneDST(t *testing.T) {
location, err := time.LoadLocation("America/New_York")
require.NoError(t, err)
for _, test := range []struct {
name string
now time.Time
want time.Time
}{
{"spring", time.Date(2026, 3, 7, 10, 0, 0, 0, location), time.Date(2026, 3, 8, 14, 0, 0, 0, time.UTC)},
{"fall", time.Date(2026, 10, 31, 10, 0, 0, 0, location), time.Date(2026, 11, 1, 15, 0, 0, 0, time.UTC)},
} {
t.Run(test.name, func(t *testing.T) {
got, err := ParseFutureTime("tomorrow 10am", test.now)
require.NoError(t, err)
require.True(t, test.want.Equal(got), "want %s, got %s", test.want, got)
got, err = ParseFutureTime("24h", test.now)
require.NoError(t, err)
require.Equal(t, 24*time.Hour, got.Sub(test.now))
})
}
}
-30
View File
@@ -1,30 +0,0 @@
package util
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestParseFutureTime_TimezoneDST(t *testing.T) {
location, err := time.LoadLocation("America/New_York")
require.NoError(t, err)
for _, test := range []struct {
name string
now time.Time
want time.Time
}{
{"spring", time.Date(2026, 3, 7, 10, 0, 0, 0, location), time.Date(2026, 3, 8, 14, 0, 0, 0, time.UTC)},
{"fall", time.Date(2026, 10, 31, 10, 0, 0, 0, location), time.Date(2026, 11, 1, 15, 0, 0, 0, time.UTC)},
} {
t.Run(test.name, func(t *testing.T) {
got, err := ParseFutureTime("tomorrow 10am", test.now)
require.NoError(t, err)
require.True(t, test.want.Equal(got), "want %s, got %s", test.want, got)
got, err = ParseFutureTime("24h", test.now)
require.NoError(t, err)
require.Equal(t, 24*time.Hour, got.Sub(test.now))
})
}
}