From 9c709731dfd9e79243e7e23f753ab0b710a0c0c5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 7 Oct 2026 14:46:32 +0000 Subject: [PATCH] Fixes --- Makefile | 10 ++-- docs/publish.md | 2 +- docs/releases.md | 6 ++- main.go | 1 + server/server_test.go | 76 ++++++++++++++++++++++++++++++ server/server_timezone_test.go | 85 ---------------------------------- util/time.go | 5 +- util/time_test.go | 32 +++++++++++++ util/time_timezone_test.go | 30 ------------ 9 files changed, 123 insertions(+), 124 deletions(-) delete mode 100644 server/server_timezone_test.go delete mode 100644 util/time_timezone_test.go diff --git a/Makefile b/Makefile index 71bebebe..e15ad217 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/docs/publish.md b/docs/publish.md index 8b34b4d0..01827e11 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2699,7 +2699,7 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim 1 day12/11/2021, 9am24 hours from now 10am12/10/2021, 10amToday at 10am (same day, because it's only 9am) 8am12/11/2021, 8amTomorrow at 8am (because it's 9am already) - 2021-12-10T11:00:00-05:0012/10/2021, 11am (EST)Absolute RFC3339 timestamp with timezone offset + 2026-12-10T11:00:00-05:0012/10/2026, 11am (EST)Absolute RFC3339 timestamp with timezone offset 163915200012/10/2021, 11am (EST) Today at 11am (EST) diff --git a/docs/releases.md b/docs/releases.md index d3d722a8..acd2d2a6 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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 diff --git a/main.go b/main.go index b9bef369..122356f6 100644 --- a/main.go +++ b/main.go @@ -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" diff --git a/server/server_test.go b/server/server_test.go index 204dbf50..2d288cd7 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -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) + }) +} diff --git a/server/server_timezone_test.go b/server/server_timezone_test.go deleted file mode 100644 index 580a4211..00000000 --- a/server/server_timezone_test.go +++ /dev/null @@ -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) - }) -} diff --git a/util/time.go b/util/time.go index c660d36e..4583e7e2 100644 --- a/util/time.go +++ b/util/time.go @@ -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 } diff --git a/util/time_test.go b/util/time_test.go index cce7f468..8e49614d 100644 --- a/util/time_test.go +++ b/util/time_test.go @@ -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)) + }) + } +} diff --git a/util/time_timezone_test.go b/util/time_timezone_test.go deleted file mode 100644 index d4d3cc11..00000000 --- a/util/time_timezone_test.go +++ /dev/null @@ -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)) - }) - } -}