Compare commits

...
Author SHA1 Message Date
binwiederhier 81a589483f Merge branch 'main' into cluster2 2026-08-04 09:08:46 +02:00
Philipp C. Heckel 4c2b69e059 Merge pull request #1885 from binwiederhier/revert-1882-main
Revert "add tzdata to docker arm build"
2026-08-04 07:24:30 +02:00
Philipp C. Heckel ea6b1ca520 Revert "add tzdata to docker arm build" 2026-08-04 01:24:15 -04:00
binwiederhier 423063893d Bump install notes 2026-08-04 07:05:29 +02:00
Philipp C. Heckel 143b9fb55c Merge pull request #1847 from nexus-uw/patch-2
Add action and template directories to Dockerfile-build
2026-08-04 06:47:50 +02:00
Philipp C. Heckel 3104ad20e2 Merge pull request #1820 from houllette/add-ex-ntfy-library
Add ex_ntfy (Elixir library) to integrations
2026-08-04 06:47:03 +02:00
Philipp C. Heckel 00e3c1351a Merge pull request #1880 from rubixvi/patch-1
remove 404 abandoned project
2026-08-04 06:46:34 +02:00
Philipp C. Heckel c23dc0b30c Merge pull request #1882 from 0xpsyduck/main
add tzdata to docker arm build
2026-08-04 06:46:13 +02:00
Philipp C. Heckel 01af61d228 Merge pull request #1884 from binwiederhier/dependabot/github_actions/all-85123b4e12
Bump docker/login-action from 4.4.0 to 4.5.2 in the all group across 1 directory
2026-08-04 06:40:10 +02:00
binwiederhier 9328f1f3c8 Bump fast-uri 2026-08-03 23:07:09 +02:00
dependabot[bot] 61873b593f Bump docker/login-action in the all group across 1 directory
Bumps the all group with 1 update in the / directory: [docker/login-action](https://github.com/docker/login-action).


Updates `docker/login-action` from 4.4.0 to 4.5.2
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...371161bbe7024a29a25c5e19bfcbc0804fe9ad2c)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 20:41:24 +00:00
binwiederhier 9dc30e4a97 Update GH actions 2026-08-03 22:39:01 +02:00
binwiederhier cd333f130f Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-08-03 18:38:38 +02:00
binwiederhier f42326605e Update deps 2026-08-03 18:38:29 +02:00
binwiederhier 7ed7fea081 Limit memory usage in templates 2026-08-03 18:34:29 +02:00
psyduck 01c13e186a add tzdata to docker arm build 2026-08-03 07:39:54 +00:00
binwiederhier 0ecba37334 Combine message dispatching into a dispatch function 2026-08-03 08:11:51 +02:00
binwiederhier ccfbc2309d Refactor 2026-08-02 23:54:35 +02:00
Vincent Vu fc808db251 remove 404 project
Removed duplicate entry for 'ntfy-desktop' and updated the list of integrations.
2026-08-02 19:22:41 +10:00
binwiederhier 5a6c4277ad WIP: Clsuter support (cross node delivery, leader election) 2026-08-01 16:10:31 +02:00
Philipp C. Heckel dc11655153 Merge pull request #1874 from binwiederhier/schema-compare-tests
Schema compare tests
2026-07-29 08:10:20 +02:00
binwiederhier bd96177fdf Move pg tests to its own file 2026-07-29 08:05:50 +02:00
binwiederhier a3d43190c9 Harden migration 2026-07-29 07:25:12 +02:00
binwiederhier 95ad323d1c Schema compare tests 2026-07-29 07:12:28 +02:00
Philipp C. Heckel e7efdaeb3b Merge pull request #1873 from binwiederhier/user-schema-migration
Use schema/ package in user/ package
2026-07-29 06:45:43 +02:00
binwiederhier 244a9bc06d Use schema/ package in user/ package 2026-07-29 06:23:24 +02:00
Philipp C. Heckel 5e13ca05d5 Merge pull request #1871 from binwiederhier/message-cache-migration2
Use schema migration lib for message cache
2026-07-28 23:02:29 +02:00
Edgars Andersons 0ff1cd5bab Translated using Weblate (Latvian)
Currently translated at 26.7% (123 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/lv/
2026-07-28 14:02:00 +00:00
Vadym Nekhai 4a0f66e258 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2026-07-26 13:02:00 +02:00
Oğuz Ersen 53bbd12cd7 Translated using Weblate (Turkish)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2026-07-26 13:01:54 +02:00
Vadym Nekhai e620fbe95b Translated using Weblate (Ukrainian)
Currently translated at 98.2% (451 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2026-07-24 10:50:01 +02:00
Joker88 b703627d7f Translated using Weblate (Russian)
Currently translated at 88.0% (404 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ru/
2026-07-24 10:50:01 +02:00
Vadym Nekhai 6f4f9e6407 Translated using Weblate (Ukrainian)
Currently translated at 96.9% (445 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2026-07-23 17:01:27 +02:00
Simon Ramsay 07e1260b89 Add action and template directories to Dockerfile-build
Added new directories for action and template to the build process.
2026-07-16 20:47:51 -07:00
dashhrafa af4d85ec03 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt_BR/
2026-07-16 22:01:23 +02:00
Holden Oullette b15213e531 Add ex_ntfy (Elixir library) to integrations 2026-07-05 22:32:25 -06:00
69 changed files with 5035 additions and 1036 deletions
+3 -3
View File
@@ -8,13 +8,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps:
-
name: Checkout ntfy code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
name: Checkout docs pages code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: binwiederhier/ntfy-docs.github.io
path: build/ntfy-docs.github.io
+4 -4
View File
@@ -25,19 +25,19 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
- name: Docker login
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
+3 -3
View File
@@ -25,13 +25,13 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
+3
View File
@@ -48,6 +48,9 @@ ADD ./webpush ./webpush
ADD ./attachment ./attachment
ADD ./mail ./mail
ADD ./s3 ./s3
ADD ./action ./action
ADD ./template/gotext ./template/gotext
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
FROM alpine
+113
View File
@@ -0,0 +1,113 @@
// Package cluster implements cross-node message delivery for a multi-node ntfy cluster. Nodes
// register themselves in a PostgreSQL node registry (control plane) and fan published messages
// out to each other directly over HTTP (data plane); PostgreSQL is never on the message path.
// The single-node default is the nop cluster, which does nothing.
package cluster
import (
"errors"
"net/http"
"time"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/model"
)
// The internal peer API: every kind of node-to-node communication is a path under
// /v1/internal/, served only on the dedicated cluster listener. Future concerns (rate limit
// counters, stats) become new paths or new sections of the state envelope.
const (
// MessagePath receives batches of published messages (NDJSON, one apiMessage per line).
MessagePath = "/v1/internal/message"
// StatePath receives peer state (JSON apiState): full subscription snapshots and
// incremental updates.
StatePath = "/v1/internal/state"
)
// NodeID identifies a cluster node; it keys the registry, the per-peer queues, and the peer
// state table.
//
// Naming convention: a "node" is any cluster member in the absolute sense (identity, registry,
// config); a "peer" is another node as seen from this one (Peers, peerQueue, peerState). A peer
// IS a node, which is why peer values carry a NodeID.
type NodeID string
const (
// secretHeader carries the shared secret authenticating node-to-node fan-out requests.
secretHeader = "X-Cluster-Secret"
// originHeader carries the sending node's ID on fan-out requests, so a node can skip
// requests that carry its own broadcasts (loop prevention).
originHeader = "X-Cluster-Origin"
)
// Content types of the peer API: message bodies are NDJSON (one JSON message per line, matching
// the framing of ntfy's own /topic/json subscribe stream), state bodies are plain JSON. Future
// node-to-node request types get their own paths on the cluster listener; an old node answering
// 404 on an unknown path keeps mixed-version clusters working during rolling deploys.
const (
contentTypeNDJSON = "application/x-ndjson"
contentTypeJSON = "application/json"
)
const (
defaultHeartbeatInterval = 3 * time.Second // How often a node refreshes its registry heartbeat
defaultNodeTTL = 30 * time.Second // A node counts as live if its heartbeat is newer than this; generous to avoid false-dead flapping (see plans)
defaultStateInterval = 15 * time.Second // How often the full subscription state is pushed to peers
// DefaultBatchLinger is how long a fan-out message may wait in a peer's queue for more
// messages to arrive, so they are delivered as one batch. It trades up to this much
// cross-node latency for a bounded request rate per peer.
DefaultBatchLinger = 500 * time.Millisecond
)
// Cluster fans published messages out to peer cluster nodes and receives their fan-out requests.
// Local delivery to a node's own subscribers still happens inline in the server; the cluster
// only covers the cross-node hop.
type Cluster interface {
http.Handler
// ForwardMessage sends a locally published message on to the peer nodes that may have subscribers
// for its topic (all of them, when subscription knowledge is missing or stale). It is
// fire-and-forget and must not block the caller's request path.
ForwardMessage(m *model.Message) error
// BroadcastState pushes a subscription-state delta to ALL peers (unlike ForwardMessage,
// which routes), closing the routing-knowledge window to ~one round trip. Nop single-node.
BroadcastState(state *State)
// IsLeader reports whether this node holds the cluster leader lock. Singleton background
// jobs (e.g. the Firebase keepaliver) are gated on the leader.
IsLeader() bool
// Healthy reports whether this node is fit to serve: its registry heartbeat is fresh
// enough (within NodeTTL) that peers still forward messages to it. Health checkers must
// fail open (never pull ALL nodes): during a full database outage every node reports
// unhealthy while the mesh keeps delivering on stale peer caches.
Healthy() bool
// Close stops the cluster and releases its resources.
Close() error
}
// New creates the cluster for the given config: the nop cluster when clustering is disabled (the
// single-node default), or the peer-mesh cluster otherwise.
func New(conf *Config, pool *db.DB, deliver DeliverFunc, topics TopicsFunc) (Cluster, error) {
if !conf.Enabled {
return &nopCluster{}, nil
}
if pool == nil {
return nil, errors.New("cluster mode requires a PostgreSQL database (set database-url)")
}
if conf.AdvertiseURL == "" {
return nil, errors.New("cluster mode requires an advertise URL (set cluster-advertise-url)")
}
if conf.NodeID == "" {
return nil, errors.New("cluster mode requires a stable node ID (set cluster-node-id)")
}
if conf.HeartbeatInterval == 0 {
conf.HeartbeatInterval = defaultHeartbeatInterval
}
if conf.NodeTTL == 0 {
conf.NodeTTL = defaultNodeTTL
}
if conf.StateInterval == 0 {
conf.StateInterval = defaultStateInterval
}
return newMeshCluster(conf, pool, deliver, topics)
}
+133
View File
@@ -0,0 +1,133 @@
package cluster
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/model"
)
// TestMesh_Soak floods the mesh with concurrent publishers and asserts exact delivery: every
// message reaches the peer exactly once, nothing is dropped, and batching keeps the request
// count far below the message count. Skipped unless NTFY_TEST_SOAK is set (it takes a few
// seconds and is meant for pre-deploy verification, not the regular suite).
func TestMesh_Soak(t *testing.T) {
if os.Getenv("NTFY_TEST_SOAK") == "" {
t.Skip("NTFY_TEST_SOAK not set")
}
// ~1000 msg/s aggregate (10x the ntfy.sh peak of ~88 msg/s): each publisher paces itself to
// 100 msg/s. Unthrottled publishing intentionally overruns the bounded per-peer queue (load
// shedding by design), so a zero-drop assertion only holds below the drain ceiling.
const (
publishers = 10
messagesPerPublisher = 300
publishInterval = 10 * time.Millisecond
total = publishers * messagesPerPublisher
)
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
var mu sync.Mutex
received := make(map[string]int, total) // message body -> count, to catch duplicates
requests := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
messages, err := unmarshalMessageBody(body, 1<<20)
require.Nil(t, err)
mu.Lock()
requests++
for _, m := range messages {
received[m.Message]++
}
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
conf.BatchLinger = 50 * time.Millisecond
conf.NodeTTL = time.Minute // The fake peer never heartbeats; liveness is not under test here
registerFakePeer(t, pool, "node-peer", srv.URL)
mesh, err := newMeshCluster(conf, pool, nil, nil)
require.Nil(t, err)
defer mesh.Close()
start := time.Now()
var wg sync.WaitGroup
for p := 0; p < publishers; p++ {
wg.Add(1)
go func(p int) {
defer wg.Done()
ticker := time.NewTicker(publishInterval)
defer ticker.Stop()
for i := 0; i < messagesPerPublisher; i++ {
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("p%d-m%d", p, i))))
<-ticker.C
}
}(p)
}
wg.Wait()
waitFor(t, func() bool {
mu.Lock()
defer mu.Unlock()
return len(received) == total
})
elapsed := time.Since(start)
mu.Lock()
defer mu.Unlock()
for body, count := range received {
require.Equalf(t, 1, count, "message %s delivered %d times", body, count)
}
require.Less(t, requests, total/10, "expected strong batching under load")
t.Logf("soak: %d messages, %d requests (%.1f msgs/request), %.0f msgs/s",
total, requests, float64(total)/float64(requests), float64(total)/elapsed.Seconds())
}
// BenchmarkForwardMessage measures the publish-path cost of ForwardMessage: marshal + peer lookup (cached)
// + enqueue. The peer never drains, so enqueued fragments are dropped once the queue fills;
// the benchmark measures the hot path, not HTTP delivery.
func BenchmarkForwardMessage(b *testing.B) {
if os.Getenv("NTFY_TEST_DATABASE_URL") == "" {
b.Skip("NTFY_TEST_DATABASE_URL not set")
}
schemaDSN := dbtest.CreateTestPostgresSchema(b)
pool := openTestPool(b, schemaDSN)
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
conf.BatchLinger = time.Minute // Never flush; we measure enqueue only
mesh, err := newMeshCluster(conf, pool, nil, nil)
require.Nil(b, err)
defer mesh.Close()
registerFakePeer(b, pool, "node-peer", "http://127.0.0.1:1")
m := model.NewDefaultMessage("mytopic", "benchmark message body of typical size for a push")
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := mesh.ForwardMessage(m); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkDecodeFanout measures the receive-path cost of decoding a 100-message NDJSON body.
func BenchmarkDecodeFanout(b *testing.B) {
frags := make([][]byte, 100)
for i := range frags {
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("benchmark message %d", i)))
require.Nil(b, err)
frags[i] = frag
}
body := assembleMessageBody(frags)
b.SetBytes(int64(len(body)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
messages, err := unmarshalMessageBody(body, 1<<20)
if err != nil || len(messages) != 100 {
b.Fatal("decode failed")
}
}
}
+498
View File
@@ -0,0 +1,498 @@
package cluster
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"io"
"net/http"
"sync"
"time"
"heckel.io/ntfy/v2/cluster/registry"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/metrics"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
const (
meshHTTPTimeout = 5 * time.Second
peerQueueSize = 1024 // Bounded per-peer fan-out queue (drop on overflow)
batchMaxMessages = 100 // Flush a batch early when it reaches this many messages
batchMaxBytes = 256 * 1024 // Flush a batch early when it reaches this size
stateMaxBytes = 4 * 1024 * 1024 // Upper bound for inbound state bodies (filter over ~1M topics)
stateFilterFPRate = 0.01 // Bloom false-positive rate; a false positive is one wasted send
tag = "cluster"
)
// meshCluster fans messages out directly to peer nodes over HTTP (the data plane), using
// PostgreSQL only as a control plane: the node_registry table for membership/discovery, and a
// Postgres advisory lock for singleton-job leader election. Fan-out never touches the database on
// the message path (only the cached peer list does). See plans/260715-scale-out-mesh.md.
//
// Each peer has its own bounded send queue and delivery worker, so a slow or wedged peer only
// backs up (and eventually drops) its own queue and never delays delivery to healthy peers.
type meshCluster struct {
conf *Config
deliver DeliverFunc
topics TopicsFunc
registry *registry.Registry
leader *pg.Leader
httpClient *http.Client
mux *http.ServeMux // The internal peer API; Cluster is an http.Handler
queues map[NodeID]*peerQueue // per-peer send queues; reconciled against the registry
closed bool // Guards against ForwardMessage spawning new workers after Close
states map[NodeID]*peerState // what each peer last told us (subscription knowledge)
lastStatePush time.Time // Only touched by the heartbeat goroutine
knownPeers map[NodeID]string // Peers seen in the last reconcile, for join/leave logging
lastRegistered time.Time // Last successful registry heartbeat, for Healthy
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
mu sync.Mutex // Protects queues, closed, knownPeers and lastRegistered
statesMu sync.Mutex // Protects states
}
// newMeshCluster creates the mesh cluster: it sets up the registry schema, registers this node
// (synchronously, so it is discoverable before New returns), and starts the heartbeat loop.
// Peer delivery workers are started lazily as peers appear in the registry.
func newMeshCluster(conf *Config, pool *db.DB, deliver DeliverFunc, topics TopicsFunc) (*meshCluster, error) {
if topics == nil {
topics = func() []string { return nil } // No known topics; peers will broadcast to us
}
reg, err := registry.New(pool, string(conf.NodeID), conf.AdvertiseURL, conf.NodeTTL)
if err != nil {
return nil, err
}
// Register synchronously so the node is discoverable before the constructor returns; the
// heartbeat loop refreshes the registration from here on
if err := reg.Register(); err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
c := &meshCluster{
conf: conf,
deliver: deliver,
topics: topics,
registry: reg,
// Renews its lease on its own fixed cadence; see pg.Leader for the semantics
leader: pg.NewLeader(pool.Primary(), pg.LeaderLockKey, conf.LeaderRenewInterval),
httpClient: &http.Client{Timeout: meshHTTPTimeout},
queues: make(map[NodeID]*peerQueue),
lastRegistered: time.Now(), // The synchronous Register above just succeeded
states: make(map[NodeID]*peerState),
knownPeers: make(map[NodeID]string),
ctx: ctx,
cancel: cancel,
}
c.mux = http.NewServeMux()
c.mux.HandleFunc("POST "+MessagePath, c.authenticated(c.handleMessage))
c.mux.HandleFunc("POST "+StatePath, c.authenticated(c.handleState))
c.wg.Add(1)
go c.heartbeatLoop()
return c, nil
}
// ServeHTTP serves the internal peer API. Auth lives in the authenticated middleware, so every
// endpoint gets the same shared-secret and origin handling.
func (c *meshCluster) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.mux.ServeHTTP(w, r)
}
// authenticated wraps a peer API handler with the checks every endpoint needs: the shared
// secret (constant-time compare, rejected before any body is read), a present origin, and the
// origin self-skip (a request carrying this node's own traffic is acknowledged but ignored).
func (c *meshCluster) authenticated(h func(origin NodeID, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if c.conf.Secret == "" || subtle.ConstantTimeCompare([]byte(r.Header.Get(secretHeader)), []byte(c.conf.Secret)) != 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
origin := NodeID(r.Header.Get(originHeader))
if origin == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
if origin == c.conf.NodeID {
w.WriteHeader(http.StatusOK) // Our own traffic; nothing to do
return
}
h(origin, w, r)
}
}
// heartbeatLoop runs one heartbeat immediately (the ticker first fires a full interval after
// startup, and a fresh node should be leader-capable and state-visible right away), then one per
// interval until shutdown.
func (c *meshCluster) heartbeatLoop() {
defer c.wg.Done()
ticker := time.NewTicker(c.conf.HeartbeatInterval)
defer ticker.Stop()
if err := c.heartbeat(); err != nil {
log.Tag(tag).Err(err).Warn("Cluster heartbeat failed")
}
for {
select {
case <-c.ctx.Done():
return
case <-ticker.C:
if err := c.heartbeat(); err != nil {
log.Tag(tag).Err(err).Warn("Cluster heartbeat failed")
}
}
}
}
// heartbeat is one control-plane tick: refresh this node's registry row, retry/confirm the
// leader lock, prune long-dead registry rows (as leader), reconcile the per-peer queues, and
// periodically push our subscription state to peers.
//
// A node that cannot even register itself aborts the tick: the remaining database work would
// fail against the same database, and everything downstream degrades safely without it -- ForwardMessage
// serves the stale peer cache on its own, and peers fall back to broadcasting to us once our
// last pushed state expires.
func (c *meshCluster) heartbeat() error {
if err := c.registry.Register(); err != nil {
return err
}
c.mu.Lock()
c.lastRegistered = time.Now()
c.mu.Unlock()
// Effective leadership: pg.Leader's lease semantics guarantee a no-leader gap on
// failover, never two leaders
if c.leader.IsLeader() {
metrics.ClusterLeader.Set(1)
if err := c.registry.Prune(); err != nil {
log.Tag(tag).Err(err).Warn("Failed to prune stale nodes") // Housekeeping only; not fatal for the tick
}
} else {
metrics.ClusterLeader.Set(0)
}
peers, err := c.registry.Peers()
if err != nil {
return err
}
c.reconcilePeers(peers)
if time.Since(c.lastStatePush) >= c.conf.StateInterval {
c.pushState(peers)
c.lastStatePush = time.Now()
}
return nil
}
// reconcilePeers aligns this node's per-peer attachments with the live peer set: it retires the
// queues (and workers) of peers that have left the registry or re-registered under a new
// advertise URL (the retired queue's remainder was headed for a dead address anyway), and prunes
// the stale state of departed peers. New and replacement queues are created lazily by ForwardMessage, not
// here, so a freshly joined peer is reachable immediately.
func (c *meshCluster) reconcilePeers(peers []*registry.Peer) {
metrics.ClusterPeers.Set(float64(len(peers)))
alive := make(map[NodeID]string, len(peers)) // node ID -> advertise URL
for _, p := range peers {
alive[NodeID(p.NodeID)] = p.AdvertiseURL
}
c.mu.Lock()
// Log joins and leaves (as seen through the up-to-NodeTTL-stale registry view)
for nodeID, url := range alive {
if _, ok := c.knownPeers[nodeID]; !ok {
log.Tag(tag).Info("Peer %s (%s) joined the cluster", nodeID, url)
}
}
for nodeID := range c.knownPeers {
if _, ok := alive[nodeID]; !ok {
log.Tag(tag).Info("Peer %s left the cluster", nodeID)
}
}
c.knownPeers = alive
for nodeID, q := range c.queues {
if url, ok := alive[nodeID]; !ok || q.advertiseURL != url {
q.queue.Close() // Flushes the remainder; the worker exits when the queue is drained
delete(c.queues, nodeID)
}
}
c.mu.Unlock()
// Prune the state of departed peers, but only once stale: state is push-driven and can
// arrive before a new peer is visible in the (up to NodeTTL stale) registry view, so fresh
// state must survive even when its peer is not in the live set. Without this, the states of
// long-gone nodes would accumulate forever.
c.statesMu.Lock()
for nodeID, state := range c.states {
if _, ok := alive[nodeID]; !ok && time.Since(state.updatedAt) > 3*c.conf.StateInterval {
delete(c.states, nodeID)
}
}
c.statesMu.Unlock()
}
// queueFor returns the send queue for the given peer, creating it (and its delivery worker) if it
// does not exist yet. The caller must hold c.mu.
func (c *meshCluster) queueFor(p *registry.Peer) *peerQueue {
nodeID := NodeID(p.NodeID)
q, ok := c.queues[nodeID]
if ok {
return q
}
q = &peerQueue{
advertiseURL: p.AdvertiseURL,
queue: util.NewLingerQueue(peerQueueSize, batchMaxMessages, batchMaxBytes,
func(frag []byte) int { return len(frag) }, c.conf.BatchLinger),
}
c.queues[nodeID] = q
c.wg.Add(1)
go c.peerWorker(nodeID, q)
return q
}
// ForwardMessage enqueues the message for delivery to every live peer node that may have subscribers for
// its topic (all of them, absent fresh knowledge). Delivery is fire-and-forget via each peer's
// bounded batching queue; if a peer's queue is full the message is dropped for that peer
// (subscribers reconnect and re-poll history from the database).
func (c *meshCluster) ForwardMessage(msg *model.Message) error {
peers, err := c.registry.Peers()
if err != nil {
return err
}
if len(peers) == 0 {
return nil // Cluster of one; skip the marshal
}
frag, err := marshalMessage(msg)
if err != nil {
return err
}
metrics.ClusterMessagesForwarded.Inc()
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil // Shutting down; the message is dropped like any other in-flight fan-out
}
for _, p := range peers {
// Route around peers whose fresh state provably excludes this topic; anything less
// certain (no state, stale state) falls back to broadcasting
if !c.mayNeed(NodeID(p.NodeID), msg.Topic) {
metrics.ClusterRouteSkipped.Inc()
if ev := log.Tag(tag); ev.IsTrace() {
ev.Trace("Skipping peer %s for message %s: no subscribers for topic %s", p.NodeID, msg.ID, msg.Topic)
}
continue
}
if !c.queueFor(p).queue.TryEnqueue(frag) {
metrics.ClusterQueueDropped.Inc()
log.Tag(tag).Warn("Fan-out queue for peer %s full, dropping message %s", p.NodeID, msg.ID)
} else if ev := log.Tag(tag); ev.IsTrace() {
ev.Trace("Enqueued message %s (topic %s) for peer %s", msg.ID, msg.Topic, p.NodeID)
}
}
return nil
}
// mayNeed reports whether the peer may have a subscriber for the topic. Conservative by
// construction: it returns false only when a fresh state snapshot provably excludes the topic.
// A false positive costs one wasted send; a false negative would lose a message and cannot
// happen for topics a peer has reported (Bloom filters have no false negatives).
func (c *meshCluster) mayNeed(peer NodeID, topic string) bool {
c.statesMu.Lock()
defer c.statesMu.Unlock()
state, ok := c.states[peer]
if !ok || time.Since(state.updatedAt) > 3*c.conf.StateInterval {
return true // No knowledge, or too old to trust for skipping
}
return state.topics.Contains(topic)
}
// peerWorker delivers batches of queued fan-out messages to a single peer. Batches form in the
// peer's LingerQueue (up to BatchLinger delay, flushed early on size/count caps); the worker
// exits when the queue is closed (peer left the registry, or mesh shutdown) and drained.
func (c *meshCluster) peerWorker(nodeID NodeID, q *peerQueue) {
defer c.wg.Done()
for frags := range q.queue.Dequeue() {
body := assembleMessageBody(frags)
log.Tag(tag).Debug("Sending batch of %d message(s) (%d bytes) to peer %s", len(frags), len(body), nodeID)
c.postToPeer(nodeID, messageURL(q.advertiseURL), contentTypeNDJSON, body)
metrics.ClusterBatchesSent.Inc()
}
}
// postToPeer POSTs a peer API payload, authenticated with the shared cluster secret. Failures
// are logged and counted, never retried: peer traffic is best-effort by design (messages are
// recovered via since= replay, state via the next periodic push).
func (c *meshCluster) postToPeer(nodeID NodeID, url, contentType string, payload []byte) {
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
metrics.ClusterSendErrors.Inc()
log.Tag(tag).Err(err).Warn("Failed to build request for peer %s", nodeID)
return
}
req.Header.Set("Content-Type", contentType)
req.Header.Set(secretHeader, c.conf.Secret)
req.Header.Set(originHeader, string(c.conf.NodeID))
resp, err := c.httpClient.Do(req)
if err != nil {
if c.ctx.Err() == nil {
metrics.ClusterSendErrors.Inc()
log.Tag(tag).Err(err).Warn("Failed to send to peer %s (%s)", nodeID, url)
}
return
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
metrics.ClusterSendErrors.Inc()
log.Tag(tag).Warn("Peer %s (%s) rejected request with HTTP %d", nodeID, url, resp.StatusCode)
}
}
// handleMessage receives a batch of peer messages (NDJSON) and streams them to local
// subscribers line by line, delivering each message as it is decoded.
func (c *meshCluster) handleMessage(origin NodeID, w http.ResponseWriter, r *http.Request) {
// A batch can exceed its byte cap by one message, plus framing overhead
maxBodyBytes := int64(batchMaxBytes) + c.conf.MaxMessageBytes + 1024
received := 0
deliver := func(m *model.Message) {
received++
if ev := log.Tag(tag); ev.IsTrace() {
ev.Trace("Delivering message %s (topic %s) from peer %s", m.ID, m.Topic, origin)
}
c.deliver(m)
}
if err := decodeMessageBody(io.LimitReader(r.Body, maxBodyBytes), int(c.conf.MaxMessageBytes), deliver); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
log.Tag(tag).Debug("Received batch of %d message(s) from peer %s", received, origin)
w.WriteHeader(http.StatusOK)
}
// handleState receives a peer's state envelope and applies each section it carries.
func (c *meshCluster) handleState(origin NodeID, w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, stateMaxBytes))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
var state apiState
if err := json.Unmarshal(body, &state); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if state.Topics != nil {
if err := c.applyTopicState(origin, state.Topics); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
}
w.WriteHeader(http.StatusOK)
}
// applyTopicState updates what we know about a peer's subscriptions: a full snapshot replaces
// all prior knowledge, an incremental add merges into it. Increments without a baseline are
// ignored on purpose -- without a snapshot the peer is broadcast to anyway.
func (c *meshCluster) applyTopicState(origin NodeID, topics *apiStateTopics) error {
c.statesMu.Lock()
defer c.statesMu.Unlock()
if len(topics.Filter) > 0 {
filter, err := util.UnmarshalBloomFilter(topics.Filter)
if err != nil {
return err
}
c.states[origin] = &peerState{topics: filter, updatedAt: time.Now()}
log.Tag(tag).Debug("Received subscription state from peer %s (%d filter bytes)", origin, len(topics.Filter))
return nil
}
if state, ok := c.states[origin]; ok {
for _, topic := range topics.Added {
state.topics.Add(topic)
}
state.updatedAt = time.Now()
log.Tag(tag).Debug("Received %d announced topic(s) from peer %s", len(topics.Added), origin)
}
return nil
}
// pushState sends a full state snapshot to every live peer: a Bloom filter over the topics that
// currently have local subscribers. Sent directly (not via the linger queues -- state must not
// wait behind message batches); a lost push self-heals at the next interval. Topics without
// subscribers disappear simply by not being in the next snapshot.
func (c *meshCluster) pushState(peers []*registry.Peer) {
if len(peers) == 0 {
return
}
topics := c.topics()
filter := util.NewBloomFilter(len(topics), stateFilterFPRate)
for _, topic := range topics {
filter.Add(topic)
}
data, err := filter.MarshalBinary()
if err != nil {
return
}
body, err := json.Marshal(&apiState{Topics: &apiStateTopics{Filter: data}})
if err != nil {
return
}
log.Tag(tag).Debug("Pushing subscription state (%d topics, %d bytes) to %d peer(s)", len(topics), len(body), len(peers))
for _, p := range peers {
go c.postToPeer(NodeID(p.NodeID), stateURL(p.AdvertiseURL), contentTypeJSON, body)
}
metrics.ClusterStatePushes.Inc()
}
// BroadcastState immediately tells all live peers that these topics gained their first local
// subscriber, shrinking the window in which a publisher could wrongly skip this node from a
// full state interval down to about one round trip.
func (c *meshCluster) BroadcastState(state *State) {
if len(state.AddedTopics) == 0 {
return
}
peers, err := c.registry.Peers()
if err != nil || len(peers) == 0 {
return
}
body, err := json.Marshal(&apiState{Topics: &apiStateTopics{Added: state.AddedTopics}})
if err != nil {
return
}
log.Tag(tag).Debug("Broadcasting state (%d new topics) to %d peer(s)", len(state.AddedTopics), len(peers))
for _, p := range peers {
go c.postToPeer(NodeID(p.NodeID), stateURL(p.AdvertiseURL), contentTypeJSON, body)
}
}
// IsLeader reports whether this node currently holds singleton-job leadership.
func (c *meshCluster) IsLeader() bool {
return c.leader.IsLeader()
}
// Healthy reports whether this node's registry heartbeat is fresh enough that peers still
// forward messages to it (see the Cluster interface for the checker's fail-open duty).
func (c *meshCluster) Healthy() bool {
c.mu.Lock()
defer c.mu.Unlock()
return time.Since(c.lastRegistered) < c.conf.NodeTTL
}
// Close stops the mesh: it deregisters this node, releases leadership, stops all peer workers,
// and waits for them to exit.
func (c *meshCluster) Close() error {
c.cancel() // Stops the heartbeat loop and aborts in-flight peer deliveries
// Close the peer queues so their workers flush and exit; final sends are best-effort since
// the context is already canceled (parity with fire-and-forget delivery)
c.mu.Lock()
c.closed = true
for nodeID, q := range c.queues {
q.queue.Close()
delete(c.queues, nodeID)
}
c.mu.Unlock()
// Wait for the loops BEFORE deregistering: an in-flight heartbeat's Register would otherwise
// re-insert our row right after Deregister deleted it
c.wg.Wait()
if err := c.registry.Deregister(); err != nil {
log.Tag(tag).Err(err).Warn("Failed to deregister node")
}
c.leader.Close()
metrics.ClusterLeader.Set(0)
return nil
}
+590
View File
@@ -0,0 +1,590 @@
package cluster
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/cluster/registry"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
const (
testSecret = "s3cret"
)
// openTestPool opens a dedicated connection pool to the given test schema, so that each simulated
// node has its own pool like real nodes would.
func openTestPool(t testing.TB, dsn string) *db.DB {
host, err := pg.Open(dsn)
require.Nil(t, err)
d := db.New(host, nil)
t.Cleanup(func() { d.Close() })
return d
}
func newTestMeshConfig(nodeID, advertiseURL string) *Config {
return &Config{
Enabled: true,
NodeID: NodeID(nodeID),
AdvertiseURL: advertiseURL,
Secret: testSecret,
HeartbeatInterval: 100 * time.Millisecond,
LeaderRenewInterval: 20 * time.Millisecond, // Lease duration 60ms, hold-off 120ms; keeps leadership tests fast
NodeTTL: time.Second, // Also the peer cache bound; short so fake peers registered mid-test are seen quickly
MaxMessageBytes: 1 << 20,
StateInterval: time.Minute, // Individual tests lower this to exercise state pushes
}
}
// registerFakePeer registers a fake peer via the registry (creating the table if the mesh has
// not been constructed yet): tests register fakes before the mesh boots, since its first
// heartbeat caches the peer list. The fake never refreshes its heartbeat.
func registerFakePeer(t testing.TB, pool *db.DB, nodeID NodeID, url string) {
t.Helper()
reg, err := registry.New(pool, string(nodeID), url, time.Minute)
require.Nil(t, err)
require.Nil(t, reg.Register())
}
func waitFor(t *testing.T, f func() bool) {
t.Helper()
for i := 0; i < 100; i++ {
if f() {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("timed out waiting for condition")
}
func TestMesh_CrossNodeDelivery(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
var mu sync.Mutex
var received []*model.Message
var meshB *meshCluster
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
meshB.ServeHTTP(w, r)
}))
defer srvB.Close()
meshB, err := newMeshCluster(newTestMeshConfig("node-b", srvB.URL), poolB, func(m *model.Message) {
mu.Lock()
defer mu.Unlock()
received = append(received, m)
}, nil)
require.Nil(t, err)
defer meshB.Close()
meshA, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), poolA, func(m *model.Message) {
t.Error("node A must not receive its own relayed message")
}, nil)
require.Nil(t, err)
defer meshA.Close()
msg := model.NewDefaultMessage("mytopic", "hello cross-node")
require.Nil(t, meshA.ForwardMessage(msg))
waitFor(t, func() bool {
mu.Lock()
defer mu.Unlock()
return len(received) == 1
})
mu.Lock()
defer mu.Unlock()
require.Equal(t, "mytopic", received[0].Topic)
require.Equal(t, "hello cross-node", received[0].Message)
}
func TestMesh_PeerAPI_Auth(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
var delivered int
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, func(m *model.Message) {
delivered++
}, nil)
require.Nil(t, err)
defer mesh.Close()
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "hi"))
require.Nil(t, err)
payload := assembleMessageBody([][]byte{frag})
// Wrong secret -> 401, not delivered
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
req.Header.Set(secretHeader, "wrong")
req.Header.Set(originHeader, "node-b")
mesh.ServeHTTP(rr, req)
require.Equal(t, 401, rr.Code)
// Missing secret -> 401, not delivered
rr = httptest.NewRecorder()
mesh.ServeHTTP(rr, httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload))))
require.Equal(t, 401, rr.Code)
require.Equal(t, 0, delivered)
// Missing origin -> 400, not delivered
rr = httptest.NewRecorder()
req = httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
req.Header.Set(secretHeader, testSecret)
mesh.ServeHTTP(rr, req)
require.Equal(t, 400, rr.Code)
require.Equal(t, 0, delivered)
// Correct secret and origin -> 200, delivered
rr = httptest.NewRecorder()
req = httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
req.Header.Set(secretHeader, testSecret)
req.Header.Set(originHeader, "node-b")
mesh.ServeHTTP(rr, req)
require.Equal(t, 200, rr.Code)
require.Equal(t, 1, delivered)
}
func TestMesh_PeerAPI_SelfOrigin(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
var delivered int
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, func(m *model.Message) {
delivered++
}, nil)
require.Nil(t, err)
defer mesh.Close()
// A request that carries this node's own broadcasts must not be re-delivered (loop prevention)
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "loop"))
require.Nil(t, err)
payload := assembleMessageBody([][]byte{frag})
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
req.Header.Set(secretHeader, testSecret)
req.Header.Set(originHeader, "node-a") // Same as the receiving node's ID
mesh.ServeHTTP(rr, req)
require.Equal(t, 200, rr.Code)
require.Equal(t, 0, delivered)
}
func TestMesh_SlowPeerIsolation(t *testing.T) {
// A wedged peer must not delay delivery to healthy peers: each peer has its own queue and
// delivery worker. With a shared send queue (the design this replaces), the slow peer's
// requests would occupy all delivery workers and starve the fast peer.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
var mu sync.Mutex
fastReceived := 0 // Messages, not requests: with batching, one request can carry many
srvFast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
messages, err := unmarshalMessageBody(body, 1<<20)
require.Nil(t, err)
mu.Lock()
fastReceived += len(messages)
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer srvFast.Close()
release := make(chan struct{})
srvSlow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release // Wedged until the end of the test
w.WriteHeader(http.StatusOK)
}))
defer srvSlow.Close()
defer close(release)
// Register the fake peers before the mesh boots; its first heartbeat caches the peer list
for i, url := range []string{srvFast.URL, srvSlow.URL} {
registerFakePeer(t, pool, NodeID(fmt.Sprintf("node-fake-%d", i)), url)
}
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
require.Nil(t, err)
defer mesh.Close()
const n = 20
for i := 0; i < n; i++ {
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("message %d", i))))
}
waitFor(t, func() bool {
mu.Lock()
defer mu.Unlock()
return fastReceived == n
})
}
func TestMesh_BatchCoalescing(t *testing.T) {
// Messages published within the linger window arrive as batches: fewer HTTP requests than
// messages, with nothing lost. Fails against a one-request-per-message sender.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
var mu sync.Mutex
requests, messages := 0, 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
decoded, err := unmarshalMessageBody(body, 1<<20)
require.Nil(t, err)
mu.Lock()
requests++
messages += len(decoded)
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
registerFakePeer(t, pool, "node-fake", srv.URL)
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
conf.BatchLinger = 150 * time.Millisecond
mesh, err := newMeshCluster(conf, pool, nil, nil)
require.Nil(t, err)
defer mesh.Close()
const n = 20
for i := 0; i < n; i++ {
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("message %d", i))))
}
waitFor(t, func() bool {
mu.Lock()
defer mu.Unlock()
return messages == n
})
mu.Lock()
defer mu.Unlock()
require.Less(t, requests, 5, "expected %d messages coalesced into few requests, got %d", n, requests)
}
func TestMesh_DeadPeerRemovedAndRejoin(t *testing.T) {
// A peer that dies ungracefully (no Deregister) stops refreshing its heartbeat: after the
// TTL it no longer counts as live (no more sends), its queue/worker are reconciled away, the
// leader prunes its registry row, and a re-registered peer starts receiving again.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
var mu sync.Mutex
received := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
messages, err := unmarshalMessageBody(body, 1<<20)
require.Nil(t, err)
mu.Lock()
received += len(messages)
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
conf.NodeTTL = 300 * time.Millisecond // Fast expiry so the test observes TTL-based removal
mesh, err := newMeshCluster(conf, pool, nil, nil)
require.Nil(t, err)
defer mesh.Close()
// The fake peer registers once and then "dies": its heartbeat is never refreshed
registerFakePeer(t, pool, "node-dead", srv.URL)
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "while alive")))
waitFor(t, func() bool {
mu.Lock()
defer mu.Unlock()
return received == 1
})
// After the TTL, the peer is no longer live: its queue is reconciled away and its registry
// row is pruned by the leader (this mesh is the only real node, so it holds the lock)
waitFor(t, func() bool {
mesh.mu.Lock()
defer mesh.mu.Unlock()
return len(mesh.queues) == 0
})
waitFor(t, func() bool {
var count int
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = 'node-dead'`).Scan(&count))
return count == 0
})
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "while dead")))
time.Sleep(250 * time.Millisecond) // Give a wrong implementation time to deliver anyway
mu.Lock()
require.Equal(t, 1, received) // Only the first message arrived
mu.Unlock()
// The peer comes back (same node ID, fresh heartbeat) and receives messages again; the
// relay retries because the peer list is cached for up to the node TTL
registerFakePeer(t, pool, "node-dead", srv.URL)
waitFor(t, func() bool {
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "after rejoin")))
mu.Lock()
defer mu.Unlock()
return received > 1
})
}
func TestMesh_ForwardAfterClose(t *testing.T) {
// A ForwardMessage racing shutdown (e.g. an in-flight publish during server Stop) must not spawn
// a new peer queue and worker after Close: the worker would never exit (its queue is never
// closed) and nothing waits for it.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
require.Nil(t, err)
registerFakePeer(t, pool, "node-peer", "http://127.0.0.1:1")
require.Nil(t, mesh.Close())
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "too late"))) // Dropped silently
mesh.mu.Lock()
defer mesh.mu.Unlock()
require.Empty(t, mesh.queues)
}
func TestMesh_LeaderFailover(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
meshA, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), poolA, nil, nil)
require.Nil(t, err)
defer meshA.Close()
meshB, err := newMeshCluster(newTestMeshConfig("node-b", "http://127.0.0.1:1"), poolB, nil, nil)
require.Nil(t, err)
defer meshB.Close()
// Exactly one node becomes leader
waitFor(t, func() bool {
return meshA.IsLeader() != meshB.IsLeader() // Exactly one
})
// The leader steps down; the follower takes over
leader, follower := meshA, meshB
if meshB.IsLeader() {
leader, follower = meshB, meshA
}
require.Nil(t, leader.Close())
waitFor(t, follower.IsLeader)
}
func TestMesh_CloseDeregisters(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
require.Nil(t, err)
var count int
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = 'node-a'`).Scan(&count))
require.Equal(t, 1, count)
require.Nil(t, mesh.Close())
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = 'node-a'`).Scan(&count))
require.Equal(t, 0, count)
}
// postState delivers a state envelope to a mesh's peer API, as a peer would.
func postState(c *meshCluster, origin NodeID, state *apiState) *httptest.ResponseRecorder {
body, err := json.Marshal(state)
if err != nil {
panic(err)
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", StatePath, bytes.NewReader(body))
req.Header.Set(secretHeader, testSecret)
req.Header.Set(originHeader, string(origin))
c.ServeHTTP(rr, req)
return rr
}
// topicFilter builds a marshaled Bloom filter over the given topics.
func topicFilter(t *testing.T, topics ...string) []byte {
t.Helper()
filter := util.NewBloomFilter(len(topics), 0.01)
for _, topic := range topics {
filter.Add(topic)
}
data, err := filter.MarshalBinary()
require.Nil(t, err)
return data
}
func TestMesh_RouteSkipsUnsubscribedPeer(t *testing.T) {
// A peer whose fresh state provably excludes a topic is not contacted for it; a topic in its
// state is delivered as usual.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
var mu sync.Mutex
received := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
messages, err := unmarshalMessageBody(body, 1<<20)
require.Nil(t, err)
mu.Lock()
received += len(messages)
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
registerFakePeer(t, pool, "node-b", srv.URL)
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
require.Nil(t, err)
defer mesh.Close()
// node-b reports subscribers only for "subscribed-topic"
rr := postState(mesh, "node-b", &apiState{Topics: &apiStateTopics{Filter: topicFilter(t, "subscribed-topic")}})
require.Equal(t, 200, rr.Code)
// A topic outside the peer's state is skipped
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("other-topic", "skipped")))
time.Sleep(300 * time.Millisecond) // Give a wrong implementation time to deliver anyway
mu.Lock()
require.Equal(t, 0, received)
mu.Unlock()
// A topic inside the peer's state is delivered
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("subscribed-topic", "delivered")))
waitFor(t, func() bool {
mu.Lock()
defer mu.Unlock()
return received == 1
})
}
func TestMesh_RouteBroadcastsOnStaleState(t *testing.T) {
// State too old to trust cannot justify skipping: the peer is broadcast to as if unknown.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
var mu sync.Mutex
received := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == MessagePath { // The mesh also pushes state here; count only messages
mu.Lock()
received++
mu.Unlock()
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
registerFakePeer(t, pool, "node-b", srv.URL)
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
require.Nil(t, err)
defer mesh.Close()
rr := postState(mesh, "node-b", &apiState{Topics: &apiStateTopics{Filter: topicFilter(t, "subscribed-topic")}})
require.Equal(t, 200, rr.Code)
// Age the state beyond the trust window
mesh.statesMu.Lock()
mesh.states["node-b"].updatedAt = time.Now().Add(-time.Hour)
mesh.statesMu.Unlock()
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("other-topic", "broadcast anyway")))
waitFor(t, func() bool {
mu.Lock()
defer mu.Unlock()
return received == 1
})
}
func TestMesh_StatePushReplacesAndRemoves(t *testing.T) {
// Node A periodically pushes a full snapshot of its live topics to node B; each snapshot
// REPLACES B's knowledge, so topics that lost their subscribers disappear without any
// explicit removal protocol.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
var topicsMu sync.Mutex
topicsA := []string{"topic-1"}
source := func() []string {
topicsMu.Lock()
defer topicsMu.Unlock()
return append([]string{}, topicsA...)
}
var meshB *meshCluster
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
meshB.ServeHTTP(w, r)
}))
defer srvB.Close()
meshB, err := newMeshCluster(newTestMeshConfig("node-b", srvB.URL), poolB, nil, nil)
require.Nil(t, err)
defer meshB.Close()
confA := newTestMeshConfig("node-a", "http://127.0.0.1:1")
confA.StateInterval = 200 * time.Millisecond
meshA, err := newMeshCluster(confA, poolA, nil, source)
require.Nil(t, err)
defer meshA.Close()
// B learns A's topics via the periodic push
knows := func(topic string) func() bool {
return func() bool {
meshB.statesMu.Lock()
defer meshB.statesMu.Unlock()
state, ok := meshB.states["node-a"]
return ok && state.topics.Contains(topic)
}
}
waitFor(t, knows("topic-1"))
// A's subscribers change; the next snapshot replaces the old knowledge entirely
topicsMu.Lock()
topicsA = []string{"topic-2"}
topicsMu.Unlock()
waitFor(t, knows("topic-2"))
waitFor(t, func() bool { return !knows("topic-1")() })
}
func TestMesh_AnnounceClosesWindow(t *testing.T) {
// A topic gaining its first subscriber is announced immediately, so peers learn about it
// without waiting for the next full state push.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
var meshB *meshCluster
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
meshB.ServeHTTP(w, r)
}))
defer srvB.Close()
meshB, err := newMeshCluster(newTestMeshConfig("node-b", srvB.URL), poolB, nil, nil)
require.Nil(t, err)
defer meshB.Close()
confA := newTestMeshConfig("node-a", "http://127.0.0.1:1")
confA.StateInterval = 200 * time.Millisecond // One full push establishes the baseline
meshA, err := newMeshCluster(confA, poolA, nil, func() []string { return []string{"existing"} })
require.Nil(t, err)
defer meshA.Close()
waitFor(t, func() bool {
meshB.statesMu.Lock()
defer meshB.statesMu.Unlock()
_, ok := meshB.states["node-a"]
return ok
})
// Announcements merge into the baseline right away
meshA.BroadcastState(&State{AddedTopics: []string{"fresh-topic"}})
waitFor(t, func() bool {
meshB.statesMu.Lock()
defer meshB.statesMu.Unlock()
state, ok := meshB.states["node-a"]
return ok && state.topics.Contains("fresh-topic")
})
}
func TestMesh_StateOfDepartedPeerPruned(t *testing.T) {
// peerState is push-driven and can arrive before the peer is visible in the registry, so it
// must survive reconcile while fresh -- but a departed peer's state must not leak forever:
// once it is both absent from the registry and stale past the trust window, it is pruned.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
require.Nil(t, err)
defer mesh.Close()
rr := postState(mesh, "node-gone", &apiState{Topics: &apiStateTopics{Filter: topicFilter(t, "some-topic")}})
require.Equal(t, 200, rr.Code)
// Fresh state of an unknown peer survives reconcile (the new-node visibility window)
mesh.reconcilePeers(nil)
mesh.statesMu.Lock()
_, ok := mesh.states["node-gone"]
mesh.statesMu.Unlock()
require.True(t, ok)
// Stale state of an absent peer is pruned
mesh.statesMu.Lock()
mesh.states["node-gone"].updatedAt = time.Now().Add(-time.Hour)
mesh.statesMu.Unlock()
mesh.reconcilePeers(nil)
mesh.statesMu.Lock()
_, ok = mesh.states["node-gone"]
mesh.statesMu.Unlock()
require.False(t, ok)
}
func TestMesh_HealthyReflectsRegistration(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
require.Nil(t, err)
defer mesh.Close()
require.True(t, mesh.Healthy()) // Registered synchronously at construction
// Stale heartbeat: peers stop forwarding to this node, so it must report unhealthy
mesh.mu.Lock()
mesh.lastRegistered = time.Now().Add(-2 * mesh.conf.NodeTTL)
mesh.mu.Unlock()
require.False(t, mesh.Healthy())
// A successful heartbeat restores health
require.Nil(t, mesh.heartbeat())
require.True(t, mesh.Healthy())
}
+26
View File
@@ -0,0 +1,26 @@
package cluster
import (
"net/http"
"heckel.io/ntfy/v2/model"
)
// nopCluster is the single-node default: it drops all relayed messages, rejects peer API requests, and
// reports this node as leader (a single node is trivially the leader, so leader-gated jobs need
// no special-casing in single-node mode).
type nopCluster struct{}
func (c *nopCluster) ForwardMessage(_ *model.Message) error { return nil }
func (c *nopCluster) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}
func (c *nopCluster) BroadcastState(_ *State) {}
func (c *nopCluster) IsLeader() bool { return true }
func (c *nopCluster) Healthy() bool { return true }
func (c *nopCluster) Close() error { return nil }
+84
View File
@@ -0,0 +1,84 @@
package cluster
import (
"bytes"
"net/http/httptest"
"net/netip"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/model"
)
func TestDeliver_RoundTrip(t *testing.T) {
// The fan-out body is NDJSON: one apiDeliverMessage per line, joined from pre-marshaled
// fragments; the origin travels in a header, not the body
m1 := model.NewDefaultMessage("mytopic", "my message")
m1.Sender = netip.MustParseAddr("1.2.3.4")
m1.User = "u_abc"
m2 := model.NewDefaultMessage("othertopic", "other message")
frag1, err := marshalMessage(m1)
require.Nil(t, err)
frag2, err := marshalMessage(m2)
require.Nil(t, err)
messages, err := unmarshalMessageBody(assembleMessageBody([][]byte{frag1, frag2}), 1<<20)
require.Nil(t, err)
require.Len(t, messages, 2)
require.Equal(t, "mytopic", messages[0].Topic)
require.Equal(t, "my message", messages[0].Message)
// Sender and User are json:"-" on model.Message; the lines must carry and reattach them
require.Equal(t, netip.MustParseAddr("1.2.3.4"), messages[0].Sender)
require.Equal(t, "u_abc", messages[0].User)
require.Equal(t, "othertopic", messages[1].Topic)
require.False(t, messages[1].Sender.IsValid())
}
func TestDeliver_SingleMessage(t *testing.T) {
// A single message is just a one-line body; there is no separate single-message format
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "hi"))
require.Nil(t, err)
messages, err := unmarshalMessageBody(assembleMessageBody([][]byte{frag}), 1<<20)
require.Nil(t, err)
require.Len(t, messages, 1)
}
func TestDeliver_MalformedLinesSkipped(t *testing.T) {
// Fan-out is fire-and-forget: a malformed or message-less line is skipped (and logged), the
// remaining lines are still delivered
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "good"))
require.Nil(t, err)
body := []byte("this is not json\n{\"sender\":\"1.2.3.4\"}\n" + string(frag) + "\n\n")
messages, err := unmarshalMessageBody(body, 1<<20)
require.Nil(t, err)
require.Len(t, messages, 1)
require.Equal(t, "good", messages[0].Message)
}
// unmarshalMessageBody is a test helper collecting the messages of an NDJSON message body.
func unmarshalMessageBody(body []byte, maxLineBytes int) ([]*model.Message, error) {
var messages []*model.Message
err := decodeMessageBody(bytes.NewReader(body), maxLineBytes, func(m *model.Message) {
messages = append(messages, m)
})
return messages, err
}
func TestNop(t *testing.T) {
b, err := New(&Config{}, nil, nil, nil) // not enabled -> nop cluster, no database required
require.Nil(t, err)
require.IsType(t, &nopCluster{}, b)
require.Nil(t, b.ForwardMessage(model.NewDefaultMessage("mytopic", "hi")))
// A single node is trivially the leader, so leader-gated jobs run without special-casing
require.True(t, b.IsLeader())
require.True(t, b.Healthy())
rr := httptest.NewRecorder()
b.ServeHTTP(rr, httptest.NewRequest("POST", MessagePath, nil))
require.Equal(t, 404, rr.Code)
require.Nil(t, b.Close())
}
func TestNew_EnabledRequiresDatabase(t *testing.T) {
_, err := New(&Config{Enabled: true, Secret: "secret"}, nil, nil, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "database")
}
+149
View File
@@ -0,0 +1,149 @@
// Package registry implements cluster membership: each node upserts its own row into the
// node_registry table with a fresh heartbeat, and discovers its peers by reading the other
// fresh rows. Node IDs are plain strings here; the cluster package layers its NodeID type on
// top.
package registry
import (
"sync"
"time"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
)
// Registry queries
const (
upsertNodeQuery = `
INSERT INTO node_registry (node_id, advertise_url, last_heartbeat)
VALUES ($1, $2, $3)
ON CONFLICT (node_id) DO UPDATE SET advertise_url = EXCLUDED.advertise_url, last_heartbeat = EXCLUDED.last_heartbeat
`
selectPeersQuery = `SELECT node_id, advertise_url FROM node_registry WHERE last_heartbeat >= $1 AND node_id != $2`
pruneStaleNodesQuery = `DELETE FROM node_registry WHERE last_heartbeat < $1`
deleteNodeQuery = `DELETE FROM node_registry WHERE node_id = $1`
)
// Schema version and queries
const (
schemaVersion = 1
schemaStoreKey = "node_registry"
)
var (
createTable = schema.AsMigrateFunc(`
CREATE TABLE IF NOT EXISTS node_registry (
node_id TEXT PRIMARY KEY,
advertise_url TEXT NOT NULL,
last_heartbeat BIGINT NOT NULL
)
`)
)
// Peer is a live remote node as read from the registry.
type Peer struct {
NodeID string
AdvertiseURL string
}
// Registry is the node membership table (control plane): each node upserts its own row with a
// fresh heartbeat every few seconds, and peers are the other rows with a heartbeat newer than
// the TTL. Stale rows are pruned by the leader. The TTL bounds membership staleness in BOTH
// directions: how long a silent node still counts as live, and how long the cached peer list is
// served before a re-read -- so a new node may take up to a TTL to become visible.
type Registry struct {
pool *db.DB
nodeID string
advertiseURL string
ttl time.Duration
peers []*Peer // cached peer list
peersFetched time.Time
mu sync.Mutex // Protects peers and peersFetched
}
// New creates or migrates the registry schema and returns this node's membership handle. It
// does NOT register the node: joining the cluster is an explicit Register call, owned by the
// caller, so read-only uses of the registry stay side-effect free.
func New(pool *db.DB, nodeID, advertiseURL string, ttl time.Duration) (*Registry, error) {
if err := schema.Migrate(pool.Primary(), schema.Postgres, schemaStoreKey, schemaVersion, createTable, nil); err != nil {
return nil, err
}
return &Registry{
pool: pool,
nodeID: nodeID,
advertiseURL: advertiseURL,
ttl: ttl,
}, nil
}
// Register upserts this node into the registry with a fresh heartbeat. It is a pure write: it
// does not touch the peer cache, because our own row is excluded from Peers() anyway.
func (r *Registry) Register() error {
_, err := r.pool.Exec(upsertNodeQuery, r.nodeID, r.advertiseURL, time.Now().Unix())
return err
}
// Peers returns the current set of live peer nodes (all registry rows with a fresh heartbeat,
// excluding this node), cached for the TTL.
func (r *Registry) Peers() ([]*Peer, error) {
r.mu.Lock()
if r.peers != nil && time.Since(r.peersFetched) < r.ttl {
peers := r.peers
r.mu.Unlock()
return peers, nil
}
r.mu.Unlock()
peers, err := r.queryPeers()
if err != nil {
// Serve the last-known peer list during database hiccups: fan-out keeps flowing to
// known peers instead of erroring (and logging) once per published message for the
// duration of the outage. Dead peers in the stale list only cost failed sends.
r.mu.Lock()
defer r.mu.Unlock()
if r.peers != nil {
return r.peers, nil
}
return nil, err
}
r.mu.Lock()
r.peers = peers
r.peersFetched = time.Now()
r.mu.Unlock()
return peers, nil
}
// Prune deletes registry rows whose heartbeat is long expired. Only the leader calls this; the
// grace period of 3x the TTL avoids deleting rows of nodes that are merely slow to heartbeat.
func (r *Registry) Prune() error {
_, err := r.pool.Exec(pruneStaleNodesQuery, time.Now().Add(-3*r.ttl).Unix())
return err
}
// Deregister deletes this node's registry row; called on shutdown.
func (r *Registry) Deregister() error {
_, err := r.pool.Exec(deleteNodeQuery, r.nodeID)
return err
}
// queryPeers reads the current live peer set from the registry table.
func (r *Registry) queryPeers() ([]*Peer, error) {
cutoff := time.Now().Add(-r.ttl).Unix()
rows, err := r.pool.Query(selectPeersQuery, cutoff, r.nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
peers := make([]*Peer, 0)
for rows.Next() {
p := &Peer{}
if err := rows.Scan(&p.NodeID, &p.AdvertiseURL); err != nil {
return nil, err
}
peers = append(peers, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return peers, nil
}
+222
View File
@@ -0,0 +1,222 @@
package registry
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
dbtest "heckel.io/ntfy/v2/db/test"
)
func openTestPool(t *testing.T, dsn string) *db.DB {
t.Helper()
host, err := pg.Open(dsn)
require.Nil(t, err)
d := db.New(host, nil)
t.Cleanup(func() { d.Close() })
return d
}
func TestRegistry_NewDoesNotRegister(t *testing.T) {
// New only sets up the schema and the identity handle; joining the cluster is an explicit
// Register call, owned by the caller (the mesh registers synchronously at construction).
// This keeps read-only uses (ops tooling, future admin endpoints) side-effect free.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
require.Nil(t, err)
require.Equal(t, 0, countRows(t, pool, "node-1"))
require.Nil(t, r1.Register())
require.Equal(t, 1, countRows(t, pool, "node-1"))
}
func TestRegistry_RegisterAndPeers(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
require.Nil(t, err)
require.Nil(t, r1.Register())
r2, err := New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
require.Nil(t, err)
require.Nil(t, r2.Register())
// Each node sees the other, never itself
peers, err := r1.Peers()
require.Nil(t, err)
require.Len(t, peers, 1)
require.Equal(t, "node-2", peers[0].NodeID)
require.Equal(t, "http://10.0.0.2:2587", peers[0].AdvertiseURL)
peers, err = r2.Peers()
require.Nil(t, err)
require.Len(t, peers, 1)
require.Equal(t, "node-1", peers[0].NodeID)
}
func TestRegistry_ReRegisterUpdatesAdvertiseURL(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
require.Nil(t, err)
// The same node comes back under a new address; the upsert replaces the row
old, err := New(pool, "node-2", "http://old:2587", time.Minute)
require.Nil(t, err)
require.Nil(t, old.Register())
renewed, err := New(pool, "node-2", "http://new:2587", time.Minute)
require.Nil(t, err)
require.Nil(t, renewed.Register())
expireCache(r1)
peers, err := r1.Peers()
require.Nil(t, err)
require.Len(t, peers, 1)
require.Equal(t, "http://new:2587", peers[0].AdvertiseURL)
}
func TestRegistry_PeersCachedForTTL(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
require.Nil(t, err)
peers, err := r1.Peers()
require.Nil(t, err)
require.Empty(t, peers)
// A node joining after the cache was populated is invisible until the cache expires
r2, err := New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
require.Nil(t, err)
require.Nil(t, r2.Register())
peers, err = r1.Peers()
require.Nil(t, err)
require.Empty(t, peers)
expireCache(r1)
peers, err = r1.Peers()
require.Nil(t, err)
require.Len(t, peers, 1)
}
func TestRegistry_TTLExcludesSilentNodes(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
require.Nil(t, err)
// A node whose heartbeat is older than the TTL does not count as live
_, err = pool.Exec(upsertNodeQuery, "node-silent", "http://10.0.0.9:2587", time.Now().Add(-2*time.Minute).Unix())
require.Nil(t, err)
peers, err := r1.Peers()
require.Nil(t, err)
require.Empty(t, peers)
}
func TestRegistry_PruneDeletesLongDeadOnly(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
require.Nil(t, err)
// One node beyond the 3x TTL grace period, one merely stale
_, err = pool.Exec(upsertNodeQuery, "node-long-dead", "http://10.0.0.8:2587", time.Now().Add(-4*time.Minute).Unix())
require.Nil(t, err)
_, err = pool.Exec(upsertNodeQuery, "node-slow", "http://10.0.0.9:2587", time.Now().Add(-2*time.Minute).Unix())
require.Nil(t, err)
require.Nil(t, r1.Prune())
require.Equal(t, 0, countRows(t, pool, "node-long-dead"))
require.Equal(t, 1, countRows(t, pool, "node-slow")) // Slow, not dead: kept
}
func TestRegistry_Deregister(t *testing.T) {
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
require.Nil(t, err)
require.Nil(t, r1.Register())
require.Equal(t, 1, countRows(t, pool, "node-1"))
require.Nil(t, r1.Deregister())
require.Equal(t, 0, countRows(t, pool, "node-1"))
}
func TestRegistry_PeersStaleCacheOnError(t *testing.T) {
// During a database hiccup, Peers serves the last-known peer list instead of erroring:
// fan-out keeps flowing to known peers, and the publish path does not log a warning per
// message for the duration of the outage.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
require.Nil(t, err)
r2, err := New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
require.Nil(t, err)
require.Nil(t, r2.Register())
peers, err := r1.Peers()
require.Nil(t, err)
require.Len(t, peers, 1)
// Expire the cache and break the database; the stale list must still be served
expireCache(r1)
require.Nil(t, pool.Close())
peers, err = r1.Peers()
require.Nil(t, err)
require.Len(t, peers, 1)
require.Equal(t, "node-2", peers[0].NodeID)
}
func TestRegistry_ConcurrentCreate(t *testing.T) {
// Multiple nodes cold-booting on a fresh database must not race on table creation: CREATE
// TABLE IF NOT EXISTS is not atomic in PostgreSQL, so creation is serialized via an advisory
// lock. Without it, this test fails sporadically with a duplicate-key error on pg_class.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
const n = 8
errs := make(chan error, n)
for i := 0; i < n; i++ {
go func(i int) {
pool, err := pg.Open(schemaDSN)
if err != nil {
errs <- err
return
}
defer pool.DB.Close()
_, err = New(db.New(pool, nil), fmt.Sprintf("node-%d", i), "http://127.0.0.1:1", time.Second)
errs <- err
}(i)
}
for i := 0; i < n; i++ {
require.Nil(t, <-errs)
}
}
func TestRegistry_SchemaVersionWritten(t *testing.T) {
// The registry participates in the shared schema_version framework like every other store,
// so future table changes can be applied as migrations.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
_, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
require.Nil(t, err)
var version int
require.Nil(t, pool.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, schemaStoreKey).Scan(&version))
require.Equal(t, schemaVersion, version)
// Setup is idempotent: a second node boots against the migrated schema
_, err = New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
require.Nil(t, err)
}
func TestRegistry_SchemaVersionFromTheFuture(t *testing.T) {
// A node running older code must refuse to touch a schema migrated by newer code
schemaDSN := dbtest.CreateTestPostgresSchema(t)
pool := openTestPool(t, schemaDSN)
_, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
require.Nil(t, err)
_, err = pool.Exec(`UPDATE schema_version SET version = 99 WHERE store = $1`, schemaStoreKey)
require.Nil(t, err)
_, err = New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
require.Error(t, err)
}
// expireCache forces the next Peers() call to re-read the registry table.
func expireCache(r *Registry) {
r.mu.Lock()
r.peersFetched = time.Time{}
r.mu.Unlock()
}
func countRows(t *testing.T, pool *db.DB, nodeID string) int {
t.Helper()
var count int
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = $1`, nodeID).Scan(&count))
return count
}
+76
View File
@@ -0,0 +1,76 @@
package cluster
import (
"time"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
// Config configures the cluster. It is assembled by the server from its own config, which keeps
// this package free of server types.
type Config struct {
Enabled bool // Master switch; when false, New returns the nop cluster
NodeID NodeID // Stable per-node identifier; required
AdvertiseURL string // Base URL peers use to reach this node's fan-out endpoint
Secret string // Shared secret authenticating node-to-node fan-out requests
HeartbeatInterval time.Duration // How often the node registry heartbeat is refreshed
NodeTTL time.Duration // Registry rows older than this do not count as live peers
BatchLinger time.Duration // How long messages wait in a peer queue to form a batch; 0 = send immediately
StateInterval time.Duration // How often the full subscription state is pushed to peers
MaxMessageBytes int64 // Upper bound for a single message on the wire (batch limits derive from this)
LeaderRenewInterval time.Duration // Overrides the leader lease renewal cadence; tests only, 0 = default
}
// DeliverFunc hands a message received from a peer node to this node's local subscribers. The
// server supplies it, which inverts the dependency: this package never imports the server.
type DeliverFunc func(m *model.Message)
// State is a subscription-state delta for Cluster.BroadcastState.
type State struct {
AddedTopics []string // Topics that just gained their first local subscriber on this node
}
// TopicsFunc returns the topics that currently have at least one live subscriber, computed
// fresh on every call: membership is never tracked as a list, so topics "leave" simply by not
// appearing in the next snapshot. The server supplies it (same inversion as DeliverFunc).
type TopicsFunc func() []string
// apiMessage is one line of a message request body (NDJSON: one message per line; a single
// message is just a one-line body). It carries the two fields that model.Message does not
// serialize to JSON (Sender and User), which are needed to reconstruct the visitor on the
// receiving node. The origin node travels in a request header, not in the body.
type apiMessage struct {
Sender string `json:"sender,omitempty"`
User string `json:"user,omitempty"`
Message *model.Message `json:"message"`
}
// apiState is the peer state-exchange envelope. Each concern is an optional section; future
// concerns (rate limit counters, stats) become siblings of Topics.
type apiState struct {
Topics *apiStateTopics `json:"topics,omitempty"`
}
// apiStateTopics carries a peer's subscription knowledge: either a full snapshot (Filter, a
// marshaled Bloom filter over the topics with live subscribers) replacing all prior knowledge,
// or an incremental update (Added) merged into it.
type apiStateTopics struct {
Filter []byte `json:"filter,omitempty"`
Added []string `json:"added,omitempty"`
}
// peerState is what a peer last told us about itself; ForwardMessage routes around peers whose
// fresh state provably excludes a topic.
type peerState struct {
topics *util.BloomFilter
updatedAt time.Time
}
// peerQueue is the bounded, batching send queue for a single peer, pinned to the advertise URL
// the peer was created with: a peer re-registering under a different advertise URL is treated
// as a replacement (reconcile retires the old queue; ForwardMessage creates a fresh one on demand).
type peerQueue struct {
advertiseURL string
queue *util.LingerQueue[[]byte] // pre-marshaled apiMessage fragments
}
+69
View File
@@ -0,0 +1,69 @@
package cluster
import (
"bufio"
"bytes"
"encoding/json"
"io"
"net/netip"
"strings"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
)
// messageURL derives the peer's message endpoint URL from its advertise URL.
func messageURL(advertiseURL string) string {
return strings.TrimRight(advertiseURL, "/") + MessagePath
}
// stateURL derives the peer's state endpoint URL from its advertise URL.
func stateURL(advertiseURL string) string {
return strings.TrimRight(advertiseURL, "/") + StatePath
}
// marshalMessage serializes one message and its non-JSON fields (Sender, User) as an
// apiMessage line. Lines are marshaled once per publish and shared across all per-peer
// queues; assembleMessageBody joins them without re-marshaling.
func marshalMessage(m *model.Message) ([]byte, error) {
apiMsg := &apiMessage{User: m.User, Message: m}
if m.Sender.IsValid() {
apiMsg.Sender = m.Sender.String()
}
return json.Marshal(apiMsg)
}
// assembleMessageBody builds an NDJSON fan-out request body from pre-marshaled apiMessage
// lines, avoiding a second JSON marshal of the messages.
func assembleMessageBody(frags [][]byte) []byte {
return append(bytes.Join(frags, []byte("\n")), '\n')
}
// decodeMessageBody reads NDJSON apiMessage lines from r, reattaches the non-JSON fields
// (Sender, User) onto each message, and hands them to deliver. Malformed or message-less lines
// are skipped and logged, not fatal: fan-out is fire-and-forget, so the valid remainder of a
// request is still delivered. It returns an error only for stream-level failures (e.g. a line
// exceeding maxLineBytes).
func decodeMessageBody(r io.Reader, maxLineBytes int, deliver DeliverFunc) error {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), maxLineBytes)
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 {
continue
}
var apiMsg apiMessage
if err := json.Unmarshal(line, &apiMsg); err != nil || apiMsg.Message == nil {
log.Tag(tag).Warn("Skipping malformed fan-out line")
continue
}
apiMsg.Message.User = apiMsg.User
if apiMsg.Sender != "" {
if addr, err := netip.ParseAddr(apiMsg.Sender); err == nil {
apiMsg.Message.Sender = addr
}
}
deliver(apiMsg.Message)
}
return scanner.Err()
}
+40
View File
@@ -19,6 +19,7 @@ import (
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/ban"
"heckel.io/ntfy/v2/cluster"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/server"
@@ -43,6 +44,11 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "database-replica-urls", Aliases: []string{"database_replica_urls"}, EnvVars: []string{"NTFY_DATABASE_REPLICA_URLS"}, Usage: "PostgreSQL read replica connection strings for offloading read queries"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-node-id", Aliases: []string{"cluster_node_id"}, EnvVars: []string{"NTFY_CLUSTER_NODE_ID"}, Usage: "stable per-node identifier for the cluster node registry (required in cluster mode)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-listen", Aliases: []string{"cluster_listen"}, EnvVars: []string{"NTFY_CLUSTER_LISTEN"}, Usage: "ip:port for the dedicated cluster fan-out listener; bind it to the private network (e.g. 10.0.0.5:2587)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-advertise-url", Aliases: []string{"cluster_advertise_url"}, EnvVars: []string{"NTFY_CLUSTER_ADVERTISE_URL"}, Usage: "base URL peer nodes use to reach this node's fan-out listener (defaults to http://<cluster-listen>)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-secret", Aliases: []string{"cluster_secret"}, EnvVars: []string{"NTFY_CLUSTER_SECRET"}, Usage: "shared secret authenticating node-to-node fan-out requests"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-batch-linger", Aliases: []string{"cluster_batch_linger"}, EnvVars: []string{"NTFY_CLUSTER_BATCH_LINGER"}, Value: util.FormatDuration(cluster.DefaultBatchLinger), Usage: "how long fan-out messages wait to form a batch per peer node (0 = send immediately)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}),
@@ -157,6 +163,11 @@ func execServe(c *cli.Context) error {
firebaseKeyFile := c.String("firebase-key-file")
databaseURL := c.String("database-url")
databaseReplicaURLs := c.StringSlice("database-replica-urls")
clusterNodeID := c.String("cluster-node-id")
clusterListen := c.String("cluster-listen")
clusterAdvertiseURL := c.String("cluster-advertise-url")
clusterSecret := c.String("cluster-secret")
clusterBatchLingerStr := c.String("cluster-batch-linger")
webPushPrivateKey := c.String("web-push-private-key")
webPushPublicKey := c.String("web-push-public-key")
webPushFile := c.String("web-push-file")
@@ -252,6 +263,10 @@ func execServe(c *cli.Context) error {
if err != nil {
return fmt.Errorf("invalid keepalive interval: %s", keepaliveIntervalStr)
}
clusterBatchLinger, err := util.ParseDuration(clusterBatchLingerStr)
if err != nil || clusterBatchLinger < 0 {
return fmt.Errorf("invalid cluster batch linger: %s", clusterBatchLingerStr)
}
managerInterval, err := util.ParseDuration(managerIntervalStr)
if err != nil {
return fmt.Errorf("invalid manager interval: %s", managerIntervalStr)
@@ -322,6 +337,16 @@ func execServe(c *cli.Context) error {
return errors.New("if database-url is set, auth-file, cache-file, and web-push-file must not be set")
} else if len(databaseReplicaURLs) > 0 && databaseURL == "" {
return errors.New("database-replica-urls can only be used if database-url is also set")
} else if clusterListen != "" && databaseURL == "" {
return errors.New("cluster-listen requires database-url to be set")
} else if clusterListen != "" && clusterSecret == "" {
return errors.New("cluster-listen requires cluster-secret to be set")
} else if clusterListen != "" && clusterNodeID == "" {
return errors.New("cluster-listen requires cluster-node-id to be set")
} else if clusterListen == "" && clusterSecret != "" {
return errors.New("cluster-secret can only be used if cluster-listen is set")
} else if clusterListen != "" && clusterAdvertiseURL == "" && wildcardAddr(clusterListen) {
return errors.New("cluster-advertise-url must be set if cluster-listen binds a wildcard address")
} else if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
return errors.New("if set, FCM key file must exist")
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {
@@ -559,6 +584,11 @@ func execServe(c *cli.Context) error {
conf.ProfileListenHTTP = profileListenHTTP
conf.DatabaseURL = databaseURL
conf.DatabaseReplicaURLs = databaseReplicaURLs
conf.ClusterNodeID = clusterNodeID
conf.ClusterListen = clusterListen
conf.ClusterAdvertiseURL = clusterAdvertiseURL
conf.ClusterSecret = clusterSecret
conf.ClusterBatchLinger = clusterBatchLinger
conf.WebPushPrivateKey = webPushPrivateKey
conf.WebPushPublicKey = webPushPublicKey
conf.WebPushFile = webPushFile
@@ -737,3 +767,13 @@ func maybeFromMetadata(m map[string]any, key string) string {
}
return s
}
// wildcardAddr reports whether the given listen address binds all interfaces (e.g. ":2587",
// "0.0.0.0:2587", "[::]:2587"), in which case peers cannot derive a reachable URL from it.
func wildcardAddr(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return true // Unparseable -> cannot derive a URL either
}
return host == "" || host == "0.0.0.0" || host == "::"
}
+35
View File
@@ -536,6 +536,41 @@ func TestIP_Host_Parsing(t *testing.T) {
}
}
func TestCLI_Serve_ClusterValidation(t *testing.T) {
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
// Setting cluster-listen implicitly enables clustering, which requires database-url; all
// validation must fail before any database connection is attempted
app, _, _, _ := newTestApp()
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=127.0.0.1:2587"})
require.Error(t, err)
require.Contains(t, err.Error(), "database-url")
// cluster-listen requires cluster-secret
app, _, _, _ = newTestApp()
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=127.0.0.1:2587", "--database-url=postgres://user:pass@localhost:1/na"})
require.Error(t, err)
require.Contains(t, err.Error(), "cluster-secret")
// cluster-listen requires an explicit stable node ID
app, _, _, _ = newTestApp()
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=127.0.0.1:2587", "--database-url=postgres://user:pass@localhost:1/na", "--cluster-secret=s3cret"})
require.Error(t, err)
require.Contains(t, err.Error(), "cluster-node-id")
// cluster-secret without cluster-listen is a config error (clustering would silently be off)
app, _, _, _ = newTestApp()
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-secret=s3cret"})
require.Error(t, err)
require.Contains(t, err.Error(), "cluster-listen")
// A wildcard cluster-listen bind cannot derive an advertise URL
app, _, _, _ = newTestApp()
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=:2587", "--database-url=postgres://user:pass@localhost:1/na", "--cluster-secret=s3cret", "--cluster-node-id=node-a"})
require.Error(t, err)
require.Contains(t, err.Error(), "cluster-advertise-url")
// cluster-batch-linger must not be negative
app, _, _, _ = newTestApp()
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-batch-linger=-1s"})
require.Error(t, err)
require.Contains(t, err.Error(), "cluster batch linger")
}
func newEmptyFile(t *testing.T) string {
filename := filepath.Join(t.TempDir(), "empty")
require.Nil(t, os.WriteFile(filename, []byte{}, 0600))
+163
View File
@@ -0,0 +1,163 @@
package pg
import (
"context"
"database/sql"
"sync"
"time"
"heckel.io/ntfy/v2/log"
)
const (
tagLeader = "leader"
tryAdvisoryLockQuery = `SELECT pg_try_advisory_lock($1)`
advisoryUnlockQuery = `SELECT pg_advisory_unlock($1)`
defaultRenewInterval = 5 * time.Second
leaderMissedRenewals = 3
leaderHoldoffFactor = 2
)
// Leader implements singleton-job leader election via a Postgres advisory lock held on a
// pinned connection. The lock auto-releases when the holding connection dies, so a crashed
// leader is replaced without manual fencing; distinct keys elect independently. The Leader
// renews its lease on its own loop; callers only ask IsLeader and eventually Close.
//
// Holding the lock is not the same as believing to be the leader: IsLeader also requires a
// recent renewal (lease duration) and a completed hold-off after winning the lock. The
// hold-off outlasts the lease duration by construction, so on failover the old belief always
// expires before the new one begins: a short no-leader gap, never two leaders. Defaults:
// renew every 5s, lease duration 15s, hold-off 30s -> up to ~35s without a leader.
type Leader struct {
db *sql.DB
key int64
renewInterval time.Duration
conn *sql.Conn // holds the advisory lock while this process is leader
acquiredAt time.Time // When the lock was won (this tenure), for the hold-off
renewedAt time.Time // Last successful renewal, for the lease duration; zero = lock not held
cancel context.CancelFunc // Stops the renew loop and aborts its in-flight query on Close
closeOnce sync.Once
wg sync.WaitGroup
mu sync.Mutex // Protects conn, acquiredAt and renewedAt
}
// NewLeader creates a Leader competing for the lock identified by key and starts its renew
// loop. renewInterval is for tests; pass 0 for the default.
func NewLeader(db *sql.DB, key int64, renewInterval time.Duration) *Leader {
if renewInterval <= 0 {
renewInterval = defaultRenewInterval
}
ctx, cancel := context.WithCancel(context.Background())
l := &Leader{
db: db,
key: key,
renewInterval: renewInterval,
cancel: cancel,
}
l.wg.Add(1)
go l.runAcquireOrRenewLoop(ctx)
return l
}
// IsLeader reports whether this process should act as the leader: lock held, lease renewed
// recently, hold-off elapsed (see the Leader doc comment).
func (l *Leader) IsLeader() bool {
l.mu.Lock()
defer l.mu.Unlock()
leaseDuration := leaderMissedRenewals * l.renewInterval
holdoff := leaderHoldoffFactor * leaseDuration
return time.Since(l.renewedAt) < leaseDuration && time.Since(l.acquiredAt) >= holdoff
}
// Close stops competing for leadership and releases the lock. Idempotent.
func (l *Leader) Close() {
l.closeOnce.Do(func() {
l.cancel() // Also aborts an in-flight renewal query
l.wg.Wait()
if l.IsLeader() {
log.Tag(tagLeader).Info("Lost leadership: closed (lock key %d)", l.key)
}
l.release()
})
}
// runAcquireOrRenewLoop acquires or renews the lock every renewInterval until ctx is canceled
func (l *Leader) runAcquireOrRenewLoop(ctx context.Context) {
defer l.wg.Done()
ticker := time.NewTicker(l.renewInterval)
defer ticker.Stop()
wasLeader := false
for {
attemptCtx, cancel := context.WithTimeout(ctx, l.renewInterval)
l.tryAcquireOrRenew(attemptCtx)
cancel()
if isLeader := l.IsLeader(); isLeader != wasLeader {
wasLeader = isLeader
if isLeader {
log.Tag(tagLeader).Info("Became leader (lock key %d)", l.key)
} else {
log.Tag(tagLeader).Info("Lost leadership (lock key %d)", l.key)
}
}
select {
case <-ticker.C:
case <-ctx.Done():
return
}
}
}
// tryAcquireOrRenew renews the lock on a healthy leader (a cheap ping) or retries acquiring
// it on a follower, on a pinned connection.
func (l *Leader) tryAcquireOrRenew(ctx context.Context) {
l.mu.Lock()
conn := l.conn
l.mu.Unlock()
if conn != nil {
if conn.PingContext(ctx) == nil {
// Still holding the lock, connection healthy: renew the lease
l.mu.Lock()
l.renewedAt = time.Now()
l.mu.Unlock()
log.Tag(tagLeader).Trace("Renewed leader lease (lock key %d)", l.key)
return
}
log.Tag(tagLeader).Debug("Leader lock connection died, lock lost (lock key %d)", l.key)
l.release() // Connection died; the lock is already gone, re-acquire below
}
newConn, err := l.db.Conn(ctx)
if err != nil {
log.Tag(tagLeader).Debug("Cannot get connection to compete for leader lock (lock key %d): %s", l.key, err.Error())
return
}
var acquired bool
if err := newConn.QueryRowContext(ctx, tryAdvisoryLockQuery, l.key).Scan(&acquired); err != nil || !acquired {
newConn.Close()
log.Tag(tagLeader).Trace("Leader lock held elsewhere (lock key %d)", l.key)
return
}
log.Tag(tagLeader).Debug("Acquired leader lock (lock key %d); leadership after the hold-off", l.key)
l.mu.Lock()
l.conn = newConn
l.acquiredAt = time.Now()
l.renewedAt = l.acquiredAt
l.mu.Unlock()
}
// release unlocks the advisory lock and returns the pinned connection to the pool
func (l *Leader) release() {
l.mu.Lock()
conn := l.conn
l.conn = nil
l.renewedAt = time.Time{} // Zero revokes belief; without it, IsLeader would linger a lease duration
l.mu.Unlock()
if conn != nil {
// Unlock explicitly: sql.Conn.Close() returns the connection to the pool, so the
// session-scoped lock would otherwise stay held
conn.ExecContext(context.Background(), advisoryUnlockQuery, l.key)
conn.Close()
log.Tag(tagLeader).Debug("Released leader lock (lock key %d)", l.key)
}
}
+41
View File
@@ -0,0 +1,41 @@
package pg
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
// The lease logic is pure time arithmetic, so it is unit-tested here without a database; the
// external leader tests cover the loop end to end.
func TestLeader_Lease_HoldoffMeansNoLeaderRatherThanTwo(t *testing.T) {
// Freshly acquired lock: belief must wait out the hold-off
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
l.acquiredAt = time.Now()
l.renewedAt = l.acquiredAt
require.False(t, l.IsLeader())
// Once the hold-off has passed (and verification is fresh), belief begins
l.acquiredAt = time.Now().Add(-3 * time.Minute)
l.renewedAt = time.Now()
require.True(t, l.IsLeader())
}
func TestLeader_Lease_ExpiredLeaseRevokesLeadership(t *testing.T) {
// A leader that cannot renew its lease (wedged process, long GC pause) must stop
// believing once the lease expires, even though the lock may still be held
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
l.acquiredAt = time.Now().Add(-time.Hour)
l.renewedAt = time.Now().Add(-2 * time.Minute) // Lease expired
require.False(t, l.IsLeader())
l.renewedAt = time.Now() // Fresh renewal restores belief
require.True(t, l.IsLeader())
}
func TestLeader_Lease_ReleasedIsNeverLeader(t *testing.T) {
// release() zeroes renewedAt, which fails the lease check no matter how old the tenure
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
l.acquiredAt = time.Now().Add(-time.Hour)
require.False(t, l.IsLeader())
}
+90
View File
@@ -0,0 +1,90 @@
package pg_test
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/db/pg"
dbtest "heckel.io/ntfy/v2/db/test"
)
const testRenewInterval = 20 * time.Millisecond // Lease duration 60ms, hold-off 120ms
func TestLeader_AcquireAndFailover(t *testing.T) {
testDB := dbtest.CreateTestPostgres(t) // skips if NTFY_TEST_DATABASE_URL is unset
const key = int64(42)
l1 := pg.NewLeader(testDB.Primary(), key, testRenewInterval)
defer l1.Close()
// Belief follows the hold-off, it is never instant
require.False(t, l1.IsLeader())
waitForLeader(t, l1)
// A competitor never becomes leader while the leader lives
l2 := pg.NewLeader(testDB.Primary(), key, testRenewInterval)
defer l2.Close()
time.Sleep(300 * time.Millisecond) // Several verification rounds
require.False(t, l2.IsLeader())
require.True(t, l1.IsLeader())
// Close -> the follower takes over
l1.Close()
require.False(t, l1.IsLeader())
waitForLeader(t, l2)
require.False(t, l1.IsLeader())
}
func TestLeader_ConnectionLossFailover(t *testing.T) {
// A crashed leader must not wedge the cluster: Postgres releases the session-scoped lock
// when the pinned connection dies (simulated by terminating the backend), and someone
// re-acquires. Either node may win; the invariant is one leader eventually, never two.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
hostA, err := pg.Open(schemaDSN)
require.Nil(t, err)
defer hostA.DB.Close()
hostB, err := pg.Open(schemaDSN)
require.Nil(t, err)
defer hostB.DB.Close()
const key = int64(43)
l1 := pg.NewLeader(hostA.DB, key, testRenewInterval)
defer l1.Close()
waitForLeader(t, l1)
l2 := pg.NewLeader(hostB.DB, key, testRenewInterval)
defer l2.Close()
// Kill the backend holding the lock (advisory lock keys map to classid/objid)
_, err = hostB.DB.Exec(`SELECT pg_terminate_backend(pid) FROM pg_locks WHERE locktype = 'advisory' AND objid = $1 AND granted`, key)
require.Nil(t, err)
// Eventually exactly one leader again, and never two along the way
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
leader1, leader2 := l1.IsLeader(), l2.IsLeader()
require.False(t, leader1 && leader2, "two leaders at once")
if leader1 != leader2 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("no leader re-emerged after connection loss")
}
func TestLeader_DistinctKeysAreIndependent(t *testing.T) {
testDB := dbtest.CreateTestPostgres(t)
l1 := pg.NewLeader(testDB.Primary(), 1, testRenewInterval)
defer l1.Close()
l2 := pg.NewLeader(testDB.Primary(), 2, testRenewInterval)
defer l2.Close()
// Different keys do not compete: both become effective leaders
waitForLeader(t, l1)
waitForLeader(t, l2)
}
// waitForLeader waits until the node believes it is the leader, or fails the test
func waitForLeader(t *testing.T, l *pg.Leader) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if l.IsLeader() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("node never became effective leader")
}
+1
View File
@@ -17,6 +17,7 @@ import (
// ntfy key is defined here, following the "ntfy"+2586+letter scheme
const (
SchemaLockKey = int64(0x6e7466792586a) // Schema setup serialization (transaction-scoped, see db/schema)
LeaderLockKey = int64(0x6e7466792586b) // Cluster singleton-job leader (session-scoped, held for process lifetime)
)
// Open opens a PostgreSQL connection pool for a primary database. It pings the database
+3 -1
View File
@@ -14,7 +14,9 @@ import (
_ "github.com/mattn/go-sqlite3"
)
const testCreateQuery = `CREATE TABLE IF NOT EXISTS things (id TEXT PRIMARY KEY, name TEXT NOT NULL)`
const (
testCreateQuery = `CREATE TABLE IF NOT EXISTS things (id TEXT PRIMARY KEY, name TEXT NOT NULL)`
)
func testCreate(tx *sql.Tx) error {
_, err := tx.Exec(testCreateQuery)
+185
View File
@@ -0,0 +1,185 @@
package dbtest
import (
"database/sql"
"fmt"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// Querier is the subset of *sql.DB / *db.DB needed to introspect a schema.
type Querier interface {
Query(query string, args ...any) (*sql.Rows, error)
}
// SQLiteSchema returns a normalized, comparable description of the database schema: tables
// with their columns, named indexes, and foreign keys. Column order, declared type spelling
// (INT vs INTEGER) and default values are not part of the description, so the schema produced
// by a migration chain can be compared to a freshly created one.
func SQLiteSchema(t testing.TB, d Querier) string {
t.Helper()
lines := make([]string, 0)
for _, table := range sqliteTables(t, d) {
lines = append(lines, "table "+table)
lines = append(lines, sqliteColumns(t, d, table)...)
lines = append(lines, sqliteForeignKeys(t, d, table)...)
lines = append(lines, sqliteIndexes(t, d, table)...)
}
return strings.Join(lines, "\n")
}
// PostgresSchema is SQLiteSchema's PostgreSQL counterpart, describing the current schema's
// tables, columns, constraints and indexes in a normalized, comparable way.
func PostgresSchema(t testing.TB, d Querier) string {
t.Helper()
lines := make([]string, 0)
for _, table := range postgresTables(t, d) {
lines = append(lines, "table "+table)
lines = append(lines, postgresColumns(t, d, table)...)
}
lines = append(lines, postgresConstraints(t, d)...)
lines = append(lines, postgresIndexes(t, d)...)
return strings.Join(lines, "\n")
}
func sqliteTables(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`)
}
func sqliteColumns(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA table_info(%q)`, table))
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var cid, notNull, pk int
var name, typ string
var dflt sql.NullString
require.Nil(t, rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk))
typ = strings.ToUpper(typ)
if typ == "INT" { // INT and INTEGER are the same affinity; migrations spell them inconsistently
typ = "INTEGER"
}
lines = append(lines, fmt.Sprintf(" col %s %s notnull=%d pk=%d", name, typ, notNull, pk))
}
require.Nil(t, rows.Err())
sort.Strings(lines)
return lines
}
func sqliteForeignKeys(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA foreign_key_list(%q)`, table))
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var id, seq int
var refTable, from, onUpdate, onDelete, match string
var to sql.NullString // NULL when referencing the parent's primary key implicitly
require.Nil(t, rows.Scan(&id, &seq, &refTable, &from, &to, &onUpdate, &onDelete, &match))
lines = append(lines, fmt.Sprintf(" fk %s -> %s(%s) on_delete=%s", from, refTable, to.String, onDelete))
}
require.Nil(t, rows.Err())
sort.Strings(lines)
return lines
}
func sqliteIndexes(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA index_list(%q)`, table))
require.Nil(t, err)
type index struct {
name string
unique, partial int
}
indexes := make([]index, 0)
for rows.Next() {
var seq, unique, partial int
var name, origin string
require.Nil(t, rows.Scan(&seq, &name, &unique, &origin, &partial))
// Skip auto-indexes backing PRIMARY KEY/UNIQUE table constraints; those are described
// by the column and constraint listings already
if strings.HasPrefix(name, "sqlite_autoindex_") {
continue
}
indexes = append(indexes, index{name, unique, partial})
}
require.Nil(t, rows.Err())
require.Nil(t, rows.Close())
lines := make([]string, 0, len(indexes))
for _, idx := range indexes {
cols := queryStrings(t, d, fmt.Sprintf(`SELECT name FROM pragma_index_info(%q) ORDER BY seqno`, idx.name))
lines = append(lines, fmt.Sprintf(" index %s unique=%d partial=%d cols=(%s)", idx.name, idx.unique, idx.partial, strings.Join(cols, ",")))
}
sort.Strings(lines)
return lines
}
func postgresTables(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name`)
}
func postgresColumns(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 ORDER BY column_name`, table)
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var name, typ, nullable string
require.Nil(t, rows.Scan(&name, &typ, &nullable))
lines = append(lines, fmt.Sprintf(" col %s %s nullable=%s", name, typ, nullable))
}
require.Nil(t, rows.Err())
return lines
}
func postgresConstraints(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `
SELECT 'constraint ' || conrelid::regclass::text || ': ' || pg_get_constraintdef(oid)
FROM pg_constraint
WHERE connamespace = current_schema()::regnamespace
ORDER BY 1
`)
}
func postgresIndexes(t testing.TB, d Querier) []string {
t.Helper()
rows, err := d.Query(`SELECT indexname, indexdef, schemaname FROM pg_indexes WHERE schemaname = current_schema() ORDER BY indexname`)
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var name, def, schema string
require.Nil(t, rows.Scan(&name, &def, &schema))
// The index definition qualifies the table with the (test-specific) schema name; strip
// it so snapshots from different test schemas compare equal
def = strings.ReplaceAll(def, schema+".", "")
lines = append(lines, "index "+def)
}
require.Nil(t, rows.Err())
return lines
}
func queryStrings(t testing.TB, d Querier, query string) []string {
t.Helper()
rows, err := d.Query(query)
require.Nil(t, err)
defer rows.Close()
values := make([]string, 0)
for rows.Next() {
var value string
require.Nil(t, rows.Scan(&value))
values = append(values, value)
}
require.Nil(t, rows.Err())
return values
}
+2 -2
View File
@@ -17,7 +17,7 @@ const testPoolMaxConns = "2"
// CreateTestPostgresSchema creates a temporary PostgreSQL schema and returns the DSN pointing to it.
// It registers a cleanup function to drop the schema when the test finishes.
// If NTFY_TEST_DATABASE_URL is not set, the test is skipped.
func CreateTestPostgresSchema(t *testing.T) string {
func CreateTestPostgresSchema(t testing.TB) string {
t.Helper()
dsn := os.Getenv("NTFY_TEST_DATABASE_URL")
if dsn == "" {
@@ -51,7 +51,7 @@ func CreateTestPostgresSchema(t *testing.T) string {
// CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *db.DB connection to it.
// It registers cleanup functions to close the DB and drop the schema when the test finishes.
// If NTFY_TEST_DATABASE_URL is not set, the test is skipped.
func CreateTestPostgres(t *testing.T) *db.DB {
func CreateTestPostgres(t testing.TB) *db.DB {
t.Helper()
schemaDSN := CreateTestPostgresSchema(t)
testHost, err := pg.Open(schemaDSN)
+38 -38
View File
@@ -34,37 +34,37 @@ as a service starting at boot time.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_amd64.tar.gz
tar zxvf ntfy_2.26.3_linux_amd64.tar.gz
sudo cp -a ntfy_2.26.3_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_amd64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_amd64.tar.gz
tar zxvf ntfy_2.27.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.27.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv6.tar.gz
tar zxvf ntfy_2.26.3_linux_armv6.tar.gz
sudo cp -a ntfy_2.26.3_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.tar.gz
tar zxvf ntfy_2.27.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.27.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv7.tar.gz
tar zxvf ntfy_2.26.3_linux_armv7.tar.gz
sudo cp -a ntfy_2.26.3_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.tar.gz
tar zxvf ntfy_2.27.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.27.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_arm64.tar.gz
tar zxvf ntfy_2.26.3_linux_arm64.tar.gz
sudo cp -a ntfy_2.26.3_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.tar.gz
tar zxvf ntfy_2.27.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.27.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.26.3_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.27.0_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv6"
```bash
sudo mv ntfy_2.26.3_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.27.0_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.26.3_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.27.0_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "arm64"
```bash
sudo mv ntfy_2.26.3_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.27.0_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
@@ -118,25 +118,25 @@ Install the ntfy server service script:
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.26.3_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.27.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv6"
```bash
sudo mv ntfy_2.26.3_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.27.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.26.3_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.27.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "arm64"
```bash
sudo mv ntfy_2.26.3_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.27.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
@@ -204,7 +204,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -212,7 +212,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -220,7 +220,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -228,7 +228,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -238,28 +238,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -301,18 +301,18 @@ pkg install go-ntfy
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_darwin_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_darwin_all.tar.gz > ntfy_2.26.3_darwin_all.tar.gz
tar zxvf ntfy_2.26.3_darwin_all.tar.gz
sudo cp -a ntfy_2.26.3_darwin_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_darwin_all.tar.gz > ntfy_2.27.0_darwin_all.tar.gz
tar zxvf ntfy_2.27.0_darwin_all.tar.gz
sudo cp -a ntfy_2.27.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.26.3_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.27.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -333,7 +333,7 @@ brew install ntfy
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
To install, you can either
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_windows_amd64.zip),
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
+1 -1
View File
@@ -84,13 +84,13 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP)
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
- [aiontfy](https://github.com/tr4nt0r/aiontfy) - Asynchronous client library for publishing and subscribing to ntfy (Python)
- [ex_ntfy](https://github.com/houllette/ex_ntfy) - Elixir SDK covering publishing, polling, and streaming subscriptions for ntfy servers (Elixir)
## CLIs + GUIs
- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events
- [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
- [ntfy Desktop (Windows)](https://github.com/simoneferrari/ntfy-desktop) - Native Windows desktop client with multi-server support, toast notifications and message history, built with WPF and .NET (C#)
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop
+4 -1
View File
@@ -3227,7 +3227,10 @@ your templates there first ([example for Grafana alert](https://repeatit.io/#/sh
!!! info
A few Go template features are disabled for user-supplied templates: `{{define}}`, `{{template}}`,
`{{block}}`, and `{{call}}` are not allowed. Templates also run with a short execution time limit --
a template that loops too long is stopped and rejected with an HTTP 400 error.
a template that loops too long is stopped and rejected with an HTTP 400 error. Templates are
limited to 32 KB in size, `printf` widths and precisions must be below 1000 (`%999d` is
allowed, `%1000d` is not), including the `%*d` form that takes the width from an argument, and
`indent`/`nindent` are limited to 100 spaces.
### Template functions
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
+28 -17
View File
@@ -6,12 +6,39 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| Component | Version | Release date |
|------------------|---------|---------------|
| ntfy server | v2.26.3 | Jul 20, 2026 |
| ntfy server | v2.27.0 | Aug 4, 2026 |
| ntfy Android app | v1.25.2 | July 23, 2026 |
| ntfy iOS app | v1.7.0 | May 30, 2026 |
Please check out the release notes for [upcoming releases](#not-released-yet) below.
### ntfy server v2.27.0
Released August 4, 2026
This release lets you sign in with your verified email address instead of your username, which should help if you ever
signed up with an email and then forgot which username you picked. It also hardens the message templating engine against
a few ways a small template could eat a lot of memory, and it drops the "experimental" label from
[PostgreSQL support](config.md#postgresql), which has been running ntfy.sh for a while now.
I also did a bunch of refactoring in, mostly in preparation for being able to cluster ntfy nodes and scale the service
horizontally. It'll be a while until then, ... baby steps.
**Security:**
* Limit message templates (`Template: yes`) to 32 KB, limit `printf` widths and precisions to below 1000, and limit `indent`/`nindent` to 100 spaces, preventing excessive memory use from a single small template
* Exclude secrets from the config hash served to the web app, preventing a rather theoretical information leak
**Features:**
* Allow logging in with your verified primary email address (in addition to your username), so a password reset no longer leaves you unable to sign in when you only remember the email you signed up with
**Bug fixes + maintenance:**
* Fix Twilio phone calls and phone number verifications failing silently when Twilio rejected the request, and move the Twilio integration into its own `twilio` package
* Move the Prometheus metrics into a dedicated `metrics` package
* Message cache databases from ntfy older than v1.10.0 (November 2021) can no longer be migrated; upgrade via an older ntfy version first, or delete the cache database
* Fix `user_phone` table in the SQLite user database referencing a dropped table after the v2.14 schema migration; repaired automatically by a new migration
## ntfy Android v1.25.2
Released July 23, 2026
@@ -2051,22 +2078,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.27.0 (UNRELEASED)
**Security:**
* Exclude secrets (Stripe/Twilio/web push keys, SMTP password, provisioned users and tokens) from the config hash served to the web app
**Features:**
* Allow logging in with your verified primary email address (in addition to your username), so a password reset no longer leaves you unable to sign in when you only remember the email you signed up with
**Bug fixes + maintenance:**
* Fix Twilio phone calls and phone number verifications failing silently when Twilio rejected the request, and move the Twilio integration into its own `twilio` package
* Move the Prometheus metrics into a dedicated `metrics` package
* Message cache databases from ntfy older than v1.10.0 (November 2021) can no longer be migrated; upgrade via an older ntfy version first, or delete the cache database
### ntfy iOS app v1.8.0 (UNRELEASED)
**Features:**
+28 -28
View File
@@ -3,23 +3,23 @@ module heckel.io/ntfy/v2
go 1.25.8
require (
cloud.google.com/go/firestore v1.22.0 // indirect
cloud.google.com/go/storage v1.63.0 // indirect
cloud.google.com/go/firestore v1.24.0 // indirect
cloud.google.com/go/storage v1.64.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/emersion/go-smtp v0.24.0
github.com/gabriel-vasile/mimetype v1.4.13
github.com/gabriel-vasile/mimetype v1.4.15
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.47
github.com/mattn/go-sqlite3 v1.14.49
github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.53.0
golang.org/x/crypto v0.54.0
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.21.0
golang.org/x/term v0.44.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
golang.org/x/time v0.15.0
google.golang.org/api v0.287.0
google.golang.org/api v0.291.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -28,29 +28,29 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.20.0
firebase.google.com/go/v4 v4.21.0
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/jackc/pgx/v5 v5.10.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_golang v1.24.1
github.com/stripe/stripe-go/v74 v74.30.0
golang.org/x/sys v0.46.0
golang.org/x/text v0.39.0
golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0
)
require (
cel.dev/expr v0.25.2 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth v0.22.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.11.0 // indirect
cloud.google.com/go/longrunning v1.1.0 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect
cloud.google.com/go/iam v1.12.0 // indirect
cloud.google.com/go/longrunning v1.2.0 // indirect
cloud.google.com/go/monitoring v1.30.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -62,15 +62,15 @@ require (
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -79,7 +79,7 @@ require (
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.69.0 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
@@ -94,12 +94,12 @@ require (
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/net v0.57.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/grpc v1.82.0 // indirect
google.golang.org/genproto v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/grpc v1.83.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+62 -62
View File
@@ -2,40 +2,40 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ=
cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
cloud.google.com/go/longrunning v1.1.0 h1:qJ0R0IA8ONaRCNWTRPAS0iAmt1bj3TVgJ40z7ZGRslE=
cloud.google.com/go/longrunning v1.1.0/go.mod h1:tH+A/6UvNypiPJWAQaKCsh+xiGbB23wUO8egwUXlD2E=
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ=
cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE=
cloud.google.com/go/firestore v1.24.0 h1:x0Z3hrgjYgo2wI9whuBRQcNc2hYwzZDQy/7pkUXbXcs=
cloud.google.com/go/firestore v1.24.0/go.mod h1:5aojyjN4olKUnBZDCRWwM+NsdrrCX3t1qfyERZGOonM=
cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk=
cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY=
cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q=
cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA=
cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM=
cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0=
cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY=
cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM=
cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSPZg=
cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ=
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU=
firebase.google.com/go/v4 v4.20.0/go.mod h1:hqhkQtZkThGH42TnaYi7A8EFR1E0FEuB5oHvJ1Q57t8=
firebase.google.com/go/v4 v4.21.0 h1:HBZV4jrLtFYj8EwWyqEZOuRLfkfkV2bpnfyyXHOhPxY=
firebase.google.com/go/v4 v4.21.0/go.mod h1:CDumIdA5oTiyDpLNVcQoW8ZrB5CTgyE2D45DuENIABg=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 h1:c/Ivw7FuawPLfrr+zB0LZKeCchO2cAHQpF2qZ6OV7rQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0/go.mod h1:Zba7lknY/d78oxbKqFTmCsaGwfpzeJ3ktrrLXtnTV6g=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0 h1:xTXsqDOj5k9mK3VVWHYUryryJCIdYfXxdjKFwpzINUw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 h1:18FRm6ZcN/x9+ZmhMr96hLcTtlLn2/gHPuDLVeg7XcY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
@@ -68,13 +68,13 @@ github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMD
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -96,10 +96,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4=
github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE=
github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -112,16 +112,16 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -135,12 +135,12 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -195,8 +195,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -211,8 +211,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -222,8 +222,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -236,8 +236,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -247,8 +247,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -260,8 +260,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -274,18 +274,18 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4=
google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ=
google.golang.org/api v0.291.0 h1:wfPbbY+mr9c7wZLqqzrHJLft/q8iFKREd6IgTBUene0=
google.golang.org/api v0.291.0/go.mod h1:at7kwWbuonglBFEBoeMDAV1bguHqL3qf0BHFsv3coa0=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 h1:lQG76ePMKmtujel4VIVMiFoHVWVNtJdawbCZJtWlVXU=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7/go.mod h1:LwlOWYBU335L+sR55UuR5fbbU8KmEX+3tUHf3SwMmhM=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/genproto v0.0.0-20260803160001-6ac0973c030d h1:33JLrUF0lFT31667SAtJzZAjLewV0ew5Mizks4caz0A=
google.golang.org/genproto v0.0.0-20260803160001-6ac0973c030d/go.mod h1:I7vGRdTamb7ukERkgP9I+0e4p21O4ak3cM7ICA3krg8=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+36
View File
@@ -35,6 +35,7 @@ type queries struct {
selectMessagesSinceIDScheduled string
selectMessagesLatest string
selectMessagesDue string
selectMessagesDueForUpdate string // Postgres-only: claims due rows via FOR UPDATE SKIP LOCKED; empty for SQLite/mem
deleteExpiredMessages string
updateMessagePublished string
selectMessagesCount string
@@ -238,6 +239,15 @@ func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) {
// MessagesDue returns all messages that are due for publishing
func (c *Cache) MessagesDue() ([]*model.Message, error) {
// On Postgres (cluster mode), claim due rows atomically so that concurrent delayed senders
// on other nodes cannot pick up the same message. We SELECT ... FOR UPDATE SKIP LOCKED and
// mark the claimed rows published in the same transaction; each row is thus handed to exactly
// one node. We deliberately mark published at claim time (not after delivery) to keep the row
// lock short: holding a transaction open across Firebase/WebPush/email delivery would be far
// worse than the small at-most-once window if a node crashes between claim and delivery.
if c.queries.selectMessagesDueForUpdate != "" {
return c.claimMessagesDue()
}
rows, err := c.db.Query(c.queries.selectMessagesDue, time.Now().Unix())
if err != nil {
return nil, err
@@ -245,6 +255,32 @@ func (c *Cache) MessagesDue() ([]*model.Message, error) {
return readMessages(rows)
}
// claimMessagesDue is the Postgres claiming path for MessagesDue (see its comment).
func (c *Cache) claimMessagesDue() ([]*model.Message, error) {
tx, err := c.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
rows, err := tx.Query(c.queries.selectMessagesDueForUpdate, time.Now().Unix())
if err != nil {
return nil, err
}
messages, err := readMessages(rows) // reads all rows and closes them
if err != nil {
return nil, err
}
for _, m := range messages {
if _, err := tx.Exec(c.queries.updateMessagePublished, m.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return messages, nil
}
// DeleteExpiredMessages deletes up to `limit` expired messages in a single query
// and returns the number of deleted rows.
func (c *Cache) DeleteExpiredMessages(limit int) (int64, error) {
+8
View File
@@ -61,6 +61,13 @@ const (
WHERE time <= $1 AND published = FALSE
ORDER BY time, id
`
postgresSelectMessagesDueForUpdateQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
FROM message
WHERE time <= $1 AND published = FALSE
ORDER BY time, id
FOR UPDATE SKIP LOCKED
`
postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1`
postgresSelectMessagesCountQuery = `SELECT COUNT(*) FROM message`
postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic`
@@ -88,6 +95,7 @@ var postgresQueries = queries{
selectMessagesSinceIDScheduled: postgresSelectMessagesSinceIDIncludeScheduledQuery,
selectMessagesLatest: postgresSelectMessagesLatestQuery,
selectMessagesDue: postgresSelectMessagesDueQuery,
selectMessagesDueForUpdate: postgresSelectMessagesDueForUpdateQuery,
deleteExpiredMessages: postgresDeleteExpiredMessagesQuery,
updateMessagePublished: postgresUpdateMessagePublishedQuery,
selectMessagesCount: postgresSelectMessagesCountQuery,
+76
View File
@@ -0,0 +1,76 @@
package message_test
import (
"testing"
"github.com/stretchr/testify/require"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
)
func TestPostgresStore_Migration_From14(t *testing.T) {
// A pre-framework database at version 14: full v14 schema, version tracked in the
// hand-rolled schema_version table, and no idx_message_attachment_expires yet
testDB := dbtest.CreateTestPostgres(t)
_, err := testDB.Exec(`
CREATE TABLE message (
id BIGSERIAL PRIMARY KEY,
mid TEXT NOT NULL,
sequence_id TEXT NOT NULL,
time BIGINT NOT NULL,
event TEXT NOT NULL,
expires BIGINT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
title TEXT NOT NULL,
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
icon TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
attachment_size BIGINT NOT NULL,
attachment_expires BIGINT NOT NULL,
attachment_url TEXT NOT NULL,
attachment_deleted BOOLEAN NOT NULL DEFAULT FALSE,
sender TEXT NOT NULL,
user_id TEXT NOT NULL,
content_type TEXT NOT NULL,
encoding TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_message_mid ON message (mid);
CREATE INDEX idx_message_sequence_id ON message (sequence_id);
CREATE INDEX idx_message_topic_published_time ON message (topic, published, time, id);
CREATE INDEX idx_message_published_expires ON message (published, expires);
CREATE INDEX idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = '';
CREATE INDEX idx_message_user_id_attachment_expires ON message (user_id, attachment_expires);
CREATE TABLE message_stats (key TEXT PRIMARY KEY, value BIGINT);
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
CREATE TABLE schema_version (store TEXT PRIMARY KEY, version INT NOT NULL);
INSERT INTO schema_version (store, version) VALUES ('message', 14);
`)
require.Nil(t, err)
store, err := message.NewPostgresStore(testDB, 0, 0)
require.Nil(t, err)
// The 14 -> 15 step ran: version bumped, partial index created
var version int
require.Nil(t, testDB.QueryRow(`SELECT version FROM schema_version WHERE store = 'message'`).Scan(&version))
require.Equal(t, 15, version)
var indexCount int
require.Nil(t, testDB.QueryRow(`SELECT COUNT(*) FROM pg_indexes WHERE indexname = 'idx_message_attachment_expires' AND schemaname = current_schema()`).Scan(&indexCount))
require.Equal(t, 1, indexCount)
// And the store works
require.Nil(t, store.AddMessage(model.NewDefaultMessage("mytopic", "hi there")))
messages, err := store.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Len(t, messages, 1)
// The migrated database must be structurally identical to a freshly created one
freshDB := dbtest.CreateTestPostgres(t)
_, err = message.NewPostgresStore(freshDB, 0, 0)
require.Nil(t, err)
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, testDB))
}
+14
View File
@@ -9,6 +9,7 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver
"github.com/stretchr/testify/require"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
)
@@ -50,6 +51,19 @@ func TestSqliteStore_Migration_From1(t *testing.T) {
s := newSqliteTestStoreFromFile(t, filename, "")
checkSqliteSchemaVersion(t, filename)
// The migrated database must be structurally identical to a freshly created one
freshFile := newSqliteTestStoreFile(t)
fresh, err := message.NewSQLiteStore(freshFile, "", time.Hour, 0, 0, false)
require.Nil(t, err)
t.Cleanup(func() { fresh.Close() })
freshDB, err := sql.Open("sqlite3", freshFile)
require.Nil(t, err)
defer freshDB.Close()
migratedDB, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
defer migratedDB.Close()
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
// Add delayed message
delayedMessage := model.NewDefaultMessage("mytopic", "some delayed message")
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
+38 -54
View File
@@ -1,6 +1,7 @@
package message_test
import (
"fmt"
"net/netip"
"path/filepath"
"sync"
@@ -36,60 +37,6 @@ func newTestPostgresStore(t *testing.T) *message.Cache {
return store
}
func TestPostgresStore_Migration_From14(t *testing.T) {
// A pre-framework database at version 14: full v14 schema, version tracked in the
// hand-rolled schema_version table, and no idx_message_attachment_expires yet
testDB := dbtest.CreateTestPostgres(t)
_, err := testDB.Exec(`
CREATE TABLE message (
id BIGSERIAL PRIMARY KEY,
mid TEXT NOT NULL,
sequence_id TEXT NOT NULL,
time BIGINT NOT NULL,
event TEXT NOT NULL,
expires BIGINT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
title TEXT NOT NULL,
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
icon TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
attachment_size BIGINT NOT NULL,
attachment_expires BIGINT NOT NULL,
attachment_url TEXT NOT NULL,
attachment_deleted BOOLEAN NOT NULL DEFAULT FALSE,
sender TEXT NOT NULL,
user_id TEXT NOT NULL,
content_type TEXT NOT NULL,
encoding TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE message_stats (key TEXT PRIMARY KEY, value BIGINT);
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
CREATE TABLE schema_version (store TEXT PRIMARY KEY, version INT NOT NULL);
INSERT INTO schema_version (store, version) VALUES ('message', 14);
`)
require.Nil(t, err)
store, err := message.NewPostgresStore(testDB, 0, 0)
require.Nil(t, err)
// The 14 -> 15 step ran: version bumped, partial index created
var version int
require.Nil(t, testDB.QueryRow(`SELECT version FROM schema_version WHERE store = 'message'`).Scan(&version))
require.Equal(t, 15, version)
var indexCount int
require.Nil(t, testDB.QueryRow(`SELECT COUNT(*) FROM pg_indexes WHERE indexname = 'idx_message_attachment_expires' AND schemaname = current_schema()`).Scan(&indexCount))
require.Equal(t, 1, indexCount)
// And the store works
require.Nil(t, store.AddMessage(model.NewDefaultMessage("mytopic", "hi there")))
messages, err := store.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Len(t, messages, 1)
}
func forEachBackend(t *testing.T, f func(t *testing.T, s *message.Cache)) {
t.Run("sqlite", func(t *testing.T) {
f(t, newSqliteTestStore(t))
@@ -610,6 +557,43 @@ func TestStore_MarkPublished(t *testing.T) {
})
}
func TestStore_MessagesDue_ClaimExactlyOnce(t *testing.T) {
// Postgres-only: exercises "FOR UPDATE SKIP LOCKED" claiming so that concurrent delayed
// senders running on different cluster nodes never pick up (and deliver) the same due
// message twice. With the non-claiming implementation, every concurrent caller sees every
// due row, so this test fails; with claiming, each row is returned to exactly one caller.
s := newTestPostgresStore(t) // skips if NTFY_TEST_DATABASE_URL is unset
const n = 40
for i := 0; i < n; i++ {
m := model.NewDefaultMessage("mytopic", fmt.Sprintf("scheduled %d", i))
m.Time = time.Now().Add(time.Hour).Unix() // future -> stored as published=FALSE
require.Nil(t, s.AddMessage(m))
// Move the time into the past so the message is due now (but still unpublished)
require.Nil(t, s.UpdateMessageTime(m.ID, time.Now().Add(-time.Minute).Unix()))
}
var mu sync.Mutex
seen := make(map[string]int)
var wg sync.WaitGroup
for c := 0; c < 6; c++ {
wg.Add(1)
go func() {
defer wg.Done()
due, err := s.MessagesDue()
require.Nil(t, err)
mu.Lock()
defer mu.Unlock()
for _, m := range due {
seen[m.ID]++
}
}()
}
wg.Wait()
require.Len(t, seen, n) // every due message was claimed
for id, count := range seen {
require.Equalf(t, 1, count, "message %s was claimed %d times, want exactly 1", id, count)
}
}
func TestStore_ExpireMessages(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Add messages to two topics
+36
View File
@@ -75,6 +75,33 @@ var (
HTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_http_requests_total",
}, []string{"http_code", "ntfy_code", "http_method"})
ClusterPeers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_cluster_peers",
})
ClusterMessagesForwarded = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_cluster_messages_forwarded_total",
})
ClusterSendErrors = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_cluster_send_errors_total",
})
ClusterQueueDropped = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_cluster_queue_dropped_total",
})
ClusterBatchesSent = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_cluster_batches_sent_total",
})
ClusterMessagesWasted = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_cluster_messages_wasted_total",
})
ClusterRouteSkipped = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_cluster_route_skipped_total",
})
ClusterStatePushes = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_cluster_state_pushes_total",
})
ClusterLeader = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_cluster_leader",
})
)
// init registers all collectors with the default Prometheus registry. Registration is
@@ -103,5 +130,14 @@ func init() {
Subscribers,
Topics,
HTTPRequests,
ClusterPeers,
ClusterMessagesForwarded,
ClusterSendErrors,
ClusterQueueDropped,
ClusterBatchesSent,
ClusterMessagesWasted,
ClusterRouteSkipped,
ClusterStatePushes,
ClusterLeader,
)
}
+9
View File
@@ -15,6 +15,15 @@ var expectedMetricNames = []string{
"ntfy_attachments_total_size",
"ntfy_calls_made_failure",
"ntfy_calls_made_success",
"ntfy_cluster_batches_sent_total",
"ntfy_cluster_leader",
"ntfy_cluster_messages_forwarded_total",
"ntfy_cluster_messages_wasted_total",
"ntfy_cluster_peers",
"ntfy_cluster_queue_dropped_total",
"ntfy_cluster_route_skipped_total",
"ntfy_cluster_send_errors_total",
"ntfy_cluster_state_pushes_total",
"ntfy_emails_received_failure",
"ntfy_emails_received_success",
"ntfy_emails_sent_failure",
+14 -2
View File
@@ -10,6 +10,8 @@ import (
"text/template"
"time"
"heckel.io/ntfy/v2/cluster"
"heckel.io/ntfy/v2/ban"
"heckel.io/ntfy/v2/user"
)
@@ -119,8 +121,13 @@ type Config struct {
ListenUnixMode fs.FileMode
KeyFile string
CertFile string
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
DatabaseReplicaURLs []string // PostgreSQL read replica connection strings
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
DatabaseReplicaURLs []string // PostgreSQL read replica connection strings
ClusterNodeID string // Stable per-node identifier used to skip a node's own fan-out; required in cluster mode
ClusterListen string // ip:port the dedicated cluster fan-out listener binds to (private network, e.g. "10.0.0.5:2587")
ClusterAdvertiseURL string // Base URL peers use to reach this node's fan-out listener (defaults to "http://<cluster-listen>")
ClusterSecret string `hash:"-"` // Shared secret authenticating node-to-node fan-out requests
ClusterBatchLinger time.Duration // How long fan-out messages wait to form a batch per peer; 0 sends immediately
FirebaseKeyFile string
CacheFile string
CacheDuration time.Duration
@@ -236,6 +243,11 @@ func NewConfig() *Config {
KeyFile: "",
CertFile: "",
DatabaseURL: "",
ClusterNodeID: "",
ClusterListen: "",
ClusterAdvertiseURL: "",
ClusterSecret: "",
ClusterBatchLinger: cluster.DefaultBatchLinger,
FirebaseKeyFile: "",
CacheFile: "",
CacheDuration: DefaultCacheDuration,
+1
View File
@@ -25,6 +25,7 @@ func TestConfig_HashExcludesSecrets(t *testing.T) {
conf2.UpstreamAccessToken = "tk_upstream"
conf2.WebPushPrivateKey = "web-push-private-key"
conf2.SMTPSenderPass = "hunter2"
conf2.ClusterSecret = "cluster-secret"
conf2.AuthUsers = []*user.User{{Name: "phil", Hash: "$2a$10$somebcrypthash"}}
conf2.AuthTokens = map[string][]*user.Token{"phil": {{Value: "tk_secrettoken"}}}
assert.Equal(t, conf1.Hash(), conf2.Hash())
+1
View File
@@ -148,6 +148,7 @@ var (
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil}
errHTTPBadRequestTemplateTooLarge = &errHTTP{40056, http.StatusBadRequest, "invalid request: template too large", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
+182 -62
View File
@@ -32,6 +32,7 @@ import (
"heckel.io/ntfy/v2/action"
"heckel.io/ntfy/v2/attachment"
"heckel.io/ntfy/v2/ban"
"heckel.io/ntfy/v2/cluster"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/log"
@@ -72,6 +73,8 @@ type Server struct {
stripe stripeAPI // Stripe API, can be replaced with a mock
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set
cluster cluster.Cluster // Fans messages out to peer cluster nodes (nop when not clustered)
httpClusterServer *http.Server // Dedicated private listener for node-to-node fan-out (cluster-listen)
closeChan chan bool
mu sync.RWMutex
}
@@ -150,17 +153,6 @@ var (
//go:embed docs
docsStaticFs embed.FS
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
//go:embed templates
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
templatesDir = "templates"
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
// templateMaxExecutionTime is the wall-clock deadline for a single template render, a DoS guard
// (GHSA-rhwf-xgc9-m9fp). It is a var (not a const) solely so tests can raise it; it is never
// mutated in production.
templateMaxExecutionTime = 100 * time.Millisecond
)
const (
@@ -174,8 +166,6 @@ const (
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
messagesHistoryMax = 10 // Number of message count values to keep in memory
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
templateFileExtension = ".yml" // Template files must end with this extension
)
// WebSocket constants
@@ -334,9 +324,93 @@ func New(conf *Config) (*Server, error) {
stripe: stripe,
}
s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)
// Cross-node cluster; delivery of peer messages to local subscribers is injected
// as a callback, so the cluster package never depends on the server. Peers talk to each
// other only via the dedicated cluster listener, never via the public listeners.
advertiseURL := conf.ClusterAdvertiseURL
if advertiseURL == "" && conf.ClusterListen != "" {
advertiseURL = "http://" + conf.ClusterListen
}
s.cluster, err = cluster.New(&cluster.Config{
Enabled: conf.ClusterListen != "", // Setting cluster-listen implicitly enables clustering
NodeID: cluster.NodeID(conf.ClusterNodeID),
AdvertiseURL: advertiseURL,
Secret: conf.ClusterSecret,
BatchLinger: conf.ClusterBatchLinger,
MaxMessageBytes: int64(conf.MessageSizeLimit)*4 + 1024, // Envelope overhead over the raw message
}, pool, s.deliverFromBus, s.liveTopics)
if err != nil {
return nil, err
}
// The cluster routes messages by subscription knowledge: peers learn this node's live topics
// via periodic state pushes (liveTopics) and immediate announcements on a topic's first
// subscriber (the hook below; also set in topicsFromIDs for topics created later)
for _, t := range s.topics {
t.onFirstSubscriber = s.topicAnnouncer(t.ID)
}
return s, nil
}
// clusterHandler returns the handler served on the dedicated cluster listener (cluster-listen).
// It serves the internal peer API (owned and routed by the cluster itself, including auth) plus
// a health endpoint; the public listeners never expose these paths, so internal traffic cannot
// be reached from the outside even before any firewalling.
func (s *Server) clusterHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(apiHealthPath, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if s.cluster.Healthy() {
io.WriteString(w, `{"healthy":true}`+"\n")
} else {
w.WriteHeader(http.StatusServiceUnavailable)
io.WriteString(w, `{"healthy":false}`+"\n")
}
})
mux.Handle("/", s.cluster)
return mux
}
// liveTopics returns the topics that currently have at least one subscriber, computed fresh on
// every call. Deliberately NOT the whole topics map: it also holds subscriber-less topics
// rebuilt from the message cache, which would gut the routing filter's selectivity.
func (s *Server) liveTopics() []string {
s.mu.RLock()
defer s.mu.RUnlock()
topics := make([]string, 0, len(s.topics))
for _, t := range s.topics {
if subscribers, _ := t.Stats(); subscribers > 0 {
topics = append(topics, t.ID)
}
}
return topics
}
// topicAnnouncer returns the first-subscriber hook for a topic: it tells peer nodes right away
// that this node now wants messages for it (see Cluster.BroadcastState).
func (s *Server) topicAnnouncer(id string) func() {
return func() {
s.cluster.BroadcastState(&cluster.State{AddedTopics: []string{id}})
}
}
// deliverFromBus delivers a message received from a peer node (via the cluster) to this
// node's local subscribers. It is the receive-side counterpart to Cluster.ForwardMessage: local
// delivery and all global side effects (Firebase, email, web push, upstream) already ran on the
// origin node, so this only publishes to the local topic, and never re-relays.
func (s *Server) deliverFromBus(m *model.Message) {
s.mu.RLock()
t, ok := s.topics[m.Topic]
s.mu.RUnlock()
if !ok {
metrics.ClusterMessagesWasted.Inc() // Relayed here needlessly: this node had no use for the message
return
}
v := s.visitor(m.Sender, nil)
if err := t.Publish(v, m); err != nil {
logvm(v, m).Err(err).Warn("Cluster: unable to deliver fan-out message to local subscribers")
}
}
func createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) {
if conf.CacheDuration == 0 {
return message.NewNopStore()
@@ -379,6 +453,9 @@ func (s *Server) Run() error {
if s.config.ProfileListenHTTP != "" {
listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP)
}
if s.config.ClusterListen != "" {
listenStr += fmt.Sprintf(" %s[http/cluster]", s.config.ClusterListen)
}
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.BuildVersion)
@@ -433,6 +510,12 @@ func (s *Server) Run() error {
} else if s.config.EnableMetrics {
s.metricsHandler = promhttp.Handler()
}
if s.config.ClusterListen != "" {
s.httpClusterServer = &http.Server{Addr: s.config.ClusterListen, Handler: s.clusterHandler()}
go func() {
errChan <- s.httpClusterServer.ListenAndServe()
}()
}
if s.config.ProfileListenHTTP != "" {
profileMux := http.NewServeMux()
profileMux.HandleFunc("/debug/pprof/", pprof.Index)
@@ -478,6 +561,12 @@ func (s *Server) Stop() {
if s.attachment != nil {
s.attachment.Close()
}
if s.httpClusterServer != nil {
s.httpClusterServer.Close()
}
if s.cluster != nil {
s.cluster.Close()
}
s.closeDatabases()
if s.ban != nil {
s.ban.Close()
@@ -730,10 +819,13 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
response := &apiHealthResponse{
Healthy: true,
// Unhealthy = the registry heartbeat went stale and peers stopped forwarding to this
// node; 503 lets status-code LB checks pull it (checkers must fail open, see cluster.Cluster)
healthy := s.cluster.Healthy()
if !healthy {
w.WriteHeader(http.StatusServiceUnavailable)
}
return s.writeJSON(w, response)
return s.writeJSON(w, &apiHealthResponse{Healthy: healthy})
}
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
@@ -833,6 +925,49 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
return writeMatrixDiscoveryResponse(w)
}
// dispatch delivers m to local subscribers, forwards it to peer cluster nodes, and fires the
// requested side-effect targets. It is the single choke point through which every published
// message must pass; t may be nil when the topic has no local subscribers (delayed sender).
//
// The side-effect targets (Firebase, email, calls, upstream, web push) are global, not per-node:
// they fire only here on the origin node, and never again when a peer node receives the message
// via the cluster (see deliverFromBus).
func (s *Server) dispatch(v *visitor, t *topic, m *model.Message, opts dispatchOpts) error {
// Deliver to local subscribers
if t != nil {
if opts.async {
go func() {
if err := t.Publish(v, m); err != nil {
logvm(v, m).Err(err).Warn("Unable to publish message")
}
}()
} else if err := t.Publish(v, m); err != nil {
return err
}
}
// Forward to peer cluster nodes, whose subscribers do not show up in this node's topics map
if err := s.cluster.ForwardMessage(m); err != nil {
logvm(v, m).Err(err).Warn("Cluster: unable to forward message to peer nodes")
}
// Fire the requested side-effect targets
if s.firebaseClient != nil && opts.firebase {
go s.sendToFirebase(v, m)
}
if s.mailer != nil && opts.email != "" {
go s.sendEmail(v, m, opts.email)
}
if s.config.TwilioAccount != "" && opts.call != "" {
go s.callPhone(v, m, opts.call)
}
if s.config.UpstreamBaseURL != "" && opts.upstream {
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" && opts.webPush {
go s.publishToWebPushEndpoints(v, m)
}
return nil
}
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Message, error) {
start := time.Now()
t, err := fromContext[*topic](r, contextTopic)
@@ -911,24 +1046,16 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
ev.Debug("Received message")
}
if !delayed {
if err := t.Publish(v, m); err != nil {
err := s.dispatch(v, t, m, dispatchOpts{
firebase: firebase,
email: email,
call: call,
upstream: !unifiedpush, // UP messages are not sent to upstream
webPush: true,
})
if err != nil {
return nil, err
}
if s.firebaseClient != nil && firebase {
go s.sendToFirebase(v, m)
}
if s.mailer != nil && email != "" {
go s.sendEmail(v, m, email)
}
if s.config.TwilioAccount != "" && call != "" {
go s.callPhone(v, m, call)
}
if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
}
} else {
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
}
@@ -1027,18 +1154,10 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
m.Sender = v.IP()
m.User = v.MaybeUserID()
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
// Publish to subscribers
if err := t.Publish(v, m); err != nil {
// Publish to subscribers, peer nodes, Firebase (for Android clients), and web push endpoints
if err := s.dispatch(v, t, m, dispatchOpts{firebase: true, webPush: true}); err != nil {
return err
}
// Send to Firebase for Android clients
if s.firebaseClient != nil {
go s.sendToFirebase(v, m)
}
// Send to web push endpoints
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
}
if event == model.MessageDeleteEvent {
// Delete any existing scheduled message with the same sequence ID
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
@@ -1832,7 +1951,9 @@ func (s *Server) topicsFromIDs(v *visitor, ids ...string) ([]*topic, error) {
if v != nil && !v.TopicCreationAllowed() {
return nil, errHTTPTooManyRequestsLimitTopicCreation
}
s.topics[id] = newTopic(id)
t := newTopic(id)
t.onFirstSubscriber = s.topicAnnouncer(id)
s.topics[id] = t
}
topics = append(topics, s.topics[id])
}
@@ -1919,7 +2040,8 @@ func (s *Server) resetStats() {
for _, v := range s.visitors {
v.ResetStats()
}
if s.userManager != nil {
// The user database is shared; only the cluster leader resets it (always true single-node)
if s.userManager != nil && s.cluster.IsLeader() {
if err := s.userManager.ResetStats(); err != nil {
log.Tag(tagResetter).Warn("Failed to write to database: %s", err.Error())
}
@@ -1934,7 +2056,11 @@ func (s *Server) runFirebaseKeepaliver() {
for {
select {
case <-time.After(s.config.FirebaseKeepaliveInterval):
s.sendToFirebase(v, model.NewKeepaliveMessage(firebaseControlTopic))
// Leader only: every FCM keepalive wakes all subscribed phones, so a cluster must
// send it exactly once, not once per node (checked per tick to survive failover)
if s.cluster.IsLeader() {
s.sendToFirebase(v, model.NewKeepaliveMessage(firebaseControlTopic))
}
/*
FIXME: Disable iOS polling entirely for now due to thundering herd problem (see #677)
To solve this, we'd have to shard the iOS poll topics to spread out the polling evenly.
@@ -1987,24 +2113,18 @@ func (s *Server) sendDelayedMessages() error {
func (s *Server) sendDelayedMessage(v *visitor, m *model.Message) error {
logvm(v, m).Debug("Sending delayed message")
s.mu.RLock()
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
t := s.topics[m.Topic] // May be nil if there are no local subscribers; dispatch handles that
s.mu.RUnlock()
if ok {
go func() {
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
if err := t.Publish(v, m); err != nil {
logvm(v, m).Err(err).Warn("Unable to publish message")
}
}()
}
if s.firebaseClient != nil { // Firebase subscribers may not show up in topics map
go s.sendToFirebase(v, m)
}
if s.config.UpstreamBaseURL != "" {
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler.
// Firebase subscribers may not show up in the topics map, so side effects fire regardless.
err := s.dispatch(v, t, m, dispatchOpts{
firebase: true,
upstream: true,
webPush: true,
async: true,
})
if err != nil {
return err
}
if err := s.messageCache.MarkPublished(m); err != nil {
return err
+28
View File
@@ -61,6 +61,34 @@
#
# database-url: <connection-string>
# If "cluster-listen" is set, clustering is implicitly enabled: this node registers itself in
# the PostgreSQL node registry and fans published messages out to the other cluster nodes over
# HTTP, so subscribers connected to any node receive messages published to any other node.
# Requires "database-url" and "cluster-secret".
#
# - cluster-listen is the ip:port of the dedicated fan-out listener that peer nodes talk to.
# Bind it to a private network interface (e.g. "10.0.0.5:2587"); the public listeners never
# serve the fan-out endpoint.
# - cluster-node-id is a stable per-node identifier (e.g. the hostname); required.
# - cluster-advertise-url is the base URL peer nodes use to reach this node's fan-out listener;
# it defaults to "http://<cluster-listen>". It must be set explicitly if cluster-listen binds
# a wildcard address (e.g. ":2587").
# - cluster-secret authenticates node-to-node fan-out requests; it must be identical on all
# nodes.
# - cluster-batch-linger is how long fan-out messages may wait to form a batch per peer node,
# trading up to that much cross-node delivery latency for a bounded request rate between
# nodes. Set to 0 to send each message immediately.
#
# SECURITY: The fan-out endpoint injects messages into arbitrary topics. The shared secret
# protects it, and it is only served on the dedicated cluster listener -- but you should still
# make sure that listener is reachable only from the private network (firewall/VPC rules).
#
# cluster-listen: <ip:port>
# cluster-node-id: <hostname>
# cluster-advertise-url: "http://<cluster-listen>"
# cluster-secret: <secret>
# cluster-batch-linger: 500ms
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
# This allows for service restarts without losing messages in support of the since= parameter.
# Not required if "database-url" is set (messages are stored in PostgreSQL instead).
+2 -1
View File
@@ -985,7 +985,8 @@ func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error {
return err
}
m := model.NewDefaultMessage(syncTopic.ID, string(messageBytes))
if err := syncTopic.Publish(v, m); err != nil {
// Dispatch so the sync event also reaches the user's devices connected to peer cluster nodes
if err := s.dispatch(v, syncTopic, m, dispatchOpts{}); err != nil {
return err
}
return nil
+353
View File
@@ -0,0 +1,353 @@
package server
import (
"database/sql"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/cluster"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
)
// fakeCluster records relayed messages and topic announcements so tests can assert that every
// publish path passes through the cluster exactly once, and that subscription hooks fire.
type fakeCluster struct {
mu sync.Mutex
messages []*model.Message
announced []string
notLeader bool
notHealthy bool
}
func (b *fakeCluster) ForwardMessage(m *model.Message) error {
b.mu.Lock()
defer b.mu.Unlock()
b.messages = append(b.messages, m)
return nil
}
func (b *fakeCluster) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {}
func (b *fakeCluster) BroadcastState(state *cluster.State) {
b.mu.Lock()
defer b.mu.Unlock()
b.announced = append(b.announced, state.AddedTopics...)
}
func (b *fakeCluster) Healthy() bool {
b.mu.Lock()
defer b.mu.Unlock()
return !b.notHealthy
}
func (b *fakeCluster) setHealthy(healthy bool) {
b.mu.Lock()
defer b.mu.Unlock()
b.notHealthy = !healthy
}
func (b *fakeCluster) IsLeader() bool {
b.mu.Lock()
defer b.mu.Unlock()
return !b.notLeader
}
func (b *fakeCluster) setLeader(leader bool) {
b.mu.Lock()
defer b.mu.Unlock()
b.notLeader = !leader
}
func (b *fakeCluster) Close() error { return nil }
func (b *fakeCluster) Messages() []*model.Message {
b.mu.Lock()
defer b.mu.Unlock()
return append([]*model.Message{}, b.messages...)
}
func (b *fakeCluster) Announced() []string {
b.mu.Lock()
defer b.mu.Unlock()
return append([]string{}, b.announced...)
}
func TestServer_Cluster_PublishForwardsOnce(t *testing.T) {
s := newTestServer(t, newTestConfig(t, ""))
b := &fakeCluster{}
s.cluster = b
response := request(t, s, "PUT", "/mytopic", "hi there", nil)
require.Equal(t, 200, response.Code)
messages := b.Messages()
require.Len(t, messages, 1)
require.Equal(t, "mytopic", messages[0].Topic)
require.Equal(t, "hi there", messages[0].Message)
}
func TestServer_Cluster_SyncEventForwards(t *testing.T) {
// Account sync events are delivered via the user's st_... sync topic; without relaying
// them, cross-device account sync silently breaks when a user's devices land on different
// cluster nodes.
s := newTestServer(t, newTestConfig(t, ""))
b := &fakeCluster{}
s.cluster = b
u := &user.User{ID: "u_abc", Name: "phil", SyncTopic: "st_1234"}
v := s.visitor(netip.MustParseAddr("1.2.3.4"), nil)
require.Nil(t, s.publishSyncEventForUser(v, u))
messages := b.Messages()
require.Len(t, messages, 1)
require.Equal(t, "st_1234", messages[0].Topic)
}
func TestServer_Cluster_DeliverNotOnPublicHandler(t *testing.T) {
// The fan-out endpoint lives only on the dedicated cluster listener; the public handler must
// not serve it, even with cluster mode on and a valid secret.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
conf := newTestConfig(t, schemaDSN)
conf.ClusterNodeID = "node-a"
conf.ClusterListen = "127.0.0.1:1" // Enables clustering; not bound since Run() is not called
conf.ClusterSecret = "s3cret"
conf.ClusterAdvertiseURL = "http://127.0.0.1:1"
s := newTestServer(t, conf)
topics, err := s.topicsFromIDs(nil, "mytopic")
require.Nil(t, err)
var mu sync.Mutex
var received []*model.Message
topics[0].Subscribe(func(_ *visitor, m *model.Message) error {
mu.Lock()
defer mu.Unlock()
received = append(received, m)
return nil
}, "", func() {})
// A valid fan-out request against the PUBLIC handler must not deliver
response := request(t, s, "POST", "/v1/internal/message",
`{"message":{"id":"x1","time":1,"event":"message","topic":"mytopic","message":"sneaky"}}`,
map[string]string{"X-Cluster-Secret": "s3cret", "X-Cluster-Origin": "node-b"})
require.Equal(t, 404, response.Code)
time.Sleep(250 * time.Millisecond) // Delivery is async; give a wrong implementation time to fail
mu.Lock()
require.Empty(t, received)
mu.Unlock()
// The same request against the cluster listener handler DOES deliver
rr := httptest.NewRecorder()
req, err := http.NewRequest("POST", "/v1/internal/message",
strings.NewReader(`{"message":{"id":"x2","time":1,"event":"message","topic":"mytopic","message":"legit"}}`))
require.Nil(t, err)
req.Header.Set("X-Cluster-Secret", "s3cret")
req.Header.Set("X-Cluster-Origin", "node-b")
s.clusterHandler().ServeHTTP(rr, req)
require.Equal(t, 200, rr.Code)
waitFor(t, func() bool {
mu.Lock()
defer mu.Unlock()
return len(received) == 1
})
}
func TestServer_Cluster_EndToEnd(t *testing.T) {
// Two full servers sharing one Postgres schema: a message published to node A over HTTP must
// reach a subscriber connected to node B, via the node registry and the fan-out endpoint.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
// Node B: create the listener first so its advertise URL is known before the server exists
listenerB, err := net.Listen("tcp", "127.0.0.1:0")
require.Nil(t, err)
confB := newTestConfig(t, schemaDSN)
confB.ClusterNodeID = "node-b"
confB.ClusterListen = listenerB.Addr().String() // Enables clustering; the test serves it below
confB.ClusterSecret = "s3cret"
confB.ClusterAdvertiseURL = "http://" + listenerB.Addr().String()
sB := newTestServer(t, confB)
srvB := &http.Server{Handler: sB.clusterHandler()}
go srvB.Serve(listenerB)
defer srvB.Close()
// Node A: publish-only in this test, so its advertise URL is never called
confA := newTestConfig(t, schemaDSN)
confA.ClusterNodeID = "node-a"
confA.ClusterListen = "127.0.0.1:1" // Enables clustering; not bound since Run() is not called
confA.ClusterSecret = "s3cret"
confA.ClusterAdvertiseURL = "http://127.0.0.1:1"
sA := newTestServer(t, confA)
// Subscribe on node B
topics, err := sB.topicsFromIDs(nil, "mytopic")
require.Nil(t, err)
var mu sync.Mutex
var received []*model.Message
topics[0].Subscribe(func(_ *visitor, m *model.Message) error {
mu.Lock()
defer mu.Unlock()
received = append(received, m)
return nil
}, "", func() {})
// Publish on node A
response := request(t, sA, "PUT", "/mytopic", "hello cluster", nil)
require.Equal(t, 200, response.Code)
waitFor(t, func() bool {
mu.Lock()
defer mu.Unlock()
return len(received) == 1
})
mu.Lock()
defer mu.Unlock()
require.Equal(t, "hello cluster", received[0].Message)
}
func TestServer_Cluster_DeliverFromBus(t *testing.T) {
// deliverFromBus is the receive side of the broadcaster: a message that originated on a peer
// node must reach this node's local subscribers, but must NOT be re-broadcast (loop) nor
// re-trigger origin-only side effects.
s := newTestServer(t, newTestConfig(t, ""))
b := &fakeCluster{}
s.cluster = b
topics, err := s.topicsFromIDs(nil, "mytopic")
require.Nil(t, err)
var mu sync.Mutex
var received []*model.Message
topics[0].Subscribe(func(_ *visitor, m *model.Message) error {
mu.Lock()
defer mu.Unlock()
received = append(received, m)
return nil
}, "", func() {})
m := model.NewDefaultMessage("mytopic", "from peer")
m.Sender = netip.MustParseAddr("5.6.7.8")
s.deliverFromBus(m)
waitFor(t, func() bool {
mu.Lock()
defer mu.Unlock()
return len(received) == 1
})
require.Empty(t, b.Messages()) // Peer messages are never re-relayed
}
func TestServer_Cluster_FirstSubscriberAnnounces(t *testing.T) {
// A topic gaining its FIRST subscriber is announced to peers exactly once, so publishers on
// other nodes stop skipping this node for it without waiting for the next state push.
s := newTestServer(t, newTestConfig(t, ""))
b := &fakeCluster{}
s.cluster = b
topics, err := s.topicsFromIDs(nil, "mytopic")
require.Nil(t, err)
subscriber := func(_ *visitor, _ *model.Message) error { return nil }
topics[0].Subscribe(subscriber, "", func() {})
waitFor(t, func() bool {
return len(b.Announced()) == 1 && b.Announced()[0] == "mytopic"
})
// A second subscriber does not re-announce
topics[0].Subscribe(subscriber, "", func() {})
time.Sleep(250 * time.Millisecond)
require.Len(t, b.Announced(), 1)
}
func TestServer_Cluster_ManagerPrunesOnlyOnLeader(t *testing.T) {
c := newTestConfig(t, "")
s := newTestServer(t, c)
cl := &fakeCluster{notLeader: true}
s.cluster = cl
// Publish and expire a message
rr := request(t, s, "POST", "/mytopic", "hi", nil)
require.Equal(t, 200, rr.Code)
m := toMessage(t, rr.Body.String())
require.Nil(t, s.messageCache.ExpireMessages("mytopic"))
// A non-leader node leaves shared-database pruning to the leader
s.execManager()
_, err := s.messageCache.Message(m.ID)
require.Nil(t, err)
// Once this node is the leader, the same run prunes
cl.setLeader(true)
s.execManager()
_, err = s.messageCache.Message(m.ID)
require.Equal(t, model.ErrMessageNotFound, err)
}
func TestServer_Cluster_StatsResetOnlyOnLeader(t *testing.T) {
c := newTestConfigWithAuthFile(t, "")
s := newTestServer(t, c)
cl := &fakeCluster{notLeader: true}
s.cluster = cl
// An anonymous visitor with an in-memory message count
v := newVisitor(c, s.messageCache, s.userManager, netip.MustParseAddr("1.2.3.4"), nil)
require.True(t, v.MessageAllowed())
s.mu.Lock()
s.visitors["ip:1.2.3.4"] = v
s.mu.Unlock()
require.Equal(t, int64(1), v.Stats().Messages)
// A user with persisted stats in the (shared) user database
require.Nil(t, s.userManager.AddUser("phil", "phil1234", user.RoleUser, false))
authDB, err := sql.Open("sqlite3", c.AuthFile)
require.Nil(t, err)
defer authDB.Close()
_, err = authDB.Exec(`UPDATE user SET stats_messages = 5 WHERE user = 'phil'`)
require.Nil(t, err)
// A non-leader node resets its own in-memory visitor stats, but leaves the user database
// to the leader
s.resetStats()
require.Equal(t, int64(0), v.Stats().Messages)
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, int64(5), u.Stats.Messages)
// The leader resets the user database too
cl.setLeader(true)
s.resetStats()
u, err = s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, int64(0), u.Stats.Messages)
}
func TestServer_Cluster_FirebaseKeepaliverOnlyOnLeader(t *testing.T) {
// Every FCM keepalive wakes all subscribed phones, so only the leader may send them;
// N nodes sending N keepalives would multiply the battery cost for every user
c := newTestConfig(t, "")
c.FirebaseKeepaliveInterval = 20 * time.Millisecond
s := newTestServer(t, c)
sender := newTestFirebaseSender(100)
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
cl := &fakeCluster{notLeader: true}
s.cluster = cl
s.closeChan = make(chan bool) // Closed by Stop() in the test cleanup
go s.runFirebaseKeepaliver()
// A non-leader node stays silent
time.Sleep(150 * time.Millisecond)
require.Empty(t, sender.Messages())
// The leader sends keepalives
cl.setLeader(true)
waitFor(t, func() bool { return len(sender.Messages()) > 0 })
}
func TestServer_Cluster_HealthReflectsCluster(t *testing.T) {
// A node whose registry heartbeat went stale no longer receives forwarded messages, so
// health checks must pull it from rotation (the fail-open policy lives in the checker)
s := newTestServer(t, newTestConfig(t, ""))
cl := &fakeCluster{}
s.cluster = cl
rr := request(t, s, "GET", "/v1/health", "", nil)
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), `"healthy":true`)
cl.setHealthy(false)
rr = request(t, s, "GET", "/v1/health", "", nil)
require.Equal(t, 503, rr.Code)
require.Contains(t, rr.Body.String(), `"healthy":false`)
// The cluster listener's health endpoint reflects the same state
rr2 := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/v1/health", nil)
require.Nil(t, err)
s.clusterHandler().ServeHTTP(rr2, req)
require.Equal(t, 503, rr2.Code)
}
+9 -5
View File
@@ -10,12 +10,16 @@ func (s *Server) execManager() {
// WARNING: Make sure to only selectively lock with the mutex, and be aware that this
// there is no mutex for the entire function.
// Prune all the things
// Prune all the things. In-memory state is pruned on every node; jobs touching shared
// databases (and the web push job, which also sends expiry-warning notifications) run on
// the cluster leader only. In a single-node setup, IsLeader is always true.
s.pruneVisitors()
s.pruneTokens()
s.pruneAttachments()
s.pruneMessages()
s.pruneAndNotifyWebPushSubscriptions()
if s.cluster.IsLeader() {
s.pruneTokens()
s.pruneAttachments()
s.pruneMessages()
s.pruneAndNotifyWebPushSubscriptions()
}
// Message count
messagesCached, err := s.messageCache.MessagesCount()
+51 -1
View File
@@ -3,12 +3,16 @@ package server
import (
"bytes"
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"text/template/parse"
"time"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/model"
@@ -17,6 +21,32 @@ import (
"heckel.io/ntfy/v2/util/sprig"
)
var (
//go:embed templates
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
templatesDir = "templates"
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
// templatePrintfLargeSizeRegex matches a printf directive whose width or precision is a star
// (taken from an argument) or has four or more digits, i.e. is at least 1000. It deliberately
// scans the flag/width/precision characters after a % without requiring a well-formed
// directive: fmt pads even malformed ones (e.g. "%000 9999999#" emits 10 MB), so anything
// unrecognized must still be caught.
templatePrintfLargeSizeRegex = regexp.MustCompile(`%[-+# 0-9.*\[\]]*(\*|[0-9]{4})`)
// templateMaxExecutionTime is the wall-clock deadline for a single template render, a DoS guard
// (GHSA-rhwf-xgc9-m9fp). It is a var (not a const) solely so tests can raise it; it is never
// mutated in production.
templateMaxExecutionTime = 100 * time.Millisecond
)
const (
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
templateMaxTemplateBytes = 32 * 1024 // Maximum size of a template (inline or from a template file), used to prevent DoS attacks
templateFileExtension = ".yml" // Template files must end with this extension
)
func (s *Server) handleBodyAsTemplatedTextMessage(ctx context.Context, m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
if err != nil {
@@ -106,11 +136,14 @@ func (s *Server) renderTemplateFromParams(ctx context.Context, m *model.Message,
// renderTemplate renders a template with the given JSON source data.
func (s *Server) renderTemplate(ctx context.Context, name, tpl, source string) (string, error) {
if len(tpl) > templateMaxTemplateBytes {
return "", errHTTPBadRequestTemplateTooLarge
}
var data any
if err := json.Unmarshal([]byte(source), &data); err != nil {
return "", errHTTPBadRequestTemplateMessageNotJSON
}
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Funcs(gotext.FuncMap{"printf": templatePrintf}).Parse(tpl)
if err != nil {
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
}
@@ -168,6 +201,8 @@ func treeContainsDisallowedNode(node parse.Node) bool {
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
case *parse.TemplateNode: // {{template}} or {{block}} invocation
return true
case *parse.ChainNode: // A term followed by field accesses, e.g. (call .x).y
return treeContainsDisallowedNode(n.Node)
case *parse.PipeNode:
if n == nil {
return false
@@ -188,3 +223,18 @@ func treeContainsDisallowedNode(node parse.Node) bool {
}
return false
}
// templatePrintf is the template builtin printf, guarded against memory amplification: fmt
// allows widths and precisions up to 1e6 per verb, so a small template like
// {{printf "%999999d%999999d..." ...}} can allocate gigabytes inside a single fmt call -- and the
// executor's cancellation context is only checked between template nodes, never inside one.
// Widths and precisions of 1000 or more are therefore rejected, as is the star (*) form, which
// takes the width from an argument. Combined with the template size limit, this bounds a single
// render to a few MB. Registered via Funcs, which takes precedence over the builtin, and checked
// at call time so a format string assembled during execution is covered too.
func templatePrintf(format string, args ...any) (string, error) {
if templatePrintfLargeSizeRegex.MatchString(strings.ReplaceAll(format, "%%", "")) { // Strip escaped percent signs, they take no width
return "", errors.New("printf width or precision too large")
}
return fmt.Sprintf(format, args...), nil
}
+131
View File
@@ -0,0 +1,131 @@
package server
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestServer_MessageTemplate_TooLarge(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
response := request(t, s, "PUT", "/mytopic", `{"foo":"bar"}`, map[string]string{
"X-Message": "{{.foo}}" + strings.Repeat("x", 33*1024),
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40056, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_MessageTemplate_PrintfWidthTooLarge(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// A handful of 1MB-wide verbs would allocate several MB inside a single fmt call, where
// the executor's context is never checked; the printf guard must reject the call before
// fmt runs, not after the limit writer sees the output
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
"X-Message": `{{printf "%1000000d%1000000d%1000000d" .n .n .n}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
require.Contains(t, response.Body.String(), "printf width or precision too large")
})
}
func TestServer_MessageTemplate_PrintfWidthTooLarge_DynamicFormat(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// The format string is assembled at execution time, so the guard must inspect the actual
// argument, not the template source
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
"X-Message": `{{$f := print "%" "999999" "d" "%" "999999" "d"}}{{printf $f .n .n}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Contains(t, response.Body.String(), "printf width or precision too large")
})
}
func TestServer_MessageTemplate_PrintfStarWidthTooLarge(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// Star widths take the width from an argument; sprig's math functions (int64 results)
// make large integer arguments reachable from a template
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
"X-Message": `{{printf "%*d" (mul 1000 2000) 1}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Contains(t, response.Body.String(), "printf width or precision too large")
})
}
func TestServer_MessageTemplate_PrintfSmallWidthStillWorks(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
response := request(t, s, "PUT", "/mytopic", `{"n":7}`, map[string]string{
"X-Message": `{{printf "%05d" 7}}`,
"X-Template": "1",
})
require.Equal(t, 200, response.Code)
require.Equal(t, "00007", toMessage(t, response.Body.String()).Message)
})
}
func Test_templatePrintf(t *testing.T) {
tests := []struct {
format string
args []any
want string // Empty means the call must be rejected
}{
{"%d", []any{5}, "5"},
{"%05d", []any{5}, "00005"},
{"%-8.3f|", []any{1.5}, "1.500 |"},
{"%1000d", []any{1}, ""}, // Rejected: four digits
{"%.1000s", []any{"x"}, ""},
{"%*d", []any{500, 1}, ""}, // Rejected: star width
{"%.*s", []any{400, "x"}, ""}, // Rejected: star precision
{"%[1]1000000d", []any{1}, ""}, // Rejected: explicit arg index does not hide the width
{"%[2]*[1]d", []any{6, 12}, ""}, // Rejected: star width behind an arg index
{"100%% of 2024 values", nil, "100% of 2024 values"}, // Literal digits are not a width
}
for _, test := range tests {
out, err := templatePrintf(test.format, test.args...)
if test.want == "" {
require.Error(t, err, "format %q must be rejected", test.format)
require.Contains(t, err.Error(), "too large")
} else {
require.Nil(t, err, "format %q", test.format)
require.Equal(t, test.want, out)
}
}
// The largest allowed width still produces bounded output
out, err := templatePrintf("%999d", 1)
require.Nil(t, err)
require.Len(t, out, 999)
}
func TestServer_MessageTemplate_DisallowedCallInChain(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// {{call}} behind a field access parses into a ChainNode. JSON data cannot produce a
// function value, so this cannot be exploited today, but the ban must catch every
// syntactic form rather than relying on the call failing at runtime.
response := request(t, s, "PUT", "/mytopic", `{"fn":1}`, map[string]string{
"X-Message": `{{(call .fn).x}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40044, toHTTPError(t, response.Body.String()).Code)
})
}
+10 -5
View File
@@ -20,11 +20,12 @@ const (
// topic represents a channel to which subscribers can subscribe, and publishers
// can publish a message
type topic struct {
ID string
subscribers map[int]*topicSubscriber
rateVisitor *visitor
lastAccess time.Time
mu sync.RWMutex
ID string
subscribers map[int]*topicSubscriber
rateVisitor *visitor
lastAccess time.Time
onFirstSubscriber func() // Fired (async) when the subscriber count goes 0 -> 1; may be nil
mu sync.RWMutex
}
type topicSubscriber struct {
@@ -56,6 +57,10 @@ func (t *topic) Subscribe(s subscriber, userID string, cancel func()) (subscribe
break
}
}
if len(t.subscribers) == 0 && t.onFirstSubscriber != nil {
// Fired async so cluster announcements never run under the topic lock
go t.onFirstSubscriber()
}
t.subscribers[subscriberID] = &topicSubscriber{
userID: userID, // May be empty
subscriber: s,
+11
View File
@@ -29,6 +29,17 @@ type publishMessage struct {
Delay string `json:"delay"`
}
// dispatchOpts selects which delivery targets fire for a published message, beyond delivery
// to local subscribers and the cross-node forward, which always happen (see Server.dispatch)
type dispatchOpts struct {
firebase bool // Send to Firebase (if configured)
email string // Send an email to this address (if a mailer is configured)
call string // Call this phone number (if Twilio is configured)
upstream bool // Forward a poll request to the upstream server (if configured)
webPush bool // Publish to web push endpoints (if configured)
async bool // Deliver to local subscribers in a goroutine, logging errors instead of returning them
}
// messageEncoder is a function that knows how to encode a message
type messageEncoder func(msg *model.Message) (string, error)
+1
View File
@@ -33,6 +33,7 @@ const (
tokenLength = 32
tokenMaxCount = 60 // Only keep this many tokens in the table per user
tag = "user_manager"
schemaStore = "user" // Store name in the schema_version table (see db/schema)
)
// Default constants that may be overridden by configs
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"strings"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
)
// PostgreSQL queries
@@ -348,7 +349,7 @@ var postgresQueries = queries{
// NewPostgresManager creates a new Manager backed by a PostgreSQL database
func NewPostgresManager(d *db.DB, config *Config) (*Manager, error) {
if err := setupPostgres(d.Primary()); err != nil {
if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, postgresMigrations); err != nil {
return nil, err
}
return newManager(d, postgresQueries, config)
+11 -65
View File
@@ -1,8 +1,7 @@
package user
import (
"database/sql"
"fmt"
"heckel.io/ntfy/v2/db/schema"
)
// Initial PostgreSQL schema
@@ -90,21 +89,14 @@ const (
PRIMARY KEY (token_hash)
);
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
ON CONFLICT (id) DO NOTHING;
`
)
// Schema table management queries for Postgres
const (
postgresCurrentSchemaVersion = 8
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
postgresCurrentSchemaVersion = 9
)
const (
@@ -133,62 +125,16 @@ const (
);
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
`
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
)
var postgresMigrations = map[int]func(db *sql.DB) error{
6: postgresMigrateFrom6,
7: postgresMigrateFrom7,
}
var (
postgresCreateTables = schema.AsMigrateFunc(postgresCreateTablesQueries)
func setupPostgres(db *sql.DB) error {
var schemaVersion int
err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
if err != nil {
return setupNewPostgres(db)
// postgresMigrations maps a schema version to the migration upgrading it to the next
// version. Always append migrations at the end, never insert in the middle.
postgresMigrations = map[int]schema.MigrateFunc{
6: schema.AsMigrateFunc(postgresMigrate6To7UpdateQueries),
7: schema.AsMigrateFunc(postgresMigrate7To8UpdateQueries),
8: schema.NopMigrateFunc, // 8 -> 9 repairs a SQLite-only foreign key defect; nothing to do on Postgres
}
if schemaVersion == postgresCurrentSchemaVersion {
return nil
} else if schemaVersion > postgresCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
}
for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
fn, ok := postgresMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db); err != nil {
return err
}
}
return nil
}
func postgresMigrateFrom6(db *sql.DB) error {
if _, err := db.Exec(postgresMigrate6To7UpdateQueries); err != nil {
return err
}
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
}
func postgresMigrateFrom7(db *sql.DB) error {
if _, err := db.Exec(postgresMigrate7To8UpdateQueries); err != nil {
return err
}
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 8); err != nil {
return err
}
return nil
}
func setupNewPostgres(db *sql.DB) error {
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
return err
}
if _, err := db.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil {
return err
}
return nil
}
)
+6 -2
View File
@@ -9,6 +9,7 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
"heckel.io/ntfy/v2/util"
)
@@ -351,14 +352,17 @@ func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager
// Open with case-sensitive LIKE. ACL topic matching is done via LIKE (see
// selectTopicPerms), and SQLite's LIKE is case-insensitive for ASCII by
// default -- without this, an ACL rule for "secret" would also match a
// request for "SECRET", which is a security iisue. PostgreSQL's LIKE is
// request for "SECRET", which is a security issue. PostgreSQL's LIKE is
// already case-sensitive, so this only affects SQLite. The pragma is
// applied to every pooled connection by the driver.
d, err := sql.Open("sqlite3", fmt.Sprintf("%s?_case_sensitive_like=on", filename))
if err != nil {
return nil, err
}
if err := setupSQLite(d); err != nil {
// Migrations must run before the startup queries: the 5 -> 6 table rebuilds rely on
// foreign keys being OFF, which is only guaranteed on fresh connections (the foreign_keys
// pragma is enabled as part of the builtin startup queries below)
if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations); err != nil {
return nil, err
}
if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
+64 -175
View File
@@ -2,10 +2,8 @@ package user
import (
"database/sql"
"fmt"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/db/schema"
"heckel.io/ntfy/v2/util"
)
@@ -105,10 +103,6 @@ const (
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
@@ -119,12 +113,8 @@ const (
sqliteBuiltinStartupQueries = `PRAGMA foreign_keys = ON;`
)
// Schema version table management for SQLite
const (
sqliteCurrentSchemaVersion = 8
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
sqliteCurrentSchemaVersion = 9
)
// Schema migrations for SQLite
@@ -190,10 +180,6 @@ const (
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
@@ -270,10 +256,11 @@ const (
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
`
// 5 -> 6
// 5 -> 6. The table rebuilds below rely on foreign keys being OFF (otherwise RENAME
// would rewrite the childrens' REFERENCES clauses to point at the _old tables). This is
// guaranteed because migrations run on fresh connections, before the startup queries
// enable the foreign_keys pragma; see NewSQLiteManager.
sqliteMigrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
-- Alter user table: Add provisioned column
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS user (
@@ -359,57 +346,45 @@ const (
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
`
-- Re-enable foreign keys
PRAGMA foreign_keys=on;
// 8 -> 9: Repair the user_phone foreign key. The 5 -> 6 migration renamed user to
// user_old, which rewrote user_phone's REFERENCES clause to user_old -- a table that was
// then dropped (the rebuilt tables got correct fresh foreign keys; user_phone was the only
// child table not rebuilt). Rebuilding user_phone re-points the foreign key at user; on
// healthy databases the rebuild is a harmless no-op schema-wise.
sqliteMigrate8To9UpdateQueries = `
ALTER TABLE user_phone RENAME TO user_phone_old;
CREATE TABLE user_phone (
user_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_phone (user_id, phone_number)
SELECT user_id, phone_number FROM user_phone_old
WHERE user_id IN (SELECT id FROM user); -- Drop orphaned rows that the broken foreign key failed to cascade-delete
DROP TABLE user_phone_old;
`
)
var (
sqliteMigrations = map[int]func(db *sql.DB) error{
sqliteCreateTables = schema.AsMigrateFunc(sqliteCreateTablesQueries)
// sqliteMigrations maps a schema version to the migration upgrading it to the next
// version. Always append migrations at the end, never insert in the middle.
sqliteMigrations = map[int]schema.MigrateFunc{
1: sqliteMigrateFrom1,
2: sqliteMigrateFrom2,
3: sqliteMigrateFrom3,
4: sqliteMigrateFrom4,
5: sqliteMigrateFrom5,
6: sqliteMigrateFrom6,
7: sqliteMigrateFrom7,
2: schema.AsMigrateFunc(sqliteMigrate2To3UpdateQueries),
3: schema.AsMigrateFunc(sqliteMigrate3To4UpdateQueries),
4: schema.AsMigrateFunc(sqliteMigrate4To5UpdateQueries),
5: schema.AsMigrateFunc(sqliteMigrate5To6UpdateQueries),
6: schema.AsMigrateFunc(sqliteMigrate6To7UpdateQueries),
7: schema.AsMigrateFunc(sqliteMigrate7To8UpdateQueries),
8: schema.AsMigrateFunc(sqliteMigrate8To9UpdateQueries),
}
)
func setupSQLite(db *sql.DB) error {
var schemaVersion int
if err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {
return setupNewSQLite(db)
}
if schemaVersion == sqliteCurrentSchemaVersion {
return nil
} else if schemaVersion > sqliteCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion)
}
for i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {
fn, ok := sqliteMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db); err != nil {
return err
}
}
return nil
}
func setupNewSQLite(sqlDB *sql.DB) error {
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteCreateTablesQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {
return err
}
return nil
})
}
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil {
return err
@@ -422,122 +397,36 @@ func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
return nil
}
func sqliteMigrateFrom1(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 1 to 2")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
// Rename user -> user_old, and create new tables
if _, err := tx.Exec(sqliteMigrate1To2CreateTablesQueries); err != nil {
func sqliteMigrateFrom1(tx *sql.Tx) error {
// Rename user -> user_old, and create new tables
if _, err := tx.Exec(sqliteMigrate1To2CreateTablesQueries); err != nil {
return err
}
// Insert users from user_old into new user table, with ID and sync_topic
rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery)
if err != nil {
return err
}
defer rows.Close()
usernames := make([]string, 0)
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return err
}
// Insert users from user_old into new user table, with ID and sync_topic
rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery)
if err != nil {
usernames = append(usernames, username)
}
if err := rows.Close(); err != nil {
return err
}
for _, username := range usernames {
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
if _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTxQuery, userID, syncTopic, username); err != nil {
return err
}
defer rows.Close()
usernames := make([]string, 0)
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return err
}
usernames = append(usernames, username)
}
if err := rows.Close(); err != nil {
return err
}
for _, username := range usernames {
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
if _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTxQuery, userID, syncTopic, username); err != nil {
return err
}
}
// Migrate old "access" table to "user_access" and drop "access" and "user_old"
if _, err := tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom2(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 2 to 3")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate2To3UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom3(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 3 to 4")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate3To4UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom4(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 4 to 5")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate4To5UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom5(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 5 to 6")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate5To6UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom6(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 6 to 7")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate6To7UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom7(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 7 to 8")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate7To8UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
return err
}
return nil
})
}
// Migrate old "access" table to "user_access" and drop "access" and "user_old"
_, err = tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery)
return err
}
+147 -2
View File
@@ -14,6 +14,7 @@ import (
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/db/schema"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/util"
)
@@ -1566,6 +1567,83 @@ func TestToFromSQLWildcard(t *testing.T) {
require.Equal(t, "foo", fromSQLWildcard(toSQLWildcard("foo")))
}
// testPostgresV6Schema is the PostgreSQL schema exactly as created by the version that first
// shipped Postgres support (schema version 6), taken from the code at that time; used to
// verify the migration chain from its oldest supported version.
const testPostgresV6Schema = `
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
messages_limit BIGINT NOT NULL,
messages_expiry_duration BIGINT NOT NULL,
emails_limit BIGINT NOT NULL,
calls_limit BIGINT NOT NULL,
reservations_limit BIGINT NOT NULL,
attachment_file_size_limit BIGINT NOT NULL,
attachment_total_size_limit BIGINT NOT NULL,
attachment_expiry_duration BIGINT NOT NULL,
attachment_bandwidth_limit BIGINT NOT NULL,
stripe_monthly_price_id TEXT,
stripe_yearly_price_id TEXT,
UNIQUE(code),
UNIQUE(stripe_monthly_price_id),
UNIQUE(stripe_yearly_price_id)
);
CREATE TABLE IF NOT EXISTS "user" (
id TEXT PRIMARY KEY,
tier_id TEXT REFERENCES tier(id),
user_name TEXT NOT NULL UNIQUE,
pass TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('anonymous', 'admin', 'user')),
prefs JSONB NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned BOOLEAN NOT NULL,
stats_messages BIGINT NOT NULL DEFAULT 0,
stats_emails BIGINT NOT NULL DEFAULT 0,
stats_calls BIGINT NOT NULL DEFAULT 0,
stripe_customer_id TEXT UNIQUE,
stripe_subscription_id TEXT UNIQUE,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until BIGINT,
stripe_subscription_cancel_at BIGINT,
created BIGINT NOT NULL,
deleted BIGINT
);
CREATE TABLE IF NOT EXISTS user_access (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
topic TEXT NOT NULL,
read BOOLEAN NOT NULL,
write BOOLEAN NOT NULL,
owner_user_id TEXT REFERENCES "user"(id) ON DELETE CASCADE,
provisioned BOOLEAN NOT NULL,
PRIMARY KEY (user_id, topic)
);
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
last_access BIGINT NOT NULL,
last_origin TEXT NOT NULL,
expires BIGINT NOT NULL,
provisioned BOOLEAN NOT NULL,
PRIMARY KEY (user_id, token)
);
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number)
);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
VALUES ('u_everyone', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
ON CONFLICT (id) DO NOTHING;
`
func TestMigrationFrom1(t *testing.T) {
filename := filepath.Join(t.TempDir(), "user.db")
db, err := sql.Open("sqlite3", filename)
@@ -1649,6 +1727,8 @@ func TestMigrationFrom1(t *testing.T) {
require.Equal(t, 1, len(everyoneGrants))
require.Equal(t, "stats", everyoneGrants[0].TopicPattern)
require.Equal(t, PermissionRead, everyoneGrants[0].Permission)
checkMigratedSqliteSchema(t, filename)
}
func TestMigrationFrom4(t *testing.T) {
@@ -1740,12 +1820,15 @@ func TestMigrationFrom4(t *testing.T) {
`)
require.Nil(t, err)
// Insert a few ACL entries
// Insert a few ACL entries, and phone numbers: one for a live user, one orphaned (its user
// is gone; the broken pre-v9 foreign key never cascade-deleted it)
_, err = db.Exec(`
BEGIN;
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'mytopic_', 1, 1);
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'up%', 1, 1);
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'down_%', 1, 1);
INSERT INTO user_phone (user_id, phone_number) VALUES ('u_everyone', '+12223334444');
INSERT INTO user_phone (user_id, phone_number) VALUES ('u_gone', '+15556667777');
COMMIT;
`)
require.Nil(t, err)
@@ -1795,6 +1878,68 @@ func TestMigrationFrom4(t *testing.T) {
require.Nil(t, a.Authorize(nil, "up123", PermissionRead))
require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters
// The 8 -> 9 repair kept the live user's phone number and dropped the orphaned row
phoneNumbers := make([]string, 0)
rows, err = db.Query(`SELECT phone_number FROM user_phone ORDER BY phone_number`)
require.Nil(t, err)
for rows.Next() {
var phoneNumber string
require.Nil(t, rows.Scan(&phoneNumber))
phoneNumbers = append(phoneNumbers, phoneNumber)
}
require.Nil(t, rows.Close())
require.Equal(t, []string{"+12223334444"}, phoneNumbers)
checkMigratedSqliteSchema(t, filename)
}
// TestMigrationFrom6Postgres tests the Postgres migration chain from its oldest supported
// version (6, the version PostgreSQL support first shipped with).
func TestMigrationFrom6Postgres(t *testing.T) {
testDB := dbtest.CreateTestPostgres(t)
_, err := testDB.Exec(testPostgresV6Schema)
require.Nil(t, err)
_, err = testDB.Exec(`INSERT INTO schema_version (store, version) VALUES ('user', 6)`)
require.Nil(t, err)
// Create manager to trigger migration
a, err := NewPostgresManager(testDB, &Config{DefaultAccess: PermissionDenyAll, BcryptCost: bcrypt.MinCost, QueueWriterInterval: DefaultUserStatsQueueWriterInterval})
require.Nil(t, err)
var version int
require.Nil(t, testDB.QueryRow(`SELECT version FROM schema_version WHERE store = 'user'`).Scan(&version))
require.Equal(t, postgresCurrentSchemaVersion, version)
// The manager works against the migrated schema
require.Nil(t, a.AddUser("phil", "mypass", RoleUser, false))
u, err := a.User("phil")
require.Nil(t, err)
require.Nil(t, a.AddEmail(u.ID, "phil@example.com"))
// The migrated database must be structurally identical to a freshly created one
freshDB := dbtest.CreateTestPostgres(t)
_, err = NewPostgresManager(freshDB, &Config{DefaultAccess: PermissionDenyAll, BcryptCost: bcrypt.MinCost, QueueWriterInterval: DefaultUserStatsQueueWriterInterval})
require.Nil(t, err)
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, testDB))
}
// checkMigratedSqliteSchema verifies that a migrated database is structurally identical to a
// freshly created one (this pins, among other things, that the foreign keys of the tables
// rebuilt in migration 5 -> 6 still point at "user", not at a dropped "user_old"), and that
// its data passes SQLite's foreign key consistency check.
func checkMigratedSqliteSchema(t *testing.T, filename string) {
t.Helper()
freshFile := filepath.Join(t.TempDir(), "fresh.db")
fresh := newTestManagerFromFile(t, freshFile, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)
defer fresh.Close()
freshDB, err := sql.Open("sqlite3", freshFile)
require.Nil(t, err)
defer freshDB.Close()
migratedDB, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
defer migratedDB.Close()
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
rows, err := migratedDB.Query(`PRAGMA foreign_key_check`)
require.Nil(t, err)
defer rows.Close()
require.False(t, rows.Next(), "foreign_key_check reported violations in the migrated database")
}
func checkSchemaVersion(t *testing.T, d *db.DB) {
@@ -3352,7 +3497,7 @@ func TestManager_Emails_PrimaryFlagAndHelpers(t *testing.T) {
func openReplicaTestSQLite(t *testing.T, filename string) *sql.DB {
d, err := sql.Open("sqlite3", filename+"?_case_sensitive_like=on")
require.Nil(t, err)
require.Nil(t, setupSQLite(d))
require.Nil(t, schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations))
return d
}
+103
View File
@@ -0,0 +1,103 @@
package util
import (
"errors"
"hash/fnv"
"math"
)
// BloomFilter is a fixed-size probabilistic set: Contains may return false positives (bounded by
// the target rate the filter was sized for) but never false negatives. Elements cannot be
// removed; rebuild the filter from scratch instead.
type BloomFilter struct {
bits []uint64
k int // number of hash probes per element, derived via double hashing
}
// NewBloomFilter creates a filter sized for n elements at the given false-positive rate.
func NewBloomFilter(n int, fpRate float64) *BloomFilter {
if n < 1 {
n = 1
}
if fpRate <= 0 || fpRate >= 1 {
fpRate = 0.01
}
m := int(math.Ceil(-float64(n) * math.Log(fpRate) / (math.Ln2 * math.Ln2))) // bits
k := int(math.Round(float64(m) / float64(n) * math.Ln2)) // probes
if k < 1 {
k = 1
}
return &BloomFilter{
bits: make([]uint64, (m+63)/64),
k: k,
}
}
// Add inserts an element into the filter.
func (b *BloomFilter) Add(s string) {
h1, h2 := hashPair(s)
m := uint64(len(b.bits)) * 64
for i := 0; i < b.k; i++ {
bit := (h1 + uint64(i)*h2) % m
b.bits[bit/64] |= 1 << (bit % 64)
}
}
// Contains reports whether the element may be in the set. A false result is definitive: the
// element was never added.
func (b *BloomFilter) Contains(s string) bool {
h1, h2 := hashPair(s)
m := uint64(len(b.bits)) * 64
for i := 0; i < b.k; i++ {
bit := (h1 + uint64(i)*h2) % m
if b.bits[bit/64]&(1<<(bit%64)) == 0 {
return false
}
}
return true
}
// MarshalBinary serializes the filter as [k][bits...], with 64-bit little-endian words.
func (b *BloomFilter) MarshalBinary() ([]byte, error) {
data := make([]byte, 1+len(b.bits)*8)
data[0] = byte(b.k)
for i, word := range b.bits {
for j := 0; j < 8; j++ {
data[1+i*8+j] = byte(word >> (8 * j))
}
}
return data, nil
}
// UnmarshalBloomFilter deserializes a filter produced by MarshalBinary.
func UnmarshalBloomFilter(data []byte) (*BloomFilter, error) {
if len(data) < 9 || (len(data)-1)%8 != 0 {
return nil, errors.New("invalid bloom filter data")
}
b := &BloomFilter{
bits: make([]uint64, (len(data)-1)/8),
k: int(data[0]),
}
if b.k < 1 {
return nil, errors.New("invalid bloom filter hash count")
}
for i := range b.bits {
var word uint64
for j := 0; j < 8; j++ {
word |= uint64(data[1+i*8+j]) << (8 * j)
}
b.bits[i] = word
}
return b, nil
}
// hashPair derives the two independent hash values used for double hashing (probe i uses
// h1 + i*h2), from FNV-1a over the element and a domain-separated variant of it.
func hashPair(s string) (uint64, uint64) {
f := fnv.New64a()
f.Write([]byte(s))
h1 := f.Sum64()
f.Write([]byte{0xff}) // Domain separation for the second hash
h2 := f.Sum64() | 1 // Odd, so probes cycle through all bit positions
return h1, h2
}
+61
View File
@@ -0,0 +1,61 @@
package util_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/util"
)
func TestBloomFilter_NoFalseNegatives(t *testing.T) {
// The property routing correctness relies on: an added element is ALWAYS reported present
b := util.NewBloomFilter(1000, 0.01)
for i := 0; i < 1000; i++ {
b.Add(fmt.Sprintf("topic-%d", i))
}
for i := 0; i < 1000; i++ {
require.True(t, b.Contains(fmt.Sprintf("topic-%d", i)))
}
}
func TestBloomFilter_FalsePositiveRate(t *testing.T) {
b := util.NewBloomFilter(10000, 0.01)
for i := 0; i < 10000; i++ {
b.Add(fmt.Sprintf("added-%d", i))
}
falsePositives := 0
for i := 0; i < 10000; i++ {
if b.Contains(fmt.Sprintf("absent-%d", i)) {
falsePositives++
}
}
require.Less(t, falsePositives, 300, "expected ~1%% false positives, got %d/10000", falsePositives)
}
func TestBloomFilter_EmptyContainsNothing(t *testing.T) {
b := util.NewBloomFilter(100, 0.01)
require.False(t, b.Contains("anything"))
}
func TestBloomFilter_MarshalRoundTrip(t *testing.T) {
b := util.NewBloomFilter(500, 0.01)
for i := 0; i < 500; i++ {
b.Add(fmt.Sprintf("topic-%d", i))
}
data, err := b.MarshalBinary()
require.Nil(t, err)
b2, err := util.UnmarshalBloomFilter(data)
require.Nil(t, err)
for i := 0; i < 500; i++ {
require.True(t, b2.Contains(fmt.Sprintf("topic-%d", i)))
}
require.False(t, b2.Contains("never-added-topic"))
}
func TestBloomFilter_UnmarshalGarbage(t *testing.T) {
_, err := util.UnmarshalBloomFilter([]byte{})
require.Error(t, err)
_, err = util.UnmarshalBloomFilter([]byte{1, 2, 3})
require.Error(t, err)
}
+144
View File
@@ -0,0 +1,144 @@
package util
import (
"sync"
"time"
)
// LingerQueue is a bounded, non-blocking batching queue: enqueued elements are emitted as
// batches once a linger window expires, a batch count cap is reached, or a batch size cap is
// reached, whichever comes first. Unlike BatchingQueue, producers never block: TryEnqueue drops
// (returns false) when the queue is full, and Close flushes the remainder and terminates the
// consumer channel, so per-entity queues can be created and destroyed dynamically.
//
// Example:
//
// q := NewLingerQueue[int](64, 10, 0, nil, 500*time.Millisecond)
// go func() {
// for batch := range q.Dequeue() {
// send(batch)
// }
// }()
// q.TryEnqueue(1)
// q.TryEnqueue(2) // emitted together as [1, 2] after <= 500ms
type LingerQueue[T any] struct {
in chan T
out chan []T
max int // max elements per batch
maxSize int // max cumulative size per batch; 0 = no size cap
size func(T) int // element size function; nil = no size cap
linger time.Duration // max time the first element of a batch waits; 0 = emit immediately
closed bool
mu sync.Mutex // Protects closed, and guards TryEnqueue's send against Close's close(in)
}
// NewLingerQueue creates a LingerQueue holding up to capacity queued elements, emitting batches
// of up to max elements or maxSize cumulative size (as measured by size; pass 0/nil for no size
// cap) after at most linger.
func NewLingerQueue[T any](capacity, max, maxSize int, size func(T) int, linger time.Duration) *LingerQueue[T] {
q := &LingerQueue[T]{
in: make(chan T, capacity),
out: make(chan []T),
max: max,
maxSize: maxSize,
size: size,
linger: linger,
}
go q.run()
return q
}
// TryEnqueue enqueues an element without blocking. It returns false if the queue is full or
// closed; the caller decides how to account for the drop.
func (q *LingerQueue[T]) TryEnqueue(t T) bool {
q.mu.Lock()
defer q.mu.Unlock()
if q.closed {
return false
}
select {
case q.in <- t:
return true
default:
return false
}
}
// Dequeue returns the channel emitting batches. It is closed after Close, once the remaining
// elements have been flushed.
func (q *LingerQueue[T]) Dequeue() <-chan []T {
return q.out
}
// Close stops the queue: remaining elements are flushed as final batches, then the Dequeue
// channel is closed. TryEnqueue returns false after Close. Close is idempotent.
func (q *LingerQueue[T]) Close() {
q.mu.Lock()
defer q.mu.Unlock()
if q.closed {
return
}
q.closed = true
close(q.in)
}
// run is the batching loop: it blocks for the first element of a batch, then collects more until
// the linger expires or a cap is hit, and emits the batch. It exits once the queue is closed and
// drained. Note that receiving from the closed in channel still yields the buffered remainder
// before reporting closed, which is what flushes on Close.
func (q *LingerQueue[T]) run() {
defer close(q.out)
for {
first, ok := <-q.in
if !ok {
return
}
batch := []T{first}
bytes := q.sizeOf(first)
var timeout <-chan time.Time
if q.linger > 0 {
timeout = time.After(q.linger)
}
closed := false
collect:
for len(batch) < q.max && (q.maxSize <= 0 || bytes < q.maxSize) {
if timeout == nil {
// Zero linger: greedily drain what is immediately available, never wait
select {
case t, ok := <-q.in:
if !ok {
closed = true
break collect
}
batch = append(batch, t)
bytes += q.sizeOf(t)
default:
break collect
}
} else {
select {
case t, ok := <-q.in:
if !ok {
closed = true
break collect
}
batch = append(batch, t)
bytes += q.sizeOf(t)
case <-timeout:
break collect
}
}
}
q.out <- batch
if closed {
return
}
}
}
func (q *LingerQueue[T]) sizeOf(t T) int {
if q.size == nil {
return 0
}
return q.size(t)
}
+90
View File
@@ -0,0 +1,90 @@
package util_test
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/util"
)
func TestLingerQueue_LingerFlush(t *testing.T) {
// Items enqueued within the linger window are emitted as a single batch when it expires
q := util.NewLingerQueue[int](16, 100, 0, nil, 50*time.Millisecond)
defer q.Close()
require.True(t, q.TryEnqueue(1))
require.True(t, q.TryEnqueue(2))
require.True(t, q.TryEnqueue(3))
start := time.Now()
batch := <-q.Dequeue()
require.Equal(t, []int{1, 2, 3}, batch)
require.GreaterOrEqual(t, time.Since(start), 30*time.Millisecond) // Waited out the linger
}
func TestLingerQueue_MaxBatchFlush(t *testing.T) {
// Hitting the count cap flushes early, before the linger expires
q := util.NewLingerQueue[int](16, 5, 0, nil, time.Minute)
defer q.Close()
for i := 0; i < 12; i++ {
require.True(t, q.TryEnqueue(i))
}
require.Len(t, <-q.Dequeue(), 5)
require.Len(t, <-q.Dequeue(), 5)
q.Close() // Flushes the remainder
require.Len(t, <-q.Dequeue(), 2)
}
func TestLingerQueue_SizeCapFlush(t *testing.T) {
// Hitting the byte cap flushes early, before count cap or linger
q := util.NewLingerQueue(16, 100, 10, func(s string) int { return len(s) }, time.Minute)
defer q.Close()
require.True(t, q.TryEnqueue("aaaa"))
require.True(t, q.TryEnqueue("bbbb"))
require.True(t, q.TryEnqueue("cccc")) // 12 bytes >= 10 -> flush
batch := <-q.Dequeue()
require.Equal(t, []string{"aaaa", "bbbb", "cccc"}, batch)
}
func TestLingerQueue_TryEnqueueFull(t *testing.T) {
// A full queue drops (returns false) instead of blocking the producer
q := util.NewLingerQueue[int](1, 1, 0, nil, 0)
defer q.Close()
require.True(t, q.TryEnqueue(1)) // Taken by the batcher, blocks emitting (no consumer)
waitForCond(t, func() bool { return q.TryEnqueue(2) }) // Fills the buffer once slot frees
require.False(t, q.TryEnqueue(3)) // Buffer full, batcher blocked -> drop
}
func TestLingerQueue_CloseFlushesAndCloses(t *testing.T) {
q := util.NewLingerQueue[int](16, 100, 0, nil, time.Minute)
require.True(t, q.TryEnqueue(1))
require.True(t, q.TryEnqueue(2))
q.Close()
require.Equal(t, []int{1, 2}, <-q.Dequeue()) // Remainder flushed without waiting out the linger
_, ok := <-q.Dequeue()
require.False(t, ok) // Channel closed
require.False(t, q.TryEnqueue(3))
q.Close() // Idempotent
}
func TestLingerQueue_LingerZeroImmediate(t *testing.T) {
q := util.NewLingerQueue[int](16, 100, 0, nil, 0)
defer q.Close()
require.True(t, q.TryEnqueue(1))
select {
case batch := <-q.Dequeue():
require.Contains(t, batch, 1)
case <-time.After(time.Second):
t.Fatal("expected immediate flush with zero linger")
}
}
func waitForCond(t *testing.T, f func() bool) {
t.Helper()
for i := 0; i < 100; i++ {
if f() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("timed out waiting for condition")
}
+1
View File
@@ -12,6 +12,7 @@ const (
loopExecutionLimit = 10_000 // Limit the number of loop executions to prevent execution from taking too long
stringLengthLimit = 100_000 // Limit the length of strings to prevent memory issues
sliceSizeLimit = 10_000 // Limit the size of slices to prevent memory issues
indentSpacesLimit = 100 // Limit indentation width to prevent memory issues; indent allocates spaces*lines bytes
)
// TxtFuncMap produces the function map.
+7
View File
@@ -116,6 +116,7 @@ func cat(v ...any) string {
}
// indent adds a specified number of spaces at the beginning of each line in a string.
// It has a safety limit to prevent excessive memory usage.
//
// Parameters:
// - spaces: The number of spaces to add
@@ -123,7 +124,13 @@ func cat(v ...any) string {
//
// Returns:
// - string: The indented string
//
// Panics:
// - If spaces exceeds indentSpacesLimit
func indent(spaces int, v string) string {
if spaces > indentSpacesLimit {
panic(fmt.Sprintf("indent %d exceeds limit of %d", spaces, indentSpacesLimit))
}
pad := strings.Repeat(" ", spaces)
return pad + strings.Replace(v, "\n", "\n"+pad, -1)
}
+20
View File
@@ -4,6 +4,7 @@ import (
"encoding/base32"
"encoding/base64"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -214,6 +215,25 @@ func TestNindent(t *testing.T) {
}
}
func TestIndentLimit(t *testing.T) {
// Indentation beyond a sane width is an amplification attempt: indent allocates
// spaces * lines bytes in a single uninterruptible call
if err := runt(`{{indent 100 "a"}}`, strings.Repeat(" ", 100)+"a"); err != nil {
t.Error(err)
}
for _, tpl := range []string{
`{{indent 101 "a"}}`,
`{{nindent 101 "a"}}`,
`{{indent 1000000000 "a"}}`,
} {
if _, err := runRaw(tpl, nil); err == nil {
t.Errorf("expected %s to be rejected", tpl)
} else if !strings.Contains(err.Error(), "exceeds limit") {
t.Errorf("expected limit error for %s, got: %v", tpl, err)
}
}
}
func TestReplace(t *testing.T) {
tpl := `{{"I Am Henry VIII" | replace " " "-"}}`
if err := runt(tpl, "I-Am-Henry-VIII"); err != nil {
+409 -447
View File
File diff suppressed because it is too large Load Diff
+38 -37
View File
@@ -9,44 +9,44 @@
"action_bar_account": "Konts",
"action_bar_profile_title": "Profils",
"action_bar_profile_settings": "Iestatījumi",
"action_bar_profile_logout": "Iziet",
"action_bar_profile_logout": "Atteikties",
"nav_button_account": "Konts",
"nav_button_settings": "Iestatījumi",
"nav_button_documentation": "Dokumentācija",
"nav_button_connecting": "savienojas",
"notifications_list_item": "Paziņojums",
"notifications_delete": "Dzēst",
"notifications_delete": "Izdzēst",
"notifications_tags": "Birkas",
"notifications_example": "Piemērs",
"publish_dialog_title_label": "Virsraksts",
"publish_dialog_message_label": "Ziņojums",
"publish_dialog_tags_label": "Birkas",
"publish_dialog_priority_label": "Prioritāte",
"publish_dialog_priority_label": "Svarīgums",
"publish_dialog_email_label": "E-pasta adrese",
"publish_dialog_filename_label": "Datnes nosaukums",
"publish_dialog_delay_label": "Aizkave",
"publish_dialog_button_cancel": "Atcelt",
"publish_dialog_button_send": "Sūtīt",
"publish_dialog_button_send": "Nosūtīt",
"subscribe_dialog_subscribe_button_cancel": "Atcelt",
"subscribe_dialog_subscribe_button_subscribe": "Abonēt",
"subscribe_dialog_login_password_label": "Parole",
"subscribe_dialog_error_user_anonymous": "anonīms lietotājs",
"subscribe_dialog_error_user_anonymous": "nezināms lietotājs",
"account_basics_title": "Konts",
"account_basics_username_title": "Lietotājvārds",
"account_basics_password_title": "Parole",
"account_basics_phone_numbers_dialog_channel_sms": "Nosūtīt īsziņu",
"account_basics_phone_numbers_dialog_channel_call": "Zvanīt",
"account_basics_phone_numbers_dialog_channel_sms": "Īsziņa",
"account_basics_phone_numbers_dialog_channel_call": "Zvans",
"account_usage_title": "Lietojums",
"account_usage_unlimited": "Neierobežots",
"account_basics_tier_admin": "Administrators",
"account_basics_tier_admin": "Pārvaldītājs",
"account_basics_tier_basic": "Pamata",
"account_basics_tier_free": "Bezmaksas",
"account_basics_tier_interval_monthly": "ikmēnesi",
"account_basics_tier_interval_monthly": "ik mēnesi",
"account_basics_tier_interval_yearly": "katru gadu",
"account_basics_tier_change_button": "Mainīt",
"account_delete_dialog_label": "Parole",
"account_delete_dialog_button_cancel": "Atcelt",
"account_upgrade_dialog_interval_monthly": "Ikmēnesi",
"account_upgrade_dialog_interval_monthly": "Ik mēnesi",
"account_upgrade_dialog_interval_yearly": "Katru gadu",
"account_upgrade_dialog_tier_price_per_month": "mēnesī",
"account_upgrade_dialog_tier_selected_label": "Atlasīts",
@@ -62,25 +62,25 @@
"prefs_users_dialog_password_label": "Parole",
"prefs_appearance_title": "Izskats",
"prefs_appearance_language_title": "Valoda",
"prefs_appearance_theme_title": "Motīvs",
"prefs_appearance_theme_title": "Krāsu palete",
"prefs_reservations_table_topic_header": "Tēma",
"prefs_reservations_table_access_header": "Piekļuve",
"prefs_reservations_dialog_topic_label": "Tēma",
"prefs_reservations_dialog_access_label": "Piekļuve",
"priority_min": "minimālā",
"priority_low": "zema",
"priority_min": "viszemākais",
"priority_low": "zems",
"priority_default": "noklusējuma",
"priority_high": "augsta",
"priority_max": "maksimālā",
"signup_form_confirm_password": "Atkārtot paroli",
"priority_high": "augsts",
"priority_max": "visaugstākais",
"signup_form_confirm_password": "Apstiprināt paroli",
"signup_form_button_submit": "Izveidot kontu",
"login_link_signup": "Izveidot kontu",
"action_bar_show_menu": "Rādīt izvēlni",
"action_bar_logo_alt": "ntfy logotips",
"action_bar_reservation_add": "Rezervēt tēmu",
"action_bar_reservation_edit": "Mainīt rezervāciju",
"action_bar_reservation_delete": "Noņemt rezervāciju",
"action_bar_reservation_limit_reached": "Sasniegts limits",
"action_bar_reservation_add": "Aizņemt tēmu",
"action_bar_reservation_edit": "Mainīt aizņemšanu",
"action_bar_reservation_delete": "Noņemt aizņemšanu",
"action_bar_reservation_limit_reached": "Sasniegts ierobežojums",
"action_bar_mute_notifications": "Apklusināt paziņojumus",
"action_bar_sign_up": "Izveidot kontu",
"message_bar_publish": "Publicēt ziņojumu",
@@ -88,38 +88,39 @@
"nav_button_all_notifications": "Visi paziņojumi",
"nav_button_publish_message": "Publicēt paziņojumu",
"nav_button_muted": "Paziņojumi apklusināti",
"alert_notification_permission_required_button": "Dot tagad",
"alert_notification_permission_required_button": "Piešķirt tagad",
"notifications_list": "Paziņojumu saraksts",
"notifications_priority_x": "{{priority}} prioritāte",
"notifications_priority_x": "{{priority}} svarīgums",
"notifications_new_indicator": "Jauns paziņojums",
"notifications_attachment_image": "Pielikuma attēls",
"notifications_attachment_copy_url_button": "Kopēt URL adresi",
"notifications_attachment_copy_url_button": "Ievietot URL starpliktuvē",
"notifications_attachment_open_button": "Atvērt pielikumu",
"notifications_attachment_file_image": "attēla datne",
"notifications_attachment_file_video": "video datne",
"notifications_attachment_file_audio": "audio datne",
"notifications_attachment_file_document": "cits datnes tips",
"notifications_click_copy_url_button": "Kopēt saiti",
"notifications_attachment_file_audio": "skaņas datne",
"notifications_attachment_file_document": "cits datnes veids",
"notifications_click_copy_url_button": "Ievietot saiti starpliktuvē",
"notifications_click_open_button": "Atvērt saiti",
"notifications_actions_failed_notification": "Neveiksmīga darbība",
"notifications_actions_failed_notification": "Nesekmīga darbība",
"publish_dialog_title_no_topic": "Publicēt paziņojumu",
"publish_dialog_progress_uploading": "Augšupielādē …",
"publish_dialog_message_published": "Paziņojums publicēts",
"publish_dialog_message_published": "Paziņojums izdots",
"publish_dialog_emoji_picker_show": "Atlasīt emocijzīmi",
"publish_dialog_priority_min": "Minimāla prioritāte",
"publish_dialog_priority_low": "Zema prioritāte",
"publish_dialog_priority_default": "Noklusējuma prioritāte",
"publish_dialog_priority_high": "Augsta prioritāte",
"publish_dialog_priority_max": "Maksimāla prioritāte",
"publish_dialog_base_url_label": "Pakalpojuma URL adrese",
"publish_dialog_priority_min": "Viszemākais svarīgums",
"publish_dialog_priority_low": "Zems svarīgums",
"publish_dialog_priority_default": "Noklusējuma svarīgums",
"publish_dialog_priority_high": "Augsts svarīgums",
"publish_dialog_priority_max": "Visaugstākais svarīgums",
"publish_dialog_base_url_label": "Pakalpojuma URL",
"publish_dialog_topic_label": "Tēmas nosaukums",
"publish_dialog_topic_reset": "Atiestatīt tēmu",
"publish_dialog_click_label": "Klikšķināma URL adrese",
"publish_dialog_call_label": "Tālruņa zvans",
"publish_dialog_attach_label": "Pielikuma URL adrese",
"publish_dialog_attach_label": "Pielikuma URL",
"publish_dialog_filename_placeholder": "Pielikuma datnes nosaukums",
"publish_dialog_other_features": "Citas funkcijas:",
"publish_dialog_other_features": "Citas iespējas:",
"publish_dialog_chip_call_label": "Tālruņa zvans",
"publish_dialog_chip_delay_label": "Aizkavēt piegādi",
"publish_dialog_chip_topic_label": "Mainīt tēmu"
"publish_dialog_chip_topic_label": "Mainīt tēmu",
"common_close": "Aizvērt"
}
+57 -3
View File
@@ -232,7 +232,7 @@
"account_usage_messages_title": "Mensagens publicadas",
"account_basics_phone_numbers_dialog_verify_button_sms": "Enviar SMS",
"account_basics_tier_change_button": "Mudar",
"account_basics_tier_admin_suffix_with_tier": "(com nível {{tier}})",
"account_basics_tier_admin_suffix_with_tier": "(com tier {{tier}})",
"account_basics_title": "Conta",
"account_basics_phone_numbers_no_phone_numbers_yet": "Ainda não há números de telefone",
"subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome",
@@ -244,7 +244,7 @@
"account_usage_title": "Uso",
"account_basics_tier_upgrade_button": "Atualizar para Pro",
"subscribe_dialog_error_topic_already_reserved": "Tópico já reservado",
"account_basics_tier_admin_suffix_no_tier": "(sem nível)",
"account_basics_tier_admin_suffix_no_tier": "(sem tier)",
"account_basics_tier_payment_overdue": "O teu pagamento está atrasado. Por favor, atualize seu método de pagamento, ou sua conta será rebaixada em breve.",
"account_basics_tier_description": "Nível de poder da sua conta",
"account_basics_tier_free": "Grátis",
@@ -406,5 +406,59 @@
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Um usuário provisionado não pode ser editado ou apagado",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não é possível editar ou apagar o token provisionado"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não é possível editar ou apagar o token provisionado",
"common_refresh": "Atualizar",
"email_verify_progress_title": "Verificando seu email...",
"email_verify_success_title": "E-mail verificado",
"email_verify_success_description": "Seu endereço de e-mail foi verificado e adicionado à sua conta.",
"email_verify_error_title": "A verificação falhou",
"email_verify_error_description": "Esse link de verificação está inválido ou expirou. Você pode solicitar um novo link nas configurações da sua conta.",
"email_verify_button_account": "Ir para conta",
"version_update_available_title": "Nova versão disponível",
"version_update_available_description": "O servidor do ntfy foi atualizado. Por favor, atualize a página.",
"signup_form_email": "Email (opcional, para recuperação de conta)",
"login_link_forgot_password": "Esqueci minha senha",
"reset_password_request_description": "Insira seu nome de usuário ou endereço de e-mail. Se sua conta existir, um link para redefinir sua senha será enviado pelo e-mail.",
"reset_password_request_primary_required": "Isso só funciona se você já tiver adicionado e verificado um endereço de e-mail primário.",
"reset_password_request_identifier_label": "Nome de usuário ou e-mail",
"reset_password_request_button_submit": "Enviar link de redefinição",
"reset_password_sent_title": "Verifique sua caixa de entrada",
"reset_password_sent_description": "Se uma conta existir, um link para redefinir sua senha será enviado por e-mail.",
"reset_password_back_to_login": "Voltar para login",
"reset_password_disabled": "A redefinição de senha está desativada",
"reset_password_title": "Definir nova senha",
"reset_password_form_password": "Nova senha",
"reset_password_form_confirm": "Confirmar nova senha",
"reset_password_form_button_submit": "Definir senha",
"reset_password_form_error_invalid": "Esse link de redefinição está inválido ou expirou. Por favor, peça um novo.",
"reset_password_success_title": "Senha atualizada",
"reset_password_success_description": "Sua senha mudou. Agora você pode entrar com sua nova senha.",
"action_bar_reload": "Atualizar app",
"account_basics_emails_title": "Endereços de e-mail",
"account_basics_emails_description": "Para notificações de e-mail e redefinição de senha",
"account_basics_emails_no_emails_yet": "Nenhum e-mail por enquanto",
"account_basics_emails_copied_to_clipboard": "Endereço de e-mail copiado pra área de transferência",
"account_basics_emails_chip_actions_verified": "Pode ser usado para notificações. Clique para ver ações.",
"account_basics_emails_chip_actions_unverified": "E-mail não verificado, confira sua caixa de entrada para verificar. Clique para ver ações.",
"account_basics_emails_unverified": "não verificado",
"account_basics_emails_set_primary": "Definir como e-mail primário",
"account_basics_emails_delete": "Remover e-mail",
"account_basics_emails_resend": "Reenviar e-mail de verificação",
"account_basics_emails_resent": "E-mail de verificação enviado, confira sua caixa de entrada",
"account_basics_emails_primary_elsewhere": "Esse endereço de e-mail é usado como e-mail primário em outra conta",
"account_basics_emails_no_recovery_warning": "Adicione pelo menos um endereço de e-mail para garantir que você possa recuperar sua conta caso perca sua senha.",
"account_basics_emails_no_primary_warning": "Adicione um endereço de email primário para garantir que você possa recuperar sua conta caso perca sua senha.",
"account_basics_emails_dialog_title": "Adicionar endereço de e-mail",
"account_basics_emails_dialog_description": "Insira um endereço de e-mail para adicionar à sua conta. Um link de verificação será enviado pra confirmar seu e-mail.",
"account_basics_emails_dialog_email_label": "Endereço de e-mail",
"account_basics_emails_dialog_email_placeholder": "exemplo: usuário@exemplo.com",
"account_basics_emails_dialog_verify_button": "Enviar link de verificação",
"account_basics_emails_dialog_check_inbox": "Verifique sua caixa de entrada e clique no link de verificação para confirmar esse endereço de e-mail. Ele aparecerá como não verificado até que você o verifique.",
"account_usage_emails_none": "Nenhuma notificação de e-mail pode ser enviada com essa conta",
"prefs_users_dialog_base_url_invalid": "Formato de URL inválido. Deve começar com http:// ou https://",
"prefs_users_dialog_base_url_exists": "Um usuário para esse serviço de URL já existe",
"common_close": "Fechar",
"reset_password_request_title": "Redefinir senha",
"account_basics_emails_chip_actions_primary": "E-mail primário, usado como seu endereço de e-mail padrão. Clique para ver ações.",
"account_basics_tier_provisioned": "Provisionado"
}
+2 -1
View File
@@ -405,5 +405,6 @@
"prefs_appearance_theme_dark": "Тёмная",
"prefs_appearance_theme_light": "Светлая",
"account_basics_cannot_edit_or_delete_provisioned_user": "Пользователя, созданного автоматически, нельзя изменить или удалить",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматически созданный токен нельзя изменить или удалить"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматически созданный токен нельзя изменить или удалить",
"common_refresh": "Обновить"
}
+57 -3
View File
@@ -236,8 +236,8 @@
"account_basics_tier_upgrade_button": "Pro'ya yükselt",
"account_basics_tier_change_button": "Değiştir",
"account_basics_tier_paid_until": "Abonelik {{date}} tarihine kadar ödendi ve otomatik olarak yenilenecek",
"account_basics_tier_admin_suffix_with_tier": "({{tier}} seviyesiyle)",
"account_basics_tier_admin_suffix_no_tier": "(seviye yok)",
"account_basics_tier_admin_suffix_with_tier": "{{tier}} seviyesiyle",
"account_basics_tier_admin_suffix_no_tier": "seviye yok",
"account_basics_tier_manage_billing_button": "Faturalandırmayı yönet",
"account_usage_reservations_title": "Ayırtılan konular",
"account_usage_reservations_none": "Bu hesap için ayırtılan konu yok",
@@ -405,5 +405,59 @@
"web_push_unknown_notification_body": "Web uygulamasını açarak ntfy'yi güncellemeniz gerekebilir",
"subscribe_dialog_subscribe_use_another_background_info": "Web uygulaması açık değilken diğer sunuculardan gelen bildirimler alınmayacaktır",
"account_basics_cannot_edit_or_delete_provisioned_user": "Yetkilendirilmiş kullanıcı düzenlenemez veya silinemez",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Sağlanmış belirteci düzenleyemez veya silemezsiniz"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Sağlanmış belirteci düzenleyemez veya silemezsiniz",
"common_close": "Kapat",
"common_refresh": "Yenile",
"email_verify_progress_title": "E-posta adresiniz doğrulanıyor...",
"email_verify_success_title": "E-posta adresi doğrulandı",
"email_verify_success_description": "E-posta adresiniz doğrulandı ve hesabınıza eklendi.",
"email_verify_error_title": "Doğrulama başarısız",
"email_verify_error_description": "Bu doğrulama bağlantısı geçersiz veya süresi doldu. Hesap ayarlarınızdan yeni bir bağlantı isteyebilirsiniz.",
"email_verify_button_account": "Hesaba git",
"version_update_available_title": "Yeni sürüm var",
"version_update_available_description": "ntfy sunucusu güncellendi. Lütfen sayfayı yenileyin.",
"signup_form_email": "E-posta adresi (isteğe bağlı, hesap kurtarma için)",
"login_link_forgot_password": "Parolayı unuttum",
"reset_password_request_title": "Parola sıfırla",
"reset_password_request_description": "Kullanıcı adınızı veya e-posta adresinizi girin. Hesabınız varsa, parolanızı sıfırlamanız için e-posta ile bir bağlantı gönderilecek.",
"reset_password_request_primary_required": "Bu, yalnızca birincil e-posta adresinizi eklediyseniz ve doğruladıysanız çalışır.",
"reset_password_request_identifier_label": "Kullanıcı adı veya e-posta adresi",
"reset_password_request_button_submit": "Sıfırlama bağlantısı gönder",
"reset_password_sent_title": "Gelen kutunuza bakın",
"reset_password_sent_description": "Hesabınız varsa, parolanızı sıfırlamanız için e-posta ile bir bağlantı gönderildi.",
"reset_password_back_to_login": "Oturum açmaya dön",
"reset_password_disabled": "Parola sıfırlama devre dışı",
"reset_password_title": "Yeni parola belirle",
"reset_password_form_password": "Yeni parola",
"reset_password_form_confirm": "Yeni parolayı onayla",
"reset_password_form_button_submit": "Parola belirle",
"reset_password_form_error_invalid": "Bu sıfırlama bağlantısı geçersiz veya süresi doldu. Lütfen yeni bir bağlantı isteyin.",
"reset_password_success_title": "Parola değiştirildi",
"reset_password_success_description": "Parolanız değiştirildi. Artık yeni parolanızla oturum açabilirsiniz.",
"action_bar_reload": "Uygulamayı yeniden yükle",
"account_basics_emails_title": "E-posta adresleri",
"account_basics_emails_description": "E-posta bildirimleri ve parola sıfırlama için",
"account_basics_emails_no_emails_yet": "Henüz e-posta yok",
"account_basics_emails_copied_to_clipboard": "E-posta adresi panoya kopyalandı",
"account_basics_emails_chip_actions_primary": "Öntanımlı e-posta adresiniz olarak kullanılan birincil adres. Eylemler için tıklayın.",
"account_basics_emails_chip_actions_verified": "Bildirimler için kullanılabilir. Eylemler için tıklayın.",
"account_basics_emails_chip_actions_unverified": "Adres doğrulanmadı, doğrulamak için gelen kutunuza bakın. Eylemler için tıklayın.",
"account_basics_emails_unverified": "doğrulanmadı",
"account_basics_emails_set_primary": "Birincil e-posta adresi olarak ayarla",
"account_basics_emails_delete": "Adresi kaldır",
"account_basics_emails_resend": "Doğrulama e-postasını yeniden gönder",
"account_basics_emails_resent": "Doğrulama e-postası gönderildi, gelen kutunuza bakın",
"account_basics_emails_primary_elsewhere": "Bu e-posta adresi başka bir hesapta birincil adres olarak kullanılıyor",
"account_basics_emails_no_recovery_warning": "Parolanızı kaybettiğinizde hesabınızı kurtarabilmek için en az bir e-posta adresi ekleyin.",
"account_basics_emails_no_primary_warning": "Parolanızı kaybettiğinizde hesabınızı kurtarabilmek için birincil e-posta adresi ekleyin.",
"account_basics_emails_dialog_title": "E-posta adresi ekle",
"account_basics_emails_dialog_description": "Hesabınıza eklemek için bir e-posta adresi girin. Adresin size ait olduğunu doğrulamak için bir doğrulama bağlantısı gönderilecek.",
"account_basics_emails_dialog_email_label": "E-posta adresi",
"account_basics_emails_dialog_email_placeholder": "örn. user@example.com",
"account_basics_emails_dialog_verify_button": "Doğrulama bağlantısı gönder",
"account_basics_emails_dialog_check_inbox": "Gelen kutunuza bakın ve bu e-posta adresini onaylamak için doğrulama bağlantısına tıklayın. Onaylayana kadar adresiniz doğrulanmadı olarak görünecek.",
"account_basics_tier_provisioned": "Yetkilendirildi",
"account_usage_emails_none": "Bu hesapla e-posta bildirimi gönderilemez",
"prefs_users_dialog_base_url_invalid": "Geçersiz URL biçimi. http:// veya https:// ile başlamalıdır",
"prefs_users_dialog_base_url_exists": "Bu hizmet URL'si için zaten bir kullanıcı var"
}
+60 -6
View File
@@ -27,14 +27,14 @@
"publish_dialog_title_topic": "Опублікувати в {{topic}}",
"publish_dialog_title_no_topic": "Опублікувати сповіщення",
"publish_dialog_progress_uploading": "Завантаження…",
"publish_dialog_message_published": "Сповіщення опубліковано",
"publish_dialog_message_published": "Сповіщення опубліковане",
"publish_dialog_attachment_limits_quota_reached": "перевищує квоту, залишилося {{remainingBytes}}",
"publish_dialog_priority_low": "Низький пріоритет",
"publish_dialog_topic_label": "Назва теми",
"publish_dialog_topic_placeholder": "Назва теми, наприклад phil_alerts",
"publish_dialog_topic_reset": "Скинути тему",
"publish_dialog_title_label": "Заголовок",
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад, Попередження про недостатньо місця на диску",
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад, Попередження про недостачу місця на диску",
"publish_dialog_message_label": "Повідомлення",
"publish_dialog_message_placeholder": "Введіть повідомлення",
"publish_dialog_tags_label": "Теги",
@@ -276,15 +276,15 @@
"publish_dialog_call_placeholder": "Номер телефону, на який потрібно зателефонувати з повідомленням, наприклад, +12223334444 або \"yes\"",
"publish_dialog_chip_call_label": "Телефонний дзвінок",
"publish_dialog_call_reset": "Видалити телефонний дзвінок",
"account_basics_phone_numbers_dialog_description": "Щоб користуватися функцією сповіщення про дзвінки, потрібно додати та верифікувати принаймні один телефонний номер. Верифікацію можна здійснити за допомогою SMS або телефонного дзвінка.",
"account_basics_phone_numbers_dialog_description": "Щоб скористатися функцією сповіщень про дзвінки, вам потрібно додати та підтвердити принаймні один номер телефону. Підтвердження можна здійснити за допомогою SMS або телефонного дзвінка.",
"account_delete_dialog_description": "Це призведе до остаточного видалення вашого облікового запису, включаючи всі дані, які зберігаються на сервері. Після видалення ваше ім'я користувача буде недоступне протягом 7 днів. Якщо ви дійсно хочете продовжити, будь ласка, підтвердьте свій пароль у полі нижче.",
"account_basics_tier_upgrade_button": "Оновлення до Pro",
"account_basics_password_description": "Зміна пароля облікового запису",
"account_usage_of_limit": "з {{limit}}",
"account_usage_unlimited": "Без обмежень",
"account_basics_tier_description": "Рівень потужності вашого облікового запису",
"account_basics_tier_admin_suffix_with_tier": "(з рівнем {{tier}})",
"account_basics_tier_admin_suffix_no_tier": "(без рівня)",
"account_basics_tier_admin_suffix_with_tier": "з рівнем {{tier}}",
"account_basics_tier_admin_suffix_no_tier": "без рівня",
"account_basics_tier_basic": "Базовий",
"account_basics_tier_free": "Безкоштовний",
"account_basics_tier_change_button": "Змінити",
@@ -406,5 +406,59 @@
"web_push_unknown_notification_body": "Можливо вам потрібно оновити ntfy шляхом відкриття вебзастосунку",
"alert_notification_ios_install_required_title": "Необхідне встановлення на iOS",
"account_basics_cannot_edit_or_delete_provisioned_user": "Автоматично створеного користувача не можна редагувати чи видалити",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматично створений токен не можна редагувати чи видалити"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматично створений токен не можна редагувати чи видалити",
"common_close": "Закрити",
"common_refresh": "Оновити",
"login_link_forgot_password": "Забув пароль",
"reset_password_request_title": "Скинути пароль",
"prefs_users_dialog_base_url_invalid": "Неправильний формат URL-адреси. Потрібно, щоб починалось із http:// або https://",
"email_verify_progress_title": "Підтвердження вашої електронної пошти...",
"email_verify_success_title": "Електронну пошту підтверджено",
"email_verify_success_description": "Адресу електронної пошти варифіковано та додано до вашого облікового запису.",
"email_verify_error_title": "Не вдалося перевірити",
"email_verify_error_description": "Це посилання для підтвердження невірне або його термін дії минув. Ви можете запитати нове у налаштуваннях вашого облікового запису.",
"email_verify_button_account": "Перейти до облікового запису",
"version_update_available_title": "Доступна нова версія",
"version_update_available_description": "Сервер ntfy оновлено. Будь ласка, перезавантажте сторінку.",
"signup_form_email": "Адреса електронної пошти (опціонально, для відновлення облікового запису)",
"reset_password_form_confirm": "Підтвердити новий пароль",
"reset_password_form_button_submit": "Встановити пароль",
"reset_password_form_error_invalid": "Це посилання скидання пароля невірне, або його термін дії минув. Будь ласка, запитайте нове.",
"reset_password_success_title": "Пароль змінено",
"reset_password_success_description": "Ваш пароль змінився. Ви тепер можете зайти до облікового запису із новим паролем.",
"action_bar_reload": "Перезавантажити додаток",
"account_basics_emails_title": "Електронні поштові адреси",
"account_basics_emails_description": "Для сповіщень через пошту та скидання пароля",
"prefs_users_dialog_base_url_exists": "Користувач для цієї сервісної URL-адреси вже існує",
"account_usage_emails_none": "З цього облікового запису не можна відправляти сповіщення по електронній пошті",
"account_basics_emails_dialog_title": "Додати адресу електронної пошти",
"reset_password_sent_description": "Якщо цей обліковий запис існує, посилання зі скиданням пароля буде надіслане на адресу електронної пошти.",
"reset_password_back_to_login": "Повернутися до форми входу",
"reset_password_disabled": "Скидади пароль заборонено",
"reset_password_title": "Установити новий пароль",
"reset_password_form_password": "Новий пароль",
"account_basics_emails_no_emails_yet": "Листів ще немає",
"account_basics_emails_copied_to_clipboard": "Адресу електронної пошти скопійовано до буфера обміну",
"account_basics_emails_delete": "Видалити адресу",
"account_basics_emails_dialog_email_label": "Адреса електронної пошти",
"account_basics_emails_dialog_email_placeholder": "наприклад, user@example.com",
"account_basics_emails_dialog_verify_button": "Надіслати посилання для підтвердження",
"reset_password_request_button_submit": "Надіслати посилання для скидання",
"reset_password_sent_title": "Перевірте вхідні листи",
"account_basics_emails_dialog_check_inbox": "Перевірте вхідні електронні листи та перейдіть за посиланням верифікації для підтвердження цієї адреси електронної пошти. До цього моменту вона буде у вас відображена як неперевірена.",
"account_basics_emails_primary_elsewhere": "Ця адреса електронної пошти вже використовуєтсья як основна у іншому обліковому записі",
"account_basics_emails_set_primary": "Зробити основною поштовою адресою",
"account_basics_emails_resend": "Повторно надіслати електронний лист для підтвердження",
"account_basics_emails_resent": "Листа підтвердження надіслано, перевірте свою пошту",
"account_basics_emails_no_recovery_warning": "Додайте принаймні одну адресу електронної пошти, щоб мати змогу відновити свій обліковий запис, якщо ви втратите пароль.",
"account_basics_emails_no_primary_warning": "Додайте основну адресу електронної пошти, щоб мати змогу відновити свій обліковий запис, якщо ви втратите пароль.",
"account_basics_emails_dialog_description": "Введіть адресу електронної пошти, щоб додати її до свого облікового запису. Вам буде надіслано посилання для підтвердження того, що вона ваша.",
"reset_password_request_description": "Введіть ім’я користувача або адресу електронної пошти. Якщо обліковий запис існує, на нього буде надіслано посилання для скидання пароля.",
"reset_password_request_identifier_label": "Ім’я користувача або адреса електронної пошти",
"reset_password_request_primary_required": "Це працює, лише якщо ви вже додали основну адресу електронної пошти та підтвердили її.",
"account_basics_emails_chip_actions_primary": "Основна адреса, яка використовується як ваша адреса електронної пошти за замовчуванням. Натисніть, щоб переглянути дії.",
"account_basics_emails_chip_actions_verified": "Можна використовувати для сповіщень. Натисніть, щоб переглянути дії.",
"account_basics_emails_chip_actions_unverified": "Непідтверджена адреса, перевірте свою поштову скриньку для підтвердження. Натисніть, щоб переглянути дії.",
"account_basics_emails_unverified": "неперевірена",
"account_basics_tier_provisioned": "Створений конфігурацією"
}
+49 -2
View File
@@ -13,7 +13,9 @@ import (
"heckel.io/ntfy/v2/webpush"
)
const testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
const (
testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
)
// Schema layout as written by ntfy releases before the db/schema framework; used to verify
// that existing databases open cleanly without an adoption step
@@ -30,11 +32,14 @@ const (
warned_at INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX idx_endpoint ON subscription (endpoint);
CREATE INDEX idx_subscriber_ip ON subscription (subscriber_ip);
CREATE TABLE subscription_topic (
subscription_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic)
PRIMARY KEY (subscription_id, topic),
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
);
CREATE INDEX idx_topic ON subscription_topic (topic);
CREATE TABLE schemaVersion (id INT PRIMARY KEY, version INT NOT NULL);
INSERT INTO schemaVersion VALUES (1, 1);
`
@@ -49,16 +54,58 @@ const (
updated_at BIGINT NOT NULL,
warned_at BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip);
CREATE INDEX idx_webpush_updated_at ON webpush_subscription (updated_at);
CREATE INDEX idx_webpush_user_id ON webpush_subscription (user_id);
CREATE TABLE webpush_subscription_topic (
subscription_id TEXT NOT NULL REFERENCES webpush_subscription (id) ON DELETE CASCADE,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic)
);
CREATE INDEX idx_webpush_topic ON webpush_subscription_topic (topic);
CREATE TABLE schema_version (store TEXT PRIMARY KEY, version INT NOT NULL);
INSERT INTO schema_version (store, version) VALUES ('webpush', 1);
`
)
// TestStoreSchemaEquivalence verifies that a database adopted from the pre-framework layout is
// structurally identical to a freshly created one: same tables, columns, indexes and keys.
func TestStoreSchemaEquivalence(t *testing.T) {
t.Run("sqlite", func(t *testing.T) {
freshFile := filepath.Join(t.TempDir(), "fresh.db")
fresh, err := webpush.NewSQLiteStore(freshFile, "")
require.Nil(t, err)
defer fresh.Close()
migratedFile := filepath.Join(t.TempDir(), "migrated.db")
d, err := sql.Open("sqlite3", migratedFile)
require.Nil(t, err)
_, err = d.Exec(testPreFrameworkSQLiteSchema)
require.Nil(t, err)
require.Nil(t, d.Close())
migrated, err := webpush.NewSQLiteStore(migratedFile, "")
require.Nil(t, err)
defer migrated.Close()
freshDB, err := sql.Open("sqlite3", freshFile)
require.Nil(t, err)
defer freshDB.Close()
migratedDB, err := sql.Open("sqlite3", migratedFile)
require.Nil(t, err)
defer migratedDB.Close()
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
})
t.Run("postgres", func(t *testing.T) {
freshDB := dbtest.CreateTestPostgres(t)
_, err := webpush.NewPostgresStore(freshDB)
require.Nil(t, err)
migratedDB := dbtest.CreateTestPostgres(t)
_, err = migratedDB.Exec(testPreFrameworkPostgresSchema)
require.Nil(t, err)
_, err = webpush.NewPostgresStore(migratedDB)
require.Nil(t, err)
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, migratedDB))
})
}
func TestStoreSQLiteOpensExistingDatabase(t *testing.T) {
filename := filepath.Join(t.TempDir(), "webpush.db")
d, err := sql.Open("sqlite3", filename)